Skip to content

Gui

cibmangotree.gui.main_workflow

Main GUI workflow including all pages.

Functions:

Name Description
gui_main

Launch the NiceGUI interface with a minimal single screen.

gui_main(app)

Launch the NiceGUI interface with a minimal single screen.

Parameters:

Name Type Description Default

app

App

The initialized App instance with storage and suite

required
Source code in src/cibmangotree/gui/main_workflow.py
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def gui_main(app: App):
    """
    Launch the NiceGUI interface with a minimal single screen.

    Args:
        app: The initialized App instance with storage and suite
    """

    # Initialize GUI session for state management
    gui_context = GUIContext(app=app)
    gui_session = GuiSession(context=gui_context)

    @ui.page(gui_routes.root)
    def start_page():
        """Main/home page using GuiPage abstraction."""
        page = StartPage(session=gui_session)
        page.render()

    @ui.page(gui_routes.select_project)
    def select_project_page():
        """Sub-page showing list of existing projects using GuiPage abstraction."""
        page = SelectProjectPage(session=gui_session)
        page.render()

    @ui.page(gui_routes.new_project)
    def new_project():
        """Sub-page for creating a new project name before importing dataset"""
        page = NewProjectPage(session=gui_session)
        page.render()

    @ui.page(gui_routes.import_dataset)
    def dataset_importing():
        """Sub-page for importing dataset using GuiPage abstraction."""
        page = ImportDatasetPage(session=gui_session)
        page.render()

    @ui.page(gui_routes.preview_dataset)
    def preview_dataset():
        """Sub-page for rendering preview of the imported dataset"""
        page = PreviewDatasetPage(session=gui_session)
        page.render()

    @ui.page(gui_routes.select_analyzer_fork)
    def select_analyzer_fork():
        page = SelectAnalyzerForkPage(session=gui_session)
        page.render()

    @ui.page(gui_routes.configure_analysis)
    def configure_analysis():
        """Combined analysis configuration page with stepper."""
        page = AnalysisConfigAndRunPage(session=gui_session)
        page.render()

    @ui.page(gui_routes.select_previous_analyzer)
    def select_previous_analyzer():
        """Previous analyzer selection page using GuiPage abstraction."""
        page = SelectPreviousAnalyzerPage(session=gui_session)
        page.render()

    @ui.page(gui_routes.post_analysis)
    def post_analysis():
        """Show options once analysis completes."""
        page = PostAnalysisPage(session=gui_session)
        page.render()

    @ui.page(gui_routes.dashboard)
    def dashboard():
        """
        Results dashboard page.

        Dispatches to the correct analyzer-specific dashboard based on
        the currently selected analyzer stored in the session.
        Falls back to a 'not yet available' notice for analyzers that
        do not have a dashboard implemented yet.
        """
        analyzer_id = (
            gui_session.selected_analyzer.id if gui_session.selected_analyzer else None
        )
        dashboard_class = get_dashboard(analyzer_id)

        if dashboard_class is not None:
            page = dashboard_class(session=gui_session)
            page.render()
        else:
            PlaceholderDashboard(session=gui_session).render()

    ui.run(
        native=True,
        title="CIB Mango Tree",
        favicon="🥭",
        reload=False,
    )

cibmangotree.gui.base

Base abstractions for GUI pages using Pydantic and ABC.

This module provides: - GuiPage: Abstract base class for all GUI pages - format_file_size: Utility for human-readable file sizes - present_separator: Utility for displaying separator characters

Classes:

Name Description
GuiPage

Abstract base class for all GUI pages.

Functions:

Name Description
format_file_size

Format file size in human-readable format.

present_separator

Format separator/quote character for display.

GuiPage

Bases: BaseModel, ABC

Abstract base class for all GUI pages.

Provides common page structure and lifecycle using the Template Method pattern. Subclasses implement render_content() for page-specific UI while inheriting consistent header/footer rendering.

Exit lifecycle: - on_exit(): Override to perform cleanup when leaving the page - requires_exit_confirmation(): Override to trigger confirmation dialog - get_exit_confirmation_message(): Override to customize confirmation text

Attributes:

Name Type Description
session GuiSession

Session state container with app context

route str

URL route for this page (e.g., "/", "/projects")

title str

Page title shown in header

show_back_button bool

Whether to show back navigation button

back_route str | None

Route to navigate when back button clicked

back_icon str

Icon for back button (default: "arrow_back")

back_text str | None

Optional text label for back button

show_footer bool

Whether to render footer

Usage
class MyPage(GuiPage):
    def __init__(self, session: GuiSession):
        super().__init__(
            session=session,
            route="/my_page",
            title="My Page",
            show_back_button=True,
            back_route="/",
        )

    def render_content(self) -> None:
        with ui.column().classes("items-center"):
            ui.label("My page content")

    def requires_exit_confirmation(self) -> bool:
        return self.session.current_analysis is not None

    def on_exit(self) -> None:
        self.session.reset_analysis_workflow()

# Register with NiceGUI
@ui.page("/my_page")
def my_page():
    page = MyPage(session)
    page.render()

Methods:

Name Description
get_exit_confirmation_message

Override to customize the exit confirmation message.

go_back

Navigate to the configured back route.

go_home

Navigate to home page.

navigate_to

Navigate to another page in the application.

navigate_to_external

Navigate to external URL in new tab.

notify_error

Show error notification.

notify_success

Show success notification.

notify_warning

Show warning notification.

on_exit

Override to perform cleanup when leaving the page.

render

Main rendering method implementing template pattern.

render_content

Render page-specific content.

requires_exit_confirmation

Override to trigger confirmation dialog before leaving.

Source code in src/cibmangotree/gui/base.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
class GuiPage(BaseModel, abc.ABC):
    """
    Abstract base class for all GUI pages.

    Provides common page structure and lifecycle using the Template Method
    pattern. Subclasses implement `render_content()` for page-specific UI
    while inheriting consistent header/footer rendering.

    Exit lifecycle:
    - `on_exit()`: Override to perform cleanup when leaving the page
    - `requires_exit_confirmation()`: Override to trigger confirmation dialog
    - `get_exit_confirmation_message()`: Override to customize confirmation text

    Attributes:
        session: Session state container with app context
        route: URL route for this page (e.g., "/", "/projects")
        title: Page title shown in header
        show_back_button: Whether to show back navigation button
        back_route: Route to navigate when back button clicked
        back_icon: Icon for back button (default: "arrow_back")
        back_text: Optional text label for back button
        show_footer: Whether to render footer

    Usage:
        ```python
        class MyPage(GuiPage):
            def __init__(self, session: GuiSession):
                super().__init__(
                    session=session,
                    route="/my_page",
                    title="My Page",
                    show_back_button=True,
                    back_route="/",
                )

            def render_content(self) -> None:
                with ui.column().classes("items-center"):
                    ui.label("My page content")

            def requires_exit_confirmation(self) -> bool:
                return self.session.current_analysis is not None

            def on_exit(self) -> None:
                self.session.reset_analysis_workflow()

        # Register with NiceGUI
        @ui.page("/my_page")
        def my_page():
            page = MyPage(session)
            page.render()
        ```
    """

    # Link to main session state/variables
    session: GuiSession

    # Page configuration
    route: str = "/"
    title: str = "CIB Mango Tree"

    # Navigation configuration
    show_back_button: bool = False
    back_route: str | None = None
    back_icon: str = "arrow_back"
    back_text: str | None = None

    # Footer configuration
    show_footer: bool = True

    # Allow arbitrary types
    model_config = ConfigDict(arbitrary_types_allowed=True)

    # main rendering function
    def render(self) -> None:
        """
        Main rendering method implementing template pattern.

        Call this method from the NiceGUI @ui.page decorator to render
        the complete page with header, content, and footer.

        Lifecycle:
        1. Setup colors
        2. Render header
        3. Render content (abstract - implemented by subclasses)
        4. Render footer
        """
        self._setup_colors()
        self._render_header()
        self.render_content()
        if self.show_footer:
            self._render_footer()

    @abc.abstractmethod
    def render_content(self) -> None:
        """
        Render page-specific content.

        Subclasses MUST implement this method to provide the main
        page content. This is called automatically by render().

        Example:
            ```python
            def render_content(self) -> None:
                with ui.column().classes("items-center"):
                    ui.label("Welcome")
                    ui.button("Click me", on_click=self._handle_click)
            ```
        """
        raise NotImplementedError

    def _setup_colors(self) -> None:
        """Setup Mango Tree brand colors for NiceGUI."""
        ui.colors(
            primary=gui_colors.primary,
            secondary=gui_colors.secondary,
            accent=gui_colors.accent,
        )

    def _render_header(self) -> None:
        """
        Render standardized header with 3-column layout.

        Layout:
        - Left: Back button (if show_back_button=True)
        - Center: Page title
        - Right: Home button (if not on home page)
        """
        with ui.header(elevated=True):
            with ui.row().classes("w-full items-center justify-between"):
                # Left: Back button or spacer
                with ui.element("div").classes("flex items-center"):
                    if self.show_back_button and self.back_route:
                        # Build button parameters conditionally
                        btn_kwargs = {
                            "icon": self.back_icon,
                            "color": "accent",
                            "on_click": self._handle_back_click,
                        }
                        if self.back_text:
                            btn_kwargs["text"] = self.back_text
                        ui.button(**btn_kwargs).props("flat")

                # Center: Title
                ui.label(self.title).classes("text-h6")

                # Right: Home button (if not on home page)
                with ui.element("div").classes("flex items-center"):
                    if self.show_back_button:  # Not on home if back button shown
                        ui.button(
                            icon="home",
                            color="accent",
                            on_click=self._handle_home_click,
                        ).props("flat")

    async def _handle_back_click(self) -> None:
        """Handle back button click with optional exit confirmation."""
        if self.requires_exit_confirmation():
            dialog = ExitConfirmationDialog(
                message=self.get_exit_confirmation_message(),
            )
            confirmed = await dialog
            if not confirmed:
                return

        self.on_exit()

        if self.back_route:
            self.navigate_to(self.back_route)

    async def _handle_home_click(self) -> None:
        """Handle home button click with optional exit confirmation."""
        if self.requires_exit_confirmation():
            dialog = ExitConfirmationDialog(
                message=self.get_exit_confirmation_message(),
            )
            confirmed = await dialog
            if not confirmed:
                return

        self.on_exit()
        self.navigate_to("/")

    def on_exit(self) -> None:
        """Override to perform cleanup when leaving the page.

        Called after exit confirmation (if any) is accepted,
        before navigation occurs.
        """
        pass

    def requires_exit_confirmation(self) -> bool:
        """Override to trigger confirmation dialog before leaving.

        Returns True to show confirmation, False to navigate directly.
        """
        return False

    def get_exit_confirmation_message(self) -> str:
        """Override to customize the exit confirmation message.

        Only used when requires_exit_confirmation() returns True.
        """
        return "Are you sure you want to leave this page?"

    def _render_footer(self) -> None:
        """
        Render standardized footer with 3-column layout.

        Layout:
        - Left: License information
        - Center: Project attribution
        - Right: External links (GitHub, Instagram)
        """
        with ui.footer(elevated=True):
            with (
                ui.row()
                .classes("w-full items-center")
                .style("justify-content: space-between")
            ):
                # Left: License
                with ui.element("div").classes("flex items-center"):
                    ui.label("MIT License").classes("text-sm text-bold")

                # Center: Project attribution
                with ui.element("div").classes("flex items-center gap-2"):
                    with ui.link(target=gui_urls.website_url, new_tab=True).classes(
                        "inline-flex items-center no-underline"
                    ):
                        with ui.element("div").classes("bg-white rounded-full p-1.5"):
                            ui.html(
                                self._load_svg_icon("cibmt_logo"), sanitize=False
                            ).classes("size-5")
                        ui.tooltip("Visit cibmangotree.org")
                    ui.label("A Civic Tech DC Project").classes("text-sm text-bold")

                # Right: External links
                self._render_footer_links()

    def _render_footer_links(self) -> None:
        """Render social media links in footer."""
        with ui.element("div").classes("flex items-center gap-3"):
            # GitHub button
            with ui.link(target=gui_urls.github_url, new_tab=True).classes(
                "inline-flex items-center justify-center text-white no-underline size-5"
            ):
                ui.html(self._load_svg_icon("github"), sanitize=False).classes(
                    "size-full fill-current"
                )
                ui.tooltip("Visit our GitHub")

            with ui.link(target=gui_urls.instagram_url, new_tab=True).classes(
                "inline-flex items-center justify-center text-white no-underline size-5"
            ):
                ui.html(self._load_svg_icon("instagram"), sanitize=False).classes(
                    "size-full fill-current"
                )
                ui.tooltip("Follow us on Instagram")

    # Navigation helpers
    def navigate_to(self, route: str) -> None:
        """
        Navigate to another page in the application.

        Args:
            route: Target route path (e.g., "/projects", "/new_project")
        """
        ui.navigate.to(route)

    def navigate_to_external(self, url: str) -> None:
        """
        Navigate to external URL in new tab.

        Args:
            url: External URL to open
        """
        ui.navigate.to(url, new_tab=True)

    def go_back(self) -> None:
        """Navigate to the configured back route."""
        if self.back_route:
            self.navigate_to(self.back_route)

    def go_home(self) -> None:
        """Navigate to home page."""
        self.navigate_to("/")

    # utilities
    def _load_svg_icon(self, icon_name: str) -> str:
        """
        Load SVG icon from the icons directory.

        Args:
            icon_name: Name of icon file (without .svg extension)

        Returns:
            SVG content as string
        """
        icon_path = Path(__file__).parent / "icons" / f"{icon_name}.svg"
        return icon_path.read_text()

    def notify_success(self, message: str) -> None:
        """Show success notification."""
        ui.notify(message, type="positive", color="secondary")

    def notify_warning(self, message: str) -> None:
        """Show warning notification."""
        ui.notify(message, type="warning")

    def notify_error(self, message: str) -> None:
        """Show error notification."""
        ui.notify(message, type="negative")

    @contextmanager
    def centered_content(
        self,
        max_width: str | None = None,
        height: str = "80vh",
        padding: str | None = None,
        justify: str = "center",
    ) -> Generator[ui.column, None, None]:
        style = f"height: {height}; width: 100%"
        if max_width:
            style += f"; max-width: {max_width}; margin: 0 auto"
        if padding:
            style += f"; padding: {padding}"
        container = (
            ui.column().classes(f"items-center justify-{justify} gap-8").style(style)
        )
        with container:
            yield container

    def require_project(self) -> bool:
        if not self.session.current_project:
            self.notify_warning("No project selected. Redirecting...")
            self.navigate_to(gui_routes.select_project)
            return False
        return True

    def require_file(self) -> bool:
        if not self.session.selected_file:
            self.notify_warning("No file selected. Redirecting...")
            self.navigate_to(gui_routes.import_dataset)
            return False
        return True

get_exit_confirmation_message()

Override to customize the exit confirmation message.

Only used when requires_exit_confirmation() returns True.

Source code in src/cibmangotree/gui/base.py
221
222
223
224
225
226
def get_exit_confirmation_message(self) -> str:
    """Override to customize the exit confirmation message.

    Only used when requires_exit_confirmation() returns True.
    """
    return "Are you sure you want to leave this page?"

go_back()

Navigate to the configured back route.

Source code in src/cibmangotree/gui/base.py
301
302
303
304
def go_back(self) -> None:
    """Navigate to the configured back route."""
    if self.back_route:
        self.navigate_to(self.back_route)

go_home()

Navigate to home page.

Source code in src/cibmangotree/gui/base.py
306
307
308
def go_home(self) -> None:
    """Navigate to home page."""
    self.navigate_to("/")

navigate_to(route)

Navigate to another page in the application.

Parameters:

Name Type Description Default
route
str

Target route path (e.g., "/projects", "/new_project")

required
Source code in src/cibmangotree/gui/base.py
283
284
285
286
287
288
289
290
def navigate_to(self, route: str) -> None:
    """
    Navigate to another page in the application.

    Args:
        route: Target route path (e.g., "/projects", "/new_project")
    """
    ui.navigate.to(route)

navigate_to_external(url)

Navigate to external URL in new tab.

Parameters:

Name Type Description Default
url
str

External URL to open

required
Source code in src/cibmangotree/gui/base.py
292
293
294
295
296
297
298
299
def navigate_to_external(self, url: str) -> None:
    """
    Navigate to external URL in new tab.

    Args:
        url: External URL to open
    """
    ui.navigate.to(url, new_tab=True)

notify_error(message)

Show error notification.

Source code in src/cibmangotree/gui/base.py
332
333
334
def notify_error(self, message: str) -> None:
    """Show error notification."""
    ui.notify(message, type="negative")

notify_success(message)

Show success notification.

Source code in src/cibmangotree/gui/base.py
324
325
326
def notify_success(self, message: str) -> None:
    """Show success notification."""
    ui.notify(message, type="positive", color="secondary")

notify_warning(message)

Show warning notification.

Source code in src/cibmangotree/gui/base.py
328
329
330
def notify_warning(self, message: str) -> None:
    """Show warning notification."""
    ui.notify(message, type="warning")

on_exit()

Override to perform cleanup when leaving the page.

Called after exit confirmation (if any) is accepted, before navigation occurs.

Source code in src/cibmangotree/gui/base.py
206
207
208
209
210
211
212
def on_exit(self) -> None:
    """Override to perform cleanup when leaving the page.

    Called after exit confirmation (if any) is accepted,
    before navigation occurs.
    """
    pass

render()

Main rendering method implementing template pattern.

Call this method from the NiceGUI @ui.page decorator to render the complete page with header, content, and footer.

Lifecycle: 1. Setup colors 2. Render header 3. Render content (abstract - implemented by subclasses) 4. Render footer

Source code in src/cibmangotree/gui/base.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def render(self) -> None:
    """
    Main rendering method implementing template pattern.

    Call this method from the NiceGUI @ui.page decorator to render
    the complete page with header, content, and footer.

    Lifecycle:
    1. Setup colors
    2. Render header
    3. Render content (abstract - implemented by subclasses)
    4. Render footer
    """
    self._setup_colors()
    self._render_header()
    self.render_content()
    if self.show_footer:
        self._render_footer()

render_content() abstractmethod

Render page-specific content.

Subclasses MUST implement this method to provide the main page content. This is called automatically by render().

Example
def render_content(self) -> None:
    with ui.column().classes("items-center"):
        ui.label("Welcome")
        ui.button("Click me", on_click=self._handle_click)
Source code in src/cibmangotree/gui/base.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
@abc.abstractmethod
def render_content(self) -> None:
    """
    Render page-specific content.

    Subclasses MUST implement this method to provide the main
    page content. This is called automatically by render().

    Example:
        ```python
        def render_content(self) -> None:
            with ui.column().classes("items-center"):
                ui.label("Welcome")
                ui.button("Click me", on_click=self._handle_click)
        ```
    """
    raise NotImplementedError

requires_exit_confirmation()

Override to trigger confirmation dialog before leaving.

Returns True to show confirmation, False to navigate directly.

Source code in src/cibmangotree/gui/base.py
214
215
216
217
218
219
def requires_exit_confirmation(self) -> bool:
    """Override to trigger confirmation dialog before leaving.

    Returns True to show confirmation, False to navigate directly.
    """
    return False

format_file_size(size_bytes)

Format file size in human-readable format.

Parameters:

Name Type Description Default

size_bytes

int

File size in bytes

required

Returns:

Type Description
str

Formatted string (e.g., "1.5 MB", "3.2 GB")

Example

format_file_size(1536) '1.5 KB' format_file_size(1048576) '1.0 MB'

Source code in src/cibmangotree/gui/base.py
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
def format_file_size(size_bytes: int) -> str:
    """
    Format file size in human-readable format.

    Args:
        size_bytes: File size in bytes

    Returns:
        Formatted string (e.g., "1.5 MB", "3.2 GB")

    Example:
        >>> format_file_size(1536)
        '1.5 KB'
        >>> format_file_size(1048576)
        '1.0 MB'
    """
    output_size: float = float(size_bytes)

    for unit in ["B", "KB", "MB", "GB", "TB"]:
        if output_size < 1024:
            return f"{output_size:.1f} {unit}"

        output_size /= 1024

    return f"{output_size:.1f} PB"

present_separator(value)

Format separator/quote character for display.

Parameters:

Name Type Description Default

value

str

Separator character

required

Returns:

Type Description
str

Human-readable representation

Example

present_separator("\t") 'Tab' present_separator(",") ', (Comma)'

Source code in src/cibmangotree/gui/base.py
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
def present_separator(value: str) -> str:
    """
    Format separator/quote character for display.

    Args:
        value: Separator character

    Returns:
        Human-readable representation

    Example:
        >>> present_separator("\\t")
        'Tab'
        >>> present_separator(",")
        ', (Comma)'
    """
    mapping = {
        "\t": "Tab",
        " ": "Space",
        ",": ", (Comma)",
        ";": "; (Semicolon)",
        "'": "' (Single quote)",
        '"': '" (Double quote)',
        "|": "| (Pipe)",
    }
    return mapping.get(value, value)

cibmangotree.gui.context

GUI context - similar to ViewContext but for NiceGUI.

Classes:

Name Description
GUIContext

Context for GUI mode, wrapping the App instance.

GUIContext

Bases: BaseModel

Context for GUI mode, wrapping the App instance.

Source code in src/cibmangotree/gui/context.py
10
11
12
13
14
class GUIContext(BaseModel):
    """Context for GUI mode, wrapping the App instance."""

    app: App = Field(...)
    model_config = ConfigDict(arbitrary_types_allowed=True)

cibmangotree.gui.session

Classes:

Name Description
GuiSession

Application-wide session state container.

GuiSession

Bases: BaseModel

Application-wide session state container.

Replaces module-level global variables with a type-safe, validated state container. Provides access to application context and workflow state.

Attributes:

Name Type Description
context GUIContext

Application context wrapping App instance

current_project ProjectContext | None

Currently selected/active project

selected_file_path Path | None

Path to file selected for import

new_project_name str | None

Name for project being created

import_session ImporterSession | None

Active importer session for data preview

selected_analyzer AnalyzerInterface | None

ID of selected primary analyzer

current_analysis AnalysisModel | None

Currently selected/active analysis

project_loaded_from_storage bool

True if project was loaded from disk

analysis_loaded_from_storage bool

True if analysis was loaded from disk

Example
context = GUIContext(app=app)
session = GuiSession(context=context)

# Set project
session.current_project = project

# Access app
projects = session.app.list_projects()

Methods:

Name Description
reset_analysis_workflow

Clear analysis workflow state.

reset_project_workflow

Clear project creation workflow state.

validate_file_selected

Check if a file is currently selected.

validate_project_name_set

Check if new project name is set.

validate_project_selected

Check if a project is currently selected.

Source code in src/cibmangotree/gui/session.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
class GuiSession(BaseModel):
    """
    Application-wide session state container.

    Replaces module-level global variables with a type-safe,
    validated state container. Provides access to
    application context and workflow state.

    Attributes:
        context: Application context wrapping App instance
        current_project: Currently selected/active project
        selected_file_path: Path to file selected for import
        new_project_name: Name for project being created
        import_session: Active importer session for data preview
        selected_analyzer: ID of selected primary analyzer
        current_analysis: Currently selected/active analysis
        project_loaded_from_storage: True if project was loaded from disk
        analysis_loaded_from_storage: True if analysis was loaded from disk

    Example:
        ```python
        context = GUIContext(app=app)
        session = GuiSession(context=context)

        # Set project
        session.current_project = project

        # Access app
        projects = session.app.list_projects()
        ```
    """

    # Core context
    context: GUIContext

    # Workflow state - project creation
    current_project: ProjectContext | None = None
    selected_file_path: Path | None = None
    selected_file_name: str | None = None
    selected_file: BytesIO | None = None
    selected_file_content_type: str | None = None
    new_project_name: str | None = None
    import_session: ImporterSession | None = None

    # Workflow state - analysis
    selected_analyzer: AnalyzerInterface | None = None
    selected_analyzer_name: str | None = None
    column_mapping: dict[str, str] | None = None
    current_analysis: AnalysisModel | None = None
    analysis_params: dict[str, ParamValue] | None = None

    # Source tracking flags
    project_loaded_from_storage: bool = False
    analysis_loaded_from_storage: bool = False

    # Allow arbitrary types (for NiceGUI components, ImporterSession, etc.)
    model_config = ConfigDict(arbitrary_types_allowed=True)

    @property
    def app(self):
        """Access underlying App instance."""
        return self.context.app

    def reset_project_workflow(self) -> None:
        """Clear project creation workflow state."""
        self.current_project = None
        self.selected_file_path = None
        self.selected_file_name = None
        self.selected_file = None
        self.selected_file_content_type = None
        self.new_project_name = None
        self.import_session = None
        self.project_loaded_from_storage = False

    def reset_analysis_workflow(self) -> None:
        """Clear analysis workflow state."""
        self.selected_analyzer = None
        self.selected_analyzer_name = None
        self.column_mapping = None
        self.current_analysis = None
        self.analysis_params = None
        self.analysis_loaded_from_storage = False

    def validate_project_selected(self) -> bool:
        """Check if a project is currently selected."""
        return self.current_project is not None

    def validate_file_selected(self) -> bool:
        """Check if a file is currently selected."""
        return self.selected_file is not None

    def validate_project_name_set(self) -> bool:
        """Check if new project name is set."""
        return bool(self.new_project_name and self.new_project_name.strip())

app property

Access underlying App instance.

reset_analysis_workflow()

Clear analysis workflow state.

Source code in src/cibmangotree/gui/session.py
89
90
91
92
93
94
95
96
def reset_analysis_workflow(self) -> None:
    """Clear analysis workflow state."""
    self.selected_analyzer = None
    self.selected_analyzer_name = None
    self.column_mapping = None
    self.current_analysis = None
    self.analysis_params = None
    self.analysis_loaded_from_storage = False

reset_project_workflow()

Clear project creation workflow state.

Source code in src/cibmangotree/gui/session.py
78
79
80
81
82
83
84
85
86
87
def reset_project_workflow(self) -> None:
    """Clear project creation workflow state."""
    self.current_project = None
    self.selected_file_path = None
    self.selected_file_name = None
    self.selected_file = None
    self.selected_file_content_type = None
    self.new_project_name = None
    self.import_session = None
    self.project_loaded_from_storage = False

validate_file_selected()

Check if a file is currently selected.

Source code in src/cibmangotree/gui/session.py
102
103
104
def validate_file_selected(self) -> bool:
    """Check if a file is currently selected."""
    return self.selected_file is not None

validate_project_name_set()

Check if new project name is set.

Source code in src/cibmangotree/gui/session.py
106
107
108
def validate_project_name_set(self) -> bool:
    """Check if new project name is set."""
    return bool(self.new_project_name and self.new_project_name.strip())

validate_project_selected()

Check if a project is currently selected.

Source code in src/cibmangotree/gui/session.py
 98
 99
100
def validate_project_selected(self) -> bool:
    """Check if a project is currently selected."""
    return self.current_project is not None

cibmangotree.gui.routes

Classes:

Name Description
GuiRoutes

Container for

GuiRoutes

Bases: BaseModel

Container for

Source code in src/cibmangotree/gui/routes.py
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class GuiRoutes(BaseModel):
    """Container for"""

    root: str = "/"
    import_dataset: str = "/import_dataset"
    new_project: str = "/new_project"
    select_project: str = "/select_project"
    select_analyzer_fork: str = "/select_analyzer_fork"
    select_previous_analyzer: str = "/select_previous_analyzer"
    configure_analysis: str = "/configure_analysis"
    preview_dataset: str = "/preview_dataset"
    post_analysis: str = "/post_analysis"
    dashboard: str = "/dashboard"

cibmangotree.gui.theme

Classes:

Name Description
GuiColors

Mango Tree brand colors

GuiConstants

Container for both colors and urls

GuiURLS

UI URL constants.

GuiColors

Bases: BaseModel

Mango Tree brand colors

Source code in src/cibmangotree/gui/theme.py
 9
10
11
12
13
14
15
16
17
18
19
class GuiColors(BaseModel):
    """Mango Tree brand colors"""

    model_config = ConfigDict(frozen=True)

    primary: str = Field(default=MANGO_DARK_GREEN, description="Mango dark green")
    secondary: str = Field(default=MANGO_ORANGE_LIGHT, description="Mango orange light")
    accent: str = Field(default=ACCENT, description="Accent color")

    # Additional colors for reference
    mango_orange: str = Field(default=MANGO_ORANGE, description="Mango orange")

GuiConstants

Bases: BaseModel

Container for both colors and urls

Source code in src/cibmangotree/gui/theme.py
43
44
45
46
47
48
49
class GuiConstants(BaseModel):
    """Container for both colors and urls"""

    model_config = ConfigDict(arbitrary_types_allowed=True)

    colors: GuiColors = Field(...)
    urls: GuiURLS = Field(...)

GuiURLS

Bases: BaseModel

UI URL constants.

Source code in src/cibmangotree/gui/theme.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class GuiURLS(BaseModel):
    """UI URL constants."""

    model_config = ConfigDict(frozen=True)

    # External URLs
    github_url: str = Field(
        default="https://github.com/civictechdc/cib-mango-tree",
        description="GitHub repository URL",
    )
    instagram_url: str = Field(
        default="https://www.instagram.com/cibmangotree",
        description="Instagram profile URL",
    )
    website_url: str = Field(
        default="https://cibmangotree.org",
        description="Official website URL",
    )

cibmangotree.gui.components

Modules:

Name Description
analysis
exit_confirmation
export_outputs
import_options

Import options dialog for modifying CSV/Excel import configuration.

manage_analyses
manage_projects
stepper_steps
toggle

Classes:

Name Description
AnalysisParamsCard

Card component for configuring analyzer parameters.

ExitConfirmationDialog

Reusable confirmation dialog for page exit navigation.

ExportDialog

Multi-step dialog for selecting and exporting analysis outputs.

ImportOptionsDialog

Dialog for configuring CSV/Excel import options.

ManageAnalysisDialog

Dialog for managing analyses (view and delete).

ManageProjectsDialog

Dialog for managing projects (view and delete).

ToggleButton
ToggleButtonGroup

Manages a group of toggle buttons with mutual exclusivity.

AnalysisParamsCard

Card component for configuring analyzer parameters.

Displays each parameter as an individual card, matching the visual style of the column picker cards for consistent UX.

Methods:

Name Description
__init__

Initialize the analysis parameters card.

get_param_values

Retrieve current parameter values from the UI controls.

Source code in src/cibmangotree/gui/components/analysis.py
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
class AnalysisParamsCard:
    """
    Card component for configuring analyzer parameters.

    Displays each parameter as an individual card, matching the visual style
    of the column picker cards for consistent UX.
    """

    def __init__(
        self,
        params: list[AnalyzerParam],
        default_values: dict[str, ParamValue],
    ):
        """
        Initialize the analysis parameters card.

        Args:
            params: List of analyzer parameter specifications
            default_values: Dictionary of default parameter values
        """
        self.params = params
        self.default_values = default_values
        self.param_widgets: dict[str, tuple] = {}

        # Build the card UI
        self._build_card()

    def _build_card(self):
        """Build the parameter configuration cards in a flex-wrap row."""
        if not self.params:
            with ui.column().classes("w-full items-center"):
                ui.label("This analyzer has no configurable parameters.").classes(
                    "text-grey-7"
                )
            return

        with ui.row().classes("flex-wrap gap-6 justify-center w-full"):
            for param in self.params:
                self._build_param_card(param)

    def _build_param_card(self, param: AnalyzerParam):
        """Build an individual card for a single parameter."""
        with ui.card().classes("w-72 p-4 no-shadow border border-gray-200"):
            with ui.row().classes("items-center gap-1"):
                ui.label(param.print_name).classes("text-bold")
                if param.description:
                    with ui.icon("info").classes("text-grey-6 cursor-pointer"):
                        ui.tooltip(param.description)

            param_type = param.type
            default_value = self.default_values.get(param.id)

            if param_type.type == "integer":
                self._build_integer_control(param, param_type, default_value)
            elif param_type.type == "time_binning":
                self._build_time_binning_control(param, default_value)

    def _build_integer_control(
        self,
        param: AnalyzerParam,
        param_type: IntegerParam,
        default_value: ParamValue | None,
    ):
        """Build integer parameter control."""
        int_default = default_value if isinstance(default_value, int) else None
        number_input = ui.number(
            value=int_default if int_default is not None else param_type.min,
            min=param_type.min,
            max=param_type.max,
            step=1,
            precision=0,
            validation={
                f"Must be at least {param_type.min}": lambda v: v >= param_type.min,
                f"Must be at most {param_type.max}": lambda v: v <= param_type.max,
            },
        ).classes("w-full mt-2")

        self.param_widgets[param.id] = ("integer", number_input)

    def _build_time_binning_control(
        self, param: AnalyzerParam, default_value: ParamValue | None
    ):
        """Build time binning parameter control."""
        tb_default = (
            default_value if isinstance(default_value, TimeBinningValue) else None
        )
        with ui.row().classes("gap-2 mt-2 w-full"):
            unit_select = ui.select(
                {
                    "year": "Year",
                    "month": "Month",
                    "week": "Week",
                    "day": "Day",
                    "hour": "Hour",
                    "minute": "Minute",
                    "second": "Second",
                },
                value=tb_default.unit if tb_default else "day",
            ).classes("w-32")

            amount_input = ui.number(
                value=tb_default.amount if tb_default else 1,
                min=1,
                max=1000,
                step=1,
                precision=0,
                validation={
                    "Must be at least 1": lambda v: v >= 1,
                    "Cannot exceed 1000": lambda v: v <= 1000,
                },
            ).classes("w-24")

        self.param_widgets[param.id] = ("time_binning", unit_select, amount_input)

    def get_param_values(self) -> dict[str, ParamValue]:
        """
        Retrieve current parameter values from the UI controls.

        Returns:
            Dictionary mapping parameter IDs to their values
        """
        param_values = {}

        for param_id, widgets in self.param_widgets.items():
            param_type = widgets[0]

            if param_type == "integer":
                number_input = widgets[1]
                param_values[param_id] = int(number_input.value)

            elif param_type == "time_binning":
                unit_toggle = widgets[1]
                amount_input = widgets[2]
                param_values[param_id] = TimeBinningValue(
                    unit=unit_toggle.value, amount=int(amount_input.value)
                )

        return param_values

__init__(params, default_values)

Initialize the analysis parameters card.

Parameters:

Name Type Description Default
params
list[AnalyzerParam]

List of analyzer parameter specifications

required
default_values
dict[str, ParamValue]

Dictionary of default parameter values

required
Source code in src/cibmangotree/gui/components/analysis.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def __init__(
    self,
    params: list[AnalyzerParam],
    default_values: dict[str, ParamValue],
):
    """
    Initialize the analysis parameters card.

    Args:
        params: List of analyzer parameter specifications
        default_values: Dictionary of default parameter values
    """
    self.params = params
    self.default_values = default_values
    self.param_widgets: dict[str, tuple] = {}

    # Build the card UI
    self._build_card()

get_param_values()

Retrieve current parameter values from the UI controls.

Returns:

Type Description
dict[str, ParamValue]

Dictionary mapping parameter IDs to their values

Source code in src/cibmangotree/gui/components/analysis.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def get_param_values(self) -> dict[str, ParamValue]:
    """
    Retrieve current parameter values from the UI controls.

    Returns:
        Dictionary mapping parameter IDs to their values
    """
    param_values = {}

    for param_id, widgets in self.param_widgets.items():
        param_type = widgets[0]

        if param_type == "integer":
            number_input = widgets[1]
            param_values[param_id] = int(number_input.value)

        elif param_type == "time_binning":
            unit_toggle = widgets[1]
            amount_input = widgets[2]
            param_values[param_id] = TimeBinningValue(
                unit=unit_toggle.value, amount=int(amount_input.value)
            )

    return param_values

ExitConfirmationDialog

Bases: dialog

Reusable confirmation dialog for page exit navigation.

Subclasses ui.dialog to match the pattern used by ManageProjectsDialog and ImportOptionsDialog.

Usage

dialog = ExitConfirmationDialog( message="You have unsaved changes. Leave anyway?", confirm_text="Leave", cancel_text="Stay" ) confirmed = await dialog if confirmed: ui.navigate.to("/home")

Source code in src/cibmangotree/gui/components/exit_confirmation.py
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class ExitConfirmationDialog(ui.dialog):
    """Reusable confirmation dialog for page exit navigation.

    Subclasses ui.dialog to match the pattern used by
    ManageProjectsDialog and ImportOptionsDialog.

    Usage:
        dialog = ExitConfirmationDialog(
            message="You have unsaved changes. Leave anyway?",
            confirm_text="Leave",
            cancel_text="Stay"
        )
        confirmed = await dialog
        if confirmed:
            ui.navigate.to("/home")
    """

    def __init__(
        self,
        message: str = "Are you sure you want to leave?",
        confirm_text: str = "Leave",
        cancel_text: str = "Stay",
    ):
        super().__init__()
        with self, ui.card().classes("w-80 items-center"):
            ui.label(message).classes("text-center q-mb-md")
            with ui.row().classes("gap-2"):
                ui.button(
                    cancel_text,
                    on_click=lambda: self.submit(False),
                ).props("flat")
                ui.button(
                    confirm_text,
                    on_click=lambda: self.submit(True),
                    color="negative",
                ).props("flat")

ExportDialog

Bases: dialog

Multi-step dialog for selecting and exporting analysis outputs.

Source code in src/cibmangotree/gui/components/export_outputs.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
class ExportDialog(ui.dialog):
    """Multi-step dialog for selecting and exporting analysis outputs."""

    def __init__(self, analysis_context: AnalysisContext):
        super().__init__()

        self.analysis_context = analysis_context

        self.outputs = sorted(
            analysis_context.get_all_exportable_outputs(),
            key=lambda output: (
                (
                    "0"
                    if output.secondary_spec is None
                    else "1_" + output.secondary_spec.name
                ),
                output.descriptive_qualified_name,
            ),
        )

        self.selected_outputs: list[AnalysisOutputContext] = []
        self.format: SupportedOutputExtension | None = None
        self.exported_paths: list[tuple[str, int]] = []
        self.export_errors: list[str] = []

        self.progress_queue: queue.Queue | None = None
        self.timer: ui.timer | None = None

        self.output_status_rows: dict[str, tuple] = {}

        with (
            self,
            ui.card().classes("w-full").style("min-width: 500px; max-width: 700px"),
        ):
            self._build_select_outputs()
            self._build_configure_export()
            self._build_export_progress()
            self._build_export_complete()

        self._show_step("select_outputs")

    def _show_step(self, step: str):
        steps = {
            "select_outputs": self.select_outputs,
            "configure_export": self.configure_export,
            "export_progress": self.export_progress,
            "export_complete": self.export_complete,
        }
        for name, card in steps.items():
            card.set_visibility(name == step)

    def _add_checkbox_group(
        self, label_text: str, outputs: list[AnalysisOutputContext]
    ):
        ui.label(label_text).classes("text-subtitle2 text-weight-bold q-mt-sm")
        ui.separator()
        for output in outputs:
            cb = ui.checkbox(
                f"{output.descriptive_qualified_name}",
                on_change=lambda _=None: self._on_selection_changed(),
            )
            self.output_checkboxes.append((output, cb))

    def _build_select_outputs(self):
        self.select_outputs = ui.column().classes("w-full")
        with self.select_outputs:
            ui.label("Select outputs to export").classes("text-h6 q-mb-md")

            self.output_checkboxes: list[tuple[AnalysisOutputContext, ui.checkbox]] = []
            self.checkbox_container = ui.column().classes("w-full gap-1 mb-4")

            with self.checkbox_container:
                primary_outputs = [o for o in self.outputs if o.secondary_spec is None]
                secondary_outputs = [
                    o for o in self.outputs if o.secondary_spec is not None
                ]

                if primary_outputs:
                    self._add_checkbox_group("Primary Outputs", primary_outputs)

                if secondary_outputs:
                    sec_names = sorted(
                        {o.secondary_spec.name for o in secondary_outputs}
                    )
                    for sec_name in sec_names:
                        sec_outputs = [
                            o
                            for o in secondary_outputs
                            if o.secondary_spec.name == sec_name
                        ]
                        self._add_checkbox_group("Secondary Outputs", sec_outputs)

            with ui.row().classes("w-full justify-end gap-2"):
                ui.button("Cancel", on_click=self.close, color="secondary")
                self.select_outputs_next = ui.button(
                    "Next",
                    on_click=self._go_to_configure_export,
                    color="primary",
                    icon="arrow_forward",
                )
                self.select_outputs_next.set_enabled(False)

    def _on_selection_changed(self):
        has_selection = any(cb.value for _, cb in self.output_checkboxes)
        self.select_outputs_next.set_enabled(has_selection)

    def _go_to_configure_export(self):
        self.selected_outputs = [
            output for output, cb in self.output_checkboxes if cb.value
        ]

        self.chunking_visible = any(
            output.num_rows > LARGE_OUTPUT_THRESHOLD for output in self.selected_outputs
        )
        self.chunking_section.set_visibility(self.chunking_visible)
        self._show_step("configure_export")

    def _build_configure_export(self):
        self.configure_export = ui.column().classes("w-full")
        with self.configure_export:
            ui.label("Configure export").classes("text-h6 q-mb-md")

            is_hashtags = self.analysis_context.analyzer_id == "hashtags"
            format_options: dict[str, str] = {}
            if not is_hashtags:
                format_options["csv"] = "CSV"
                format_options["xlsx"] = "Excel"
            format_options["json"] = "JSON"

            self.format_toggle = ui.toggle(
                format_options,
                value=list(format_options.keys())[0],
            ).classes("q-mb-md")

            self.chunking_visible = False
            self.chunking_section = ui.column().classes("w-full")
            self.chunking_section.set_visibility(False)
            with self.chunking_section:
                ui.label("Chunking options").classes(
                    "text-subtitle2 text-weight-bold q-mt-sm"
                )
                ui.label(
                    f"Control how outputs larger than "
                    f"{LARGE_OUTPUT_THRESHOLD:,} rows are saved."
                ).classes("q-mb-md text-sm")

                settings = self.analysis_context.app_context.settings
                current_chunk = settings.export_chunk_size
                is_current_chunking = (
                    current_chunk is not None and current_chunk is not False
                )
                default_toggle = (
                    is_current_chunking if current_chunk is not None else True
                )

                self.chunk_toggle = ui.toggle(
                    {True: "Break into chunks", False: "Export in a single file"},
                    value=default_toggle,
                ).classes("q-mb-md")

                self.chunk_size_input = ui.number(
                    "Rows per chunk",
                    value=(
                        current_chunk
                        if isinstance(current_chunk, int)
                        else LARGE_OUTPUT_THRESHOLD
                    ),
                    min=100,
                ).classes("q-mb-md")

                if not is_current_chunking:
                    self.chunk_size_input.set_visibility(False)

                self.chunk_toggle.on_value_change(
                    lambda: self.chunk_size_input.set_visibility(
                        self.chunk_toggle.value
                    )
                )

            with ui.row().classes("w-full justify-end gap-2"):
                ui.button(
                    "Back",
                    on_click=lambda: self._show_step("select_outputs"),
                    color="secondary",
                    icon="arrow_back",
                )
                ui.button(
                    "Start Export",
                    on_click=self._handle_start_export,
                    color="primary",
                    icon="arrow_forward",
                )

    def _handle_start_export(self):
        self.format = self.format_toggle.value

        if self.chunking_visible:
            settings = self.analysis_context.app_context.settings
            if self.chunk_toggle.value:
                settings.set_export_chunk_size(self.chunk_size_input.value)
            else:
                settings.set_export_chunk_size(False)

        self._start_export()

    def _build_export_progress(self):
        self.export_progress = ui.column().classes("w-full")
        with self.export_progress:
            ui.label("Exporting...").classes("text-h6 q-mb-md")

            self.export_status_label = ui.label("Preparing...").classes(
                "text-base q-mb-md"
            )

            self.export_progress_bar = (
                ui.linear_progress(value=0, show_value=False)
                .classes("w-full q-mb-md")
                .props("instant-feedback")
            )

            self.output_status_container = ui.column().classes("w-full gap-1 mb-4")

    def _build_export_complete(self):
        self.export_complete = ui.column().classes("w-full")
        with self.export_complete:
            with ui.row().classes("items-center gap-2 q-mb-md"):
                self.complete_success_icon = ui.icon(
                    "check_circle", color=MANGO_DARK_GREEN, size="lg"
                )
                self.complete_error_icon = ui.icon(
                    "cancel", color="negative", size="lg"
                )
                self.complete_status_label = ui.label("").classes("text-h6")
                self.complete_success_icon.set_visibility(False)
                self.complete_error_icon.set_visibility(False)

            self.complete_message_container = ui.column().classes("w-full gap-1 mb-4")

            with ui.row().classes("w-full justify-end gap-2"):
                self.export_complete_open_folder = ui.button(
                    "Open exports folder",
                    on_click=self._open_folder,
                    color="primary",
                    icon="folder_open",
                )
                ui.button("Close", on_click=self.close, color="secondary")

    def _start_export(self):
        if self.format is None:
            ui.notify("Please select an export format", type="warning")
            self._show_step("configure_export")
            return

        self._show_step("export_progress")

        self.progress_queue = queue.Queue()
        self.exported_paths = []
        self.export_errors = []
        self.output_status_rows.clear()

        self.output_status_container.clear()

        self.export_progress_bar.value = 0

        thread = threading.Thread(
            target=_export_worker,
            args=(
                self.selected_outputs,
                self.format,
                self.progress_queue,
            ),
            daemon=True,
        )
        thread.start()

        self.timer = ui.timer(QUEUE_POLL_INTERVAL, self._poll_progress)

    def _poll_progress(self):
        try:
            msg = self.progress_queue.get_nowait()
        except queue.Empty:
            return

        if msg.msg_type == "output_start":
            self.export_status_label.text = f"Exporting {msg.name}..."
            self.export_progress_bar.value = msg.index / msg.total

            with self.output_status_container:
                with ui.row().classes("items-center gap-2") as row:
                    spinner = ui.spinner("gears", size="sm")
                    label = ui.label(msg.name).classes("text-sm")
                self.output_status_rows[msg.name] = (row, spinner, label)

        elif msg.msg_type == "output_progress":
            self.export_progress_bar.value = (
                msg.index + msg.chunk_progress
            ) / msg.total
            if msg.name in self.output_status_rows:
                _, _, label = self.output_status_rows[msg.name]
                label.text = f"{msg.name} ({msg.chunk_progress * 100:.0f}%)"

        elif msg.msg_type == "output_finish":
            self.export_progress_bar.value = (msg.index + 1) / msg.total
            self.exported_paths.append((msg.path, msg.chunk_count))
            if msg.name in self.output_status_rows:
                _, spinner, label = self.output_status_rows[msg.name]
                spinner.set_visibility(False)
                if msg.chunk_count > 1:
                    display_path = msg.path.replace("_[*]", "")
                    label.text = (
                        f"Exported as {os.path.basename(display_path)} "
                        f"in {msg.chunk_count} chunks"
                    )
                else:
                    label.text = f"Exported as {os.path.basename(msg.path)}"

        elif msg.msg_type == "output_error":
            self.export_errors.append(f"{msg.name}: {msg.error}")
            if msg.name in self.output_status_rows:
                _, spinner, label = self.output_status_rows[msg.name]
                spinner.set_visibility(False)
                label.text = f"Error: {msg.name} \u2014 {msg.error}"

        elif msg.msg_type == "all_complete":
            self._on_export_complete()

    def _on_export_complete(self):
        if self.timer:
            self.timer.cancel()
            self.timer = None

        self.export_progress_bar.value = 1.0
        if self.export_errors:
            self.complete_status_label.text = "Export completed with errors"
            self.complete_error_icon.set_visibility(True)
            self.complete_success_icon.set_visibility(False)
        else:
            self.complete_status_label.text = "Export complete!"
            self.complete_success_icon.set_visibility(True)
            self.complete_error_icon.set_visibility(False)

        self.complete_message_container.clear()
        with self.complete_message_container:
            if self.exported_paths:
                for path, chunk_count in self.exported_paths:
                    if chunk_count > 1:
                        display_path = path.replace("_[*]", "")
                        ui.label(
                            f"Exported {os.path.basename(display_path)} in {chunk_count} chunks"
                        ).classes("text-sm")
                    else:
                        ui.label(f"Exported {os.path.basename(path)}").classes(
                            "text-sm"
                        )

            if self.export_errors:
                for error in self.export_errors:
                    ui.label(error).classes("text-sm")

        self.export_complete_open_folder.set_visibility(bool(self.exported_paths))
        self._show_step("export_complete")

    async def _open_folder(self):
        await run.io_bound(
            open_directory_explorer, self.analysis_context.export_root_path
        )

ImportOptionsDialog

Bases: dialog

Dialog for configuring CSV/Excel import options.

Displays interactive controls for modifying import parameters and allows retrying the import with updated settings.

Methods:

Name Description
__init__

Initialize the import options dialog.

Source code in src/cibmangotree/gui/components/import_options.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
class ImportOptionsDialog(ui.dialog):
    """
    Dialog for configuring CSV/Excel import options.

    Displays interactive controls for modifying import parameters
    and allows retrying the import with updated settings.
    """

    def __init__(
        self,
        import_session: CsvImportSession | ExcelImportSession,
        selected_file: BytesIO,
        on_retry: Callable[[CsvImportSession | ExcelImportSession], None],
    ):
        """
        Initialize the import options dialog.

        Args:
            import_session: Current import session with detected settings
            selected_file: BytesIO buffer of the file being imported
            on_retry: Callback function called when user clicks "Retry Import"
                     with the updated import session as parameter
        """
        super().__init__()

        self.import_session = import_session
        self.selected_file = selected_file
        self.on_retry = on_retry

        # Build dialog UI
        with (
            self,
            ui.card().classes("w-full").style("min-width: 600px; max-width: 800px"),
        ):
            ui.label("Import Configuration").classes("text-h5 mb-4")

            if isinstance(import_session, CsvImportSession):
                self._build_csv_controls()
            elif isinstance(import_session, ExcelImportSession):
                self._build_excel_controls()

            # Action buttons
            with ui.row().classes("w-full justify-end gap-2 mt-6"):
                ui.button("Cancel", on_click=self.close).props("outline")
                ui.button(
                    "Retry Import",
                    icon="refresh",
                    on_click=self._handle_retry,
                    color="primary",
                )

    def _build_csv_controls(self):
        """Build CSV-specific configuration controls."""
        session = self.import_session

        ROW_LAYOUT = "w-full items-center gap-4 mb-4"

        # Row 1: Column Separator
        with ui.row().classes(ROW_LAYOUT):
            ui.label("Column separator:").classes("text-base font-bold").style(
                "min-width: 160px"
            )
            self.separator_toggle = ui.toggle(
                {
                    ",": "Comma (,)",
                    ";": "Semicolon (;)",
                    "|": "Pipe (|)",
                    "\t": "Tab",
                },
                value=session.separator,
            )

        # Row 2: Quote Character
        with ui.row().classes(ROW_LAYOUT):
            ui.label("Quote character:").classes("text-base font-bold").style(
                "min-width: 160px"
            )
            self.quote_toggle = ui.toggle(
                {
                    '"': 'Double quote (")',
                    "'": "Single quote (')",
                },
                value=session.quote_char,
            )

        # Row 3: Has Header
        with ui.row().classes(ROW_LAYOUT):
            with (
                ui.label("Has header:")
                .classes("text-base font-bold")
                .style("min-width: 160px")
            ):
                ui.tooltip("Whether the file has a header row with column names")
            self.header_toggle = ui.toggle(
                {True: "Yes", False: "No"},
                value=session.has_header,
            )

        # Row 4: Skip Rows
        with ui.row().classes(ROW_LAYOUT):
            ui.label("Skip rows:").classes("text-base font-bold").style(
                "min-width: 160px"
            )
            self.skip_rows_input = ui.number(
                label="Number of rows to skip at start",
                value=session.skip_rows,
                min=0,
                max=100,
                step=1,
                precision=0,
                validation={
                    "Must be non-negative": lambda v: v >= 0,
                    "Cannot exceed 100": lambda v: v <= 100,
                },
            ).classes("w-48")

    def _build_excel_controls(self):
        """Build Excel-specific configuration controls."""
        session = self.import_session

        ui.label("Excel Import Options").classes("text-sm text-gray-600 mb-2")
        ui.label(f"Sheet: {session.selected_sheet}").classes("text-base")

        # Future enhancement: Add sheet selector dropdown
        ui.label("(Sheet selection coming soon)").classes("text-sm text-gray-500 mt-2")

    async def _handle_retry(self):
        """Handle retry import button click."""
        try:
            if isinstance(self.import_session, CsvImportSession):
                # Get updated values from controls
                new_separator = self.separator_toggle.value
                new_quote_char = self.quote_toggle.value
                new_has_header = self.header_toggle.value
                new_skip_rows = int(self.skip_rows_input.value)

                # Validate
                if new_skip_rows < 0 or new_skip_rows > 100:
                    ui.notify("Invalid skip rows value", type="negative")
                    return

                # Create new session with updated config
                updated_session = CsvImportSession(
                    input_file=self.selected_file,
                    separator=new_separator,
                    quote_char=new_quote_char,
                    has_header=new_has_header,
                    skip_rows=new_skip_rows,
                )

                # Call retry callback with updated session
                await self.on_retry(updated_session)

                # Close dialog
                self.close()

            elif isinstance(self.import_session, ExcelImportSession):
                # For Excel, just use existing session (no changes yet)
                await self.on_retry(self.import_session)
                self.close()

        except Exception as e:
            ui.notify(f"Error updating configuration: {str(e)}", type="negative")
            print(f"Config update error:\n{format_exc()}")

__init__(import_session, selected_file, on_retry)

Initialize the import options dialog.

Parameters:

Name Type Description Default
import_session
CsvImportSession | ExcelImportSession

Current import session with detected settings

required
selected_file
BytesIO

BytesIO buffer of the file being imported

required
on_retry
Callable[[CsvImportSession | ExcelImportSession], None]

Callback function called when user clicks "Retry Import" with the updated import session as parameter

required
Source code in src/cibmangotree/gui/components/import_options.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def __init__(
    self,
    import_session: CsvImportSession | ExcelImportSession,
    selected_file: BytesIO,
    on_retry: Callable[[CsvImportSession | ExcelImportSession], None],
):
    """
    Initialize the import options dialog.

    Args:
        import_session: Current import session with detected settings
        selected_file: BytesIO buffer of the file being imported
        on_retry: Callback function called when user clicks "Retry Import"
                 with the updated import session as parameter
    """
    super().__init__()

    self.import_session = import_session
    self.selected_file = selected_file
    self.on_retry = on_retry

    # Build dialog UI
    with (
        self,
        ui.card().classes("w-full").style("min-width: 600px; max-width: 800px"),
    ):
        ui.label("Import Configuration").classes("text-h5 mb-4")

        if isinstance(import_session, CsvImportSession):
            self._build_csv_controls()
        elif isinstance(import_session, ExcelImportSession):
            self._build_excel_controls()

        # Action buttons
        with ui.row().classes("w-full justify-end gap-2 mt-6"):
            ui.button("Cancel", on_click=self.close).props("outline")
            ui.button(
                "Retry Import",
                icon="refresh",
                on_click=self._handle_retry,
                color="primary",
            )

ManageAnalysisDialog

Bases: dialog

Dialog for managing analyses (view and delete).

Displays a list of analyses for the current project in a grid and allows users to select and delete one or more analyses.

Methods:

Name Description
__init__

Initialize the Manage Analysis dialog.

Source code in src/cibmangotree/gui/components/manage_analyses.py
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
class ManageAnalysisDialog(ui.dialog):
    """
    Dialog for managing analyses (view and delete).

    Displays a list of analyses for the current project in a grid and allows
    users to select and delete one or more analyses.
    """

    def __init__(self, session: GuiSession) -> None:
        """
        Initialize the Manage Analysis dialog.

        Args:
            session: GUI session containing app context and state
        """
        super().__init__()

        self.session = session
        now = datetime.now()
        self.analysis_contexts: list[AnalysisContext] = (
            session.current_project.list_analyses()
            if session.current_project is not None
            else []
        )
        # Track IDs of analyses deleted during this dialog session
        self.deleted_ids: set = set()

        # Build dialog UI
        with self, ui.card().classes("w-full"):
            # Dialog title
            ui.label("Manage Analyses").classes("text-h6 q-mb-md")

            # Check if there are analyses to display
            if not self.analysis_contexts:
                ui.label("No analyses found").classes("text-grey q-mb-md")
            else:
                # Analyses grid — multiRow selection enabled
                self.grid = ui.aggrid(
                    {
                        "columnDefs": [
                            {"headerName": "Analyzer Name", "field": "name"},
                            {"headerName": "Date Created", "field": "date"},
                            {"headerName": "ID", "field": "analysis_id", "hide": True},
                        ],
                        "rowData": [
                            {
                                "name": ctx.display_name,
                                "date": (
                                    present_timestamp(ctx.create_time, now)
                                    if ctx.create_time
                                    else "Unknown"
                                ),
                                "analysis_id": ctx.id,
                            }
                            for ctx in self.analysis_contexts
                        ],
                        "rowSelection": {"mode": "multiRow"},
                    },
                    theme="quartz",
                ).classes("w-full h-96")

            # Action buttons
            with ui.row().classes("w-full justify-end gap-2 mt-4"):
                ui.button(
                    "Close",
                    on_click=self._handle_close,
                    color="secondary",
                ).props("outline")

                ui.button(
                    "Delete Selected", on_click=self._handle_delete, color="negative"
                )

    async def _handle_delete(self) -> None:
        """Handle delete button click — confirm then delete all selected analyses."""
        selected_rows = await self.grid.get_selected_rows()

        if not selected_rows:
            ui.notify("Please select one or more analyses to delete", type="warning")
            return

        count = len(selected_rows)

        # Show a single confirmation for all selected rows
        confirmed = await self._show_delete_confirmation(count, selected_rows)
        if not confirmed:
            return

        errors: list[str] = []
        newly_deleted: list[str] = []

        for row in selected_rows:
            analysis_id = row["analysis_id"]
            analysis_name = row["name"]

            analysis_context = next(
                (a for a in self.analysis_contexts if a.id == analysis_id), None
            )

            if not analysis_context:
                errors.append(f"'{analysis_name}' not found")
                continue

            try:
                analysis_context.delete()
                if analysis_context.is_deleted:
                    self.deleted_ids.add(analysis_id)
                    newly_deleted.append(analysis_id)
            except Exception as e:
                errors.append(f"'{analysis_name}': {e}")

        # Update the dialog grid in place — remove deleted rows
        if newly_deleted:
            self.grid.options["rowData"] = [
                row
                for row in self.grid.options["rowData"]
                if row["analysis_id"] not in newly_deleted
            ]
            self.grid.update()

        if errors:
            ui.notify(f"Some deletions failed: {'; '.join(errors)}", type="negative")
        elif newly_deleted:
            label = "analysis" if len(newly_deleted) == 1 else "analyses"
            ui.notify(
                f"Deleted {len(newly_deleted)} {label} successfully.", type="positive"
            )

    async def _show_delete_confirmation(self, count: int, rows: list[dict]) -> bool:
        """
        Show confirmation dialog before deleting analyses.

        Args:
            count: Number of analyses selected for deletion
            rows: Selected row data dicts

        Returns:
            True if user confirmed deletion, False otherwise
        """
        if count == 1:
            description = f"analysis '{rows[0]['name']}'"
        else:
            description = f"{count} analyses"

        with ui.dialog() as dialog, ui.card():
            ui.label(f"Are you sure you want to delete {description}?").classes(
                "q-mb-md"
            )
            ui.label("This action cannot be undone.").classes("text-warning q-mb-lg")

            with ui.row().classes("w-full justify-end gap-2"):
                ui.button(
                    "Cancel",
                    on_click=lambda: dialog.submit(False),
                    color="secondary",
                ).props("outline")

                ui.button(
                    "Delete", on_click=lambda: dialog.submit(True), color="negative"
                )

        return await dialog

    def _handle_close(self) -> None:
        """Close the dialog, returning the set of deleted analysis IDs to the caller."""
        self.submit(self.deleted_ids)

__init__(session)

Initialize the Manage Analysis dialog.

Parameters:

Name Type Description Default
session
GuiSession

GUI session containing app context and state

required
Source code in src/cibmangotree/gui/components/manage_analyses.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def __init__(self, session: GuiSession) -> None:
    """
    Initialize the Manage Analysis dialog.

    Args:
        session: GUI session containing app context and state
    """
    super().__init__()

    self.session = session
    now = datetime.now()
    self.analysis_contexts: list[AnalysisContext] = (
        session.current_project.list_analyses()
        if session.current_project is not None
        else []
    )
    # Track IDs of analyses deleted during this dialog session
    self.deleted_ids: set = set()

    # Build dialog UI
    with self, ui.card().classes("w-full"):
        # Dialog title
        ui.label("Manage Analyses").classes("text-h6 q-mb-md")

        # Check if there are analyses to display
        if not self.analysis_contexts:
            ui.label("No analyses found").classes("text-grey q-mb-md")
        else:
            # Analyses grid — multiRow selection enabled
            self.grid = ui.aggrid(
                {
                    "columnDefs": [
                        {"headerName": "Analyzer Name", "field": "name"},
                        {"headerName": "Date Created", "field": "date"},
                        {"headerName": "ID", "field": "analysis_id", "hide": True},
                    ],
                    "rowData": [
                        {
                            "name": ctx.display_name,
                            "date": (
                                present_timestamp(ctx.create_time, now)
                                if ctx.create_time
                                else "Unknown"
                            ),
                            "analysis_id": ctx.id,
                        }
                        for ctx in self.analysis_contexts
                    ],
                    "rowSelection": {"mode": "multiRow"},
                },
                theme="quartz",
            ).classes("w-full h-96")

        # Action buttons
        with ui.row().classes("w-full justify-end gap-2 mt-4"):
            ui.button(
                "Close",
                on_click=self._handle_close,
                color="secondary",
            ).props("outline")

            ui.button(
                "Delete Selected", on_click=self._handle_delete, color="negative"
            )

ManageProjectsDialog

Bases: dialog

Dialog for managing projects (view and delete).

Displays a list of projects in a grid and allows users to select and delete projects.

Methods:

Name Description
__init__

Initialize the Manage Projects dialog.

Source code in src/cibmangotree/gui/components/manage_projects.py
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
class ManageProjectsDialog(ui.dialog):
    """
    Dialog for managing projects (view and delete).

    Displays a list of projects in a grid and allows users to select
    and delete projects.
    """

    def __init__(self, session: GuiSession) -> None:
        """
        Initialize the Manage Projects dialog.

        Args:
            session: GUI session containing app context and state
        """
        super().__init__()

        self.session = session
        self.project_contexts = self.session.app.list_projects()

        # Build dialog UI
        with self, ui.card().classes("w-full"):
            # Dialog title
            ui.label("Manage Projects").classes("text-h6 q-mb-md")

            # Check if there are projects to display
            if not self.project_contexts:
                ui.label("No projects found").classes("text-grey q-mb-md")
            else:
                # Projects grid
                self.grid = ui.aggrid(
                    {
                        "columnDefs": [
                            {"headerName": "Project Name", "field": "project_name"},
                            {"headerName": "Project ID", "field": "project_id"},
                        ],
                        "rowData": [
                            {
                                "project_name": proj.display_name,
                                "project_id": proj.id,
                            }
                            for proj in self.project_contexts
                        ],
                        "rowSelection": {"mode": "singleRow"},
                    },
                    theme="quartz",
                ).classes("w-full h-96")

            # Action buttons
            with ui.row().classes("w-full justify-end gap-2 mt-4"):
                ui.button(
                    "Cancel",
                    on_click=self._handle_cancel,
                    color="secondary",
                ).props("outline")

                ui.button("Delete", on_click=self._handle_delete, color="primary")

    async def _handle_delete(self) -> None:
        """Handle delete button click - show confirmation then delete selected project."""
        # Get selected row
        selected_rows = await self.grid.get_selected_rows()

        if not selected_rows:
            ui.notify("Please select a project to delete", type="warning")
            return

        selected_row = selected_rows[0]
        project_id = selected_row["project_id"]
        project_name = selected_row["project_name"]

        # Show confirmation dialog
        confirmed = await self._show_delete_confirmation(project_name, project_id)

        if not confirmed:
            return

        try:
            # Find the project context or fetch None if not found
            project_context = next(
                (p for p in self.project_contexts if p.id == project_id), None
            )

            if not project_context:
                ui.notify("Project not found", type="negative")
                return

            # Delete the project via ProjectContext API
            project_context.delete()

            # Close dialog and return information for notification pop up
            self.submit(
                (project_context.is_deleted, project_name, project_id)
            )  # Return True to indicate changes were made

        except Exception as e:
            ui.notify(f"Error deleting project: {str(e)}", type="negative")

    async def _show_delete_confirmation(self, project_name: str, project_id) -> bool:
        """
        Show confirmation dialog before deleting a project.

        Args:
            project_name: Name of the project to delete

        Returns:
            True if user confirmed deletion, False otherwise
        """

        with ui.dialog() as dialog, ui.card():
            ui.label(
                f"Are you sure you want to delete project '{project_name}' (ID: {project_id})?"
            ).classes("q-mb-md")
            ui.label("This action cannot be undone.").classes("text-warning q-mb-lg")

            with ui.row().classes("w-full justify-end gap-2"):
                ui.button(
                    "Cancel",
                    on_click=lambda: dialog.submit(False),
                    color="secondary",
                ).props("outline")

                ui.button(
                    "Delete", on_click=lambda: dialog.submit(True), color="negative"
                )

        return await dialog

    def _handle_cancel(self):
        """Handle cancel button click - close dialog without changes."""
        self.submit(False)  # Return False to indicate no changes

__init__(session)

Initialize the Manage Projects dialog.

Parameters:

Name Type Description Default
session
GuiSession

GUI session containing app context and state

required
Source code in src/cibmangotree/gui/components/manage_projects.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def __init__(self, session: GuiSession) -> None:
    """
    Initialize the Manage Projects dialog.

    Args:
        session: GUI session containing app context and state
    """
    super().__init__()

    self.session = session
    self.project_contexts = self.session.app.list_projects()

    # Build dialog UI
    with self, ui.card().classes("w-full"):
        # Dialog title
        ui.label("Manage Projects").classes("text-h6 q-mb-md")

        # Check if there are projects to display
        if not self.project_contexts:
            ui.label("No projects found").classes("text-grey q-mb-md")
        else:
            # Projects grid
            self.grid = ui.aggrid(
                {
                    "columnDefs": [
                        {"headerName": "Project Name", "field": "project_name"},
                        {"headerName": "Project ID", "field": "project_id"},
                    ],
                    "rowData": [
                        {
                            "project_name": proj.display_name,
                            "project_id": proj.id,
                        }
                        for proj in self.project_contexts
                    ],
                    "rowSelection": {"mode": "singleRow"},
                },
                theme="quartz",
            ).classes("w-full h-96")

        # Action buttons
        with ui.row().classes("w-full justify-end gap-2 mt-4"):
            ui.button(
                "Cancel",
                on_click=self._handle_cancel,
                color="secondary",
            ).props("outline")

            ui.button("Delete", on_click=self._handle_delete, color="primary")

ToggleButton

Bases: button

Methods:

Name Description
set_active

Set the button state externally.

toggle

Toggle the button state.

Source code in src/cibmangotree/gui/components/toggle.py
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class ToggleButton(ui.button):
    def __init__(self, *args, group=None, **kwargs) -> None:
        self._state = False
        self._group = group
        super().__init__(*args, **kwargs)
        self.on("click", self._handle_click)

    def _handle_click(self) -> None:
        """Handle button click, coordinating with group if present."""
        if self._group:
            self._group.select(self)
        else:
            self.toggle()

    def toggle(self) -> None:
        """Toggle the button state."""
        self._state = not self._state
        self.update()

    def set_active(self, active: bool) -> None:
        """Set the button state externally."""
        self._state = active
        self.update()

    def update(self) -> None:
        if self._group:
            # Group mode: green when active, grey when inactive
            self.props(f"color={'primary' if self._state else 'grey'}")
        else:
            # Standalone mode: orange/red toggle
            self.props(f"color={'primary' if self._state else 'red'}")
        super().update()

set_active(active)

Set the button state externally.

Source code in src/cibmangotree/gui/components/toggle.py
23
24
25
26
def set_active(self, active: bool) -> None:
    """Set the button state externally."""
    self._state = active
    self.update()

toggle()

Toggle the button state.

Source code in src/cibmangotree/gui/components/toggle.py
18
19
20
21
def toggle(self) -> None:
    """Toggle the button state."""
    self._state = not self._state
    self.update()

ToggleButtonGroup

Manages a group of toggle buttons with mutual exclusivity.

Methods:

Name Description
add_button

Add a button to the group.

get_selected

Get the currently selected button.

get_selected_text

Get the text of the currently selected button.

select

Select a button, deselecting all others.

Source code in src/cibmangotree/gui/components/toggle.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
class ToggleButtonGroup:
    """Manages a group of toggle buttons with mutual exclusivity."""

    def __init__(self):
        self.buttons = []
        self.selected = None
        self.selected_text = None

    def add_button(self, text: str, **kwargs) -> ToggleButton:
        """Add a button to the group."""
        btn = ToggleButton(text, group=self, **kwargs)
        self.buttons.append(btn)
        return btn

    def select(self, button: ToggleButton) -> None:
        """Select a button, deselecting all others."""
        for btn in self.buttons:
            btn.set_active(False)
        button.set_active(True)
        self.selected = button
        self.selected_text = button.text

    def get_selected(self) -> ToggleButton | None:
        """Get the currently selected button."""
        return self.selected

    def get_selected_text(self) -> str | None:
        """Get the text of the currently selected button."""
        return self.selected.text if self.selected else None

add_button(text, **kwargs)

Add a button to the group.

Source code in src/cibmangotree/gui/components/toggle.py
46
47
48
49
50
def add_button(self, text: str, **kwargs) -> ToggleButton:
    """Add a button to the group."""
    btn = ToggleButton(text, group=self, **kwargs)
    self.buttons.append(btn)
    return btn

get_selected()

Get the currently selected button.

Source code in src/cibmangotree/gui/components/toggle.py
60
61
62
def get_selected(self) -> ToggleButton | None:
    """Get the currently selected button."""
    return self.selected

get_selected_text()

Get the text of the currently selected button.

Source code in src/cibmangotree/gui/components/toggle.py
64
65
66
def get_selected_text(self) -> str | None:
    """Get the text of the currently selected button."""
    return self.selected.text if self.selected else None

select(button)

Select a button, deselecting all others.

Source code in src/cibmangotree/gui/components/toggle.py
52
53
54
55
56
57
58
def select(self, button: ToggleButton) -> None:
    """Select a button, deselecting all others."""
    for btn in self.buttons:
        btn.set_active(False)
    button.set_active(True)
    self.selected = button
    self.selected_text = button.text

analysis

Classes:

Name Description
AnalysisParamsCard

Card component for configuring analyzer parameters.

AnalysisParamsCard

Card component for configuring analyzer parameters.

Displays each parameter as an individual card, matching the visual style of the column picker cards for consistent UX.

Methods:

Name Description
__init__

Initialize the analysis parameters card.

get_param_values

Retrieve current parameter values from the UI controls.

Source code in src/cibmangotree/gui/components/analysis.py
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
class AnalysisParamsCard:
    """
    Card component for configuring analyzer parameters.

    Displays each parameter as an individual card, matching the visual style
    of the column picker cards for consistent UX.
    """

    def __init__(
        self,
        params: list[AnalyzerParam],
        default_values: dict[str, ParamValue],
    ):
        """
        Initialize the analysis parameters card.

        Args:
            params: List of analyzer parameter specifications
            default_values: Dictionary of default parameter values
        """
        self.params = params
        self.default_values = default_values
        self.param_widgets: dict[str, tuple] = {}

        # Build the card UI
        self._build_card()

    def _build_card(self):
        """Build the parameter configuration cards in a flex-wrap row."""
        if not self.params:
            with ui.column().classes("w-full items-center"):
                ui.label("This analyzer has no configurable parameters.").classes(
                    "text-grey-7"
                )
            return

        with ui.row().classes("flex-wrap gap-6 justify-center w-full"):
            for param in self.params:
                self._build_param_card(param)

    def _build_param_card(self, param: AnalyzerParam):
        """Build an individual card for a single parameter."""
        with ui.card().classes("w-72 p-4 no-shadow border border-gray-200"):
            with ui.row().classes("items-center gap-1"):
                ui.label(param.print_name).classes("text-bold")
                if param.description:
                    with ui.icon("info").classes("text-grey-6 cursor-pointer"):
                        ui.tooltip(param.description)

            param_type = param.type
            default_value = self.default_values.get(param.id)

            if param_type.type == "integer":
                self._build_integer_control(param, param_type, default_value)
            elif param_type.type == "time_binning":
                self._build_time_binning_control(param, default_value)

    def _build_integer_control(
        self,
        param: AnalyzerParam,
        param_type: IntegerParam,
        default_value: ParamValue | None,
    ):
        """Build integer parameter control."""
        int_default = default_value if isinstance(default_value, int) else None
        number_input = ui.number(
            value=int_default if int_default is not None else param_type.min,
            min=param_type.min,
            max=param_type.max,
            step=1,
            precision=0,
            validation={
                f"Must be at least {param_type.min}": lambda v: v >= param_type.min,
                f"Must be at most {param_type.max}": lambda v: v <= param_type.max,
            },
        ).classes("w-full mt-2")

        self.param_widgets[param.id] = ("integer", number_input)

    def _build_time_binning_control(
        self, param: AnalyzerParam, default_value: ParamValue | None
    ):
        """Build time binning parameter control."""
        tb_default = (
            default_value if isinstance(default_value, TimeBinningValue) else None
        )
        with ui.row().classes("gap-2 mt-2 w-full"):
            unit_select = ui.select(
                {
                    "year": "Year",
                    "month": "Month",
                    "week": "Week",
                    "day": "Day",
                    "hour": "Hour",
                    "minute": "Minute",
                    "second": "Second",
                },
                value=tb_default.unit if tb_default else "day",
            ).classes("w-32")

            amount_input = ui.number(
                value=tb_default.amount if tb_default else 1,
                min=1,
                max=1000,
                step=1,
                precision=0,
                validation={
                    "Must be at least 1": lambda v: v >= 1,
                    "Cannot exceed 1000": lambda v: v <= 1000,
                },
            ).classes("w-24")

        self.param_widgets[param.id] = ("time_binning", unit_select, amount_input)

    def get_param_values(self) -> dict[str, ParamValue]:
        """
        Retrieve current parameter values from the UI controls.

        Returns:
            Dictionary mapping parameter IDs to their values
        """
        param_values = {}

        for param_id, widgets in self.param_widgets.items():
            param_type = widgets[0]

            if param_type == "integer":
                number_input = widgets[1]
                param_values[param_id] = int(number_input.value)

            elif param_type == "time_binning":
                unit_toggle = widgets[1]
                amount_input = widgets[2]
                param_values[param_id] = TimeBinningValue(
                    unit=unit_toggle.value, amount=int(amount_input.value)
                )

        return param_values
__init__(params, default_values)

Initialize the analysis parameters card.

Parameters:

Name Type Description Default
params
list[AnalyzerParam]

List of analyzer parameter specifications

required
default_values
dict[str, ParamValue]

Dictionary of default parameter values

required
Source code in src/cibmangotree/gui/components/analysis.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def __init__(
    self,
    params: list[AnalyzerParam],
    default_values: dict[str, ParamValue],
):
    """
    Initialize the analysis parameters card.

    Args:
        params: List of analyzer parameter specifications
        default_values: Dictionary of default parameter values
    """
    self.params = params
    self.default_values = default_values
    self.param_widgets: dict[str, tuple] = {}

    # Build the card UI
    self._build_card()
get_param_values()

Retrieve current parameter values from the UI controls.

Returns:

Type Description
dict[str, ParamValue]

Dictionary mapping parameter IDs to their values

Source code in src/cibmangotree/gui/components/analysis.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def get_param_values(self) -> dict[str, ParamValue]:
    """
    Retrieve current parameter values from the UI controls.

    Returns:
        Dictionary mapping parameter IDs to their values
    """
    param_values = {}

    for param_id, widgets in self.param_widgets.items():
        param_type = widgets[0]

        if param_type == "integer":
            number_input = widgets[1]
            param_values[param_id] = int(number_input.value)

        elif param_type == "time_binning":
            unit_toggle = widgets[1]
            amount_input = widgets[2]
            param_values[param_id] = TimeBinningValue(
                unit=unit_toggle.value, amount=int(amount_input.value)
            )

    return param_values

exit_confirmation

Classes:

Name Description
ExitConfirmationDialog

Reusable confirmation dialog for page exit navigation.

ExitConfirmationDialog

Bases: dialog

Reusable confirmation dialog for page exit navigation.

Subclasses ui.dialog to match the pattern used by ManageProjectsDialog and ImportOptionsDialog.

Usage

dialog = ExitConfirmationDialog( message="You have unsaved changes. Leave anyway?", confirm_text="Leave", cancel_text="Stay" ) confirmed = await dialog if confirmed: ui.navigate.to("/home")

Source code in src/cibmangotree/gui/components/exit_confirmation.py
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class ExitConfirmationDialog(ui.dialog):
    """Reusable confirmation dialog for page exit navigation.

    Subclasses ui.dialog to match the pattern used by
    ManageProjectsDialog and ImportOptionsDialog.

    Usage:
        dialog = ExitConfirmationDialog(
            message="You have unsaved changes. Leave anyway?",
            confirm_text="Leave",
            cancel_text="Stay"
        )
        confirmed = await dialog
        if confirmed:
            ui.navigate.to("/home")
    """

    def __init__(
        self,
        message: str = "Are you sure you want to leave?",
        confirm_text: str = "Leave",
        cancel_text: str = "Stay",
    ):
        super().__init__()
        with self, ui.card().classes("w-80 items-center"):
            ui.label(message).classes("text-center q-mb-md")
            with ui.row().classes("gap-2"):
                ui.button(
                    cancel_text,
                    on_click=lambda: self.submit(False),
                ).props("flat")
                ui.button(
                    confirm_text,
                    on_click=lambda: self.submit(True),
                    color="negative",
                ).props("flat")

export_outputs

Classes:

Name Description
ExportDialog

Multi-step dialog for selecting and exporting analysis outputs.

ExportDialog

Bases: dialog

Multi-step dialog for selecting and exporting analysis outputs.

Source code in src/cibmangotree/gui/components/export_outputs.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
class ExportDialog(ui.dialog):
    """Multi-step dialog for selecting and exporting analysis outputs."""

    def __init__(self, analysis_context: AnalysisContext):
        super().__init__()

        self.analysis_context = analysis_context

        self.outputs = sorted(
            analysis_context.get_all_exportable_outputs(),
            key=lambda output: (
                (
                    "0"
                    if output.secondary_spec is None
                    else "1_" + output.secondary_spec.name
                ),
                output.descriptive_qualified_name,
            ),
        )

        self.selected_outputs: list[AnalysisOutputContext] = []
        self.format: SupportedOutputExtension | None = None
        self.exported_paths: list[tuple[str, int]] = []
        self.export_errors: list[str] = []

        self.progress_queue: queue.Queue | None = None
        self.timer: ui.timer | None = None

        self.output_status_rows: dict[str, tuple] = {}

        with (
            self,
            ui.card().classes("w-full").style("min-width: 500px; max-width: 700px"),
        ):
            self._build_select_outputs()
            self._build_configure_export()
            self._build_export_progress()
            self._build_export_complete()

        self._show_step("select_outputs")

    def _show_step(self, step: str):
        steps = {
            "select_outputs": self.select_outputs,
            "configure_export": self.configure_export,
            "export_progress": self.export_progress,
            "export_complete": self.export_complete,
        }
        for name, card in steps.items():
            card.set_visibility(name == step)

    def _add_checkbox_group(
        self, label_text: str, outputs: list[AnalysisOutputContext]
    ):
        ui.label(label_text).classes("text-subtitle2 text-weight-bold q-mt-sm")
        ui.separator()
        for output in outputs:
            cb = ui.checkbox(
                f"{output.descriptive_qualified_name}",
                on_change=lambda _=None: self._on_selection_changed(),
            )
            self.output_checkboxes.append((output, cb))

    def _build_select_outputs(self):
        self.select_outputs = ui.column().classes("w-full")
        with self.select_outputs:
            ui.label("Select outputs to export").classes("text-h6 q-mb-md")

            self.output_checkboxes: list[tuple[AnalysisOutputContext, ui.checkbox]] = []
            self.checkbox_container = ui.column().classes("w-full gap-1 mb-4")

            with self.checkbox_container:
                primary_outputs = [o for o in self.outputs if o.secondary_spec is None]
                secondary_outputs = [
                    o for o in self.outputs if o.secondary_spec is not None
                ]

                if primary_outputs:
                    self._add_checkbox_group("Primary Outputs", primary_outputs)

                if secondary_outputs:
                    sec_names = sorted(
                        {o.secondary_spec.name for o in secondary_outputs}
                    )
                    for sec_name in sec_names:
                        sec_outputs = [
                            o
                            for o in secondary_outputs
                            if o.secondary_spec.name == sec_name
                        ]
                        self._add_checkbox_group("Secondary Outputs", sec_outputs)

            with ui.row().classes("w-full justify-end gap-2"):
                ui.button("Cancel", on_click=self.close, color="secondary")
                self.select_outputs_next = ui.button(
                    "Next",
                    on_click=self._go_to_configure_export,
                    color="primary",
                    icon="arrow_forward",
                )
                self.select_outputs_next.set_enabled(False)

    def _on_selection_changed(self):
        has_selection = any(cb.value for _, cb in self.output_checkboxes)
        self.select_outputs_next.set_enabled(has_selection)

    def _go_to_configure_export(self):
        self.selected_outputs = [
            output for output, cb in self.output_checkboxes if cb.value
        ]

        self.chunking_visible = any(
            output.num_rows > LARGE_OUTPUT_THRESHOLD for output in self.selected_outputs
        )
        self.chunking_section.set_visibility(self.chunking_visible)
        self._show_step("configure_export")

    def _build_configure_export(self):
        self.configure_export = ui.column().classes("w-full")
        with self.configure_export:
            ui.label("Configure export").classes("text-h6 q-mb-md")

            is_hashtags = self.analysis_context.analyzer_id == "hashtags"
            format_options: dict[str, str] = {}
            if not is_hashtags:
                format_options["csv"] = "CSV"
                format_options["xlsx"] = "Excel"
            format_options["json"] = "JSON"

            self.format_toggle = ui.toggle(
                format_options,
                value=list(format_options.keys())[0],
            ).classes("q-mb-md")

            self.chunking_visible = False
            self.chunking_section = ui.column().classes("w-full")
            self.chunking_section.set_visibility(False)
            with self.chunking_section:
                ui.label("Chunking options").classes(
                    "text-subtitle2 text-weight-bold q-mt-sm"
                )
                ui.label(
                    f"Control how outputs larger than "
                    f"{LARGE_OUTPUT_THRESHOLD:,} rows are saved."
                ).classes("q-mb-md text-sm")

                settings = self.analysis_context.app_context.settings
                current_chunk = settings.export_chunk_size
                is_current_chunking = (
                    current_chunk is not None and current_chunk is not False
                )
                default_toggle = (
                    is_current_chunking if current_chunk is not None else True
                )

                self.chunk_toggle = ui.toggle(
                    {True: "Break into chunks", False: "Export in a single file"},
                    value=default_toggle,
                ).classes("q-mb-md")

                self.chunk_size_input = ui.number(
                    "Rows per chunk",
                    value=(
                        current_chunk
                        if isinstance(current_chunk, int)
                        else LARGE_OUTPUT_THRESHOLD
                    ),
                    min=100,
                ).classes("q-mb-md")

                if not is_current_chunking:
                    self.chunk_size_input.set_visibility(False)

                self.chunk_toggle.on_value_change(
                    lambda: self.chunk_size_input.set_visibility(
                        self.chunk_toggle.value
                    )
                )

            with ui.row().classes("w-full justify-end gap-2"):
                ui.button(
                    "Back",
                    on_click=lambda: self._show_step("select_outputs"),
                    color="secondary",
                    icon="arrow_back",
                )
                ui.button(
                    "Start Export",
                    on_click=self._handle_start_export,
                    color="primary",
                    icon="arrow_forward",
                )

    def _handle_start_export(self):
        self.format = self.format_toggle.value

        if self.chunking_visible:
            settings = self.analysis_context.app_context.settings
            if self.chunk_toggle.value:
                settings.set_export_chunk_size(self.chunk_size_input.value)
            else:
                settings.set_export_chunk_size(False)

        self._start_export()

    def _build_export_progress(self):
        self.export_progress = ui.column().classes("w-full")
        with self.export_progress:
            ui.label("Exporting...").classes("text-h6 q-mb-md")

            self.export_status_label = ui.label("Preparing...").classes(
                "text-base q-mb-md"
            )

            self.export_progress_bar = (
                ui.linear_progress(value=0, show_value=False)
                .classes("w-full q-mb-md")
                .props("instant-feedback")
            )

            self.output_status_container = ui.column().classes("w-full gap-1 mb-4")

    def _build_export_complete(self):
        self.export_complete = ui.column().classes("w-full")
        with self.export_complete:
            with ui.row().classes("items-center gap-2 q-mb-md"):
                self.complete_success_icon = ui.icon(
                    "check_circle", color=MANGO_DARK_GREEN, size="lg"
                )
                self.complete_error_icon = ui.icon(
                    "cancel", color="negative", size="lg"
                )
                self.complete_status_label = ui.label("").classes("text-h6")
                self.complete_success_icon.set_visibility(False)
                self.complete_error_icon.set_visibility(False)

            self.complete_message_container = ui.column().classes("w-full gap-1 mb-4")

            with ui.row().classes("w-full justify-end gap-2"):
                self.export_complete_open_folder = ui.button(
                    "Open exports folder",
                    on_click=self._open_folder,
                    color="primary",
                    icon="folder_open",
                )
                ui.button("Close", on_click=self.close, color="secondary")

    def _start_export(self):
        if self.format is None:
            ui.notify("Please select an export format", type="warning")
            self._show_step("configure_export")
            return

        self._show_step("export_progress")

        self.progress_queue = queue.Queue()
        self.exported_paths = []
        self.export_errors = []
        self.output_status_rows.clear()

        self.output_status_container.clear()

        self.export_progress_bar.value = 0

        thread = threading.Thread(
            target=_export_worker,
            args=(
                self.selected_outputs,
                self.format,
                self.progress_queue,
            ),
            daemon=True,
        )
        thread.start()

        self.timer = ui.timer(QUEUE_POLL_INTERVAL, self._poll_progress)

    def _poll_progress(self):
        try:
            msg = self.progress_queue.get_nowait()
        except queue.Empty:
            return

        if msg.msg_type == "output_start":
            self.export_status_label.text = f"Exporting {msg.name}..."
            self.export_progress_bar.value = msg.index / msg.total

            with self.output_status_container:
                with ui.row().classes("items-center gap-2") as row:
                    spinner = ui.spinner("gears", size="sm")
                    label = ui.label(msg.name).classes("text-sm")
                self.output_status_rows[msg.name] = (row, spinner, label)

        elif msg.msg_type == "output_progress":
            self.export_progress_bar.value = (
                msg.index + msg.chunk_progress
            ) / msg.total
            if msg.name in self.output_status_rows:
                _, _, label = self.output_status_rows[msg.name]
                label.text = f"{msg.name} ({msg.chunk_progress * 100:.0f}%)"

        elif msg.msg_type == "output_finish":
            self.export_progress_bar.value = (msg.index + 1) / msg.total
            self.exported_paths.append((msg.path, msg.chunk_count))
            if msg.name in self.output_status_rows:
                _, spinner, label = self.output_status_rows[msg.name]
                spinner.set_visibility(False)
                if msg.chunk_count > 1:
                    display_path = msg.path.replace("_[*]", "")
                    label.text = (
                        f"Exported as {os.path.basename(display_path)} "
                        f"in {msg.chunk_count} chunks"
                    )
                else:
                    label.text = f"Exported as {os.path.basename(msg.path)}"

        elif msg.msg_type == "output_error":
            self.export_errors.append(f"{msg.name}: {msg.error}")
            if msg.name in self.output_status_rows:
                _, spinner, label = self.output_status_rows[msg.name]
                spinner.set_visibility(False)
                label.text = f"Error: {msg.name} \u2014 {msg.error}"

        elif msg.msg_type == "all_complete":
            self._on_export_complete()

    def _on_export_complete(self):
        if self.timer:
            self.timer.cancel()
            self.timer = None

        self.export_progress_bar.value = 1.0
        if self.export_errors:
            self.complete_status_label.text = "Export completed with errors"
            self.complete_error_icon.set_visibility(True)
            self.complete_success_icon.set_visibility(False)
        else:
            self.complete_status_label.text = "Export complete!"
            self.complete_success_icon.set_visibility(True)
            self.complete_error_icon.set_visibility(False)

        self.complete_message_container.clear()
        with self.complete_message_container:
            if self.exported_paths:
                for path, chunk_count in self.exported_paths:
                    if chunk_count > 1:
                        display_path = path.replace("_[*]", "")
                        ui.label(
                            f"Exported {os.path.basename(display_path)} in {chunk_count} chunks"
                        ).classes("text-sm")
                    else:
                        ui.label(f"Exported {os.path.basename(path)}").classes(
                            "text-sm"
                        )

            if self.export_errors:
                for error in self.export_errors:
                    ui.label(error).classes("text-sm")

        self.export_complete_open_folder.set_visibility(bool(self.exported_paths))
        self._show_step("export_complete")

    async def _open_folder(self):
        await run.io_bound(
            open_directory_explorer, self.analysis_context.export_root_path
        )

import_options

Import options dialog for modifying CSV/Excel import configuration.

Allows users to adjust import settings and retry preview with updated parameters.

Classes:

Name Description
ImportOptionsDialog

Dialog for configuring CSV/Excel import options.

ImportOptionsDialog

Bases: dialog

Dialog for configuring CSV/Excel import options.

Displays interactive controls for modifying import parameters and allows retrying the import with updated settings.

Methods:

Name Description
__init__

Initialize the import options dialog.

Source code in src/cibmangotree/gui/components/import_options.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
class ImportOptionsDialog(ui.dialog):
    """
    Dialog for configuring CSV/Excel import options.

    Displays interactive controls for modifying import parameters
    and allows retrying the import with updated settings.
    """

    def __init__(
        self,
        import_session: CsvImportSession | ExcelImportSession,
        selected_file: BytesIO,
        on_retry: Callable[[CsvImportSession | ExcelImportSession], None],
    ):
        """
        Initialize the import options dialog.

        Args:
            import_session: Current import session with detected settings
            selected_file: BytesIO buffer of the file being imported
            on_retry: Callback function called when user clicks "Retry Import"
                     with the updated import session as parameter
        """
        super().__init__()

        self.import_session = import_session
        self.selected_file = selected_file
        self.on_retry = on_retry

        # Build dialog UI
        with (
            self,
            ui.card().classes("w-full").style("min-width: 600px; max-width: 800px"),
        ):
            ui.label("Import Configuration").classes("text-h5 mb-4")

            if isinstance(import_session, CsvImportSession):
                self._build_csv_controls()
            elif isinstance(import_session, ExcelImportSession):
                self._build_excel_controls()

            # Action buttons
            with ui.row().classes("w-full justify-end gap-2 mt-6"):
                ui.button("Cancel", on_click=self.close).props("outline")
                ui.button(
                    "Retry Import",
                    icon="refresh",
                    on_click=self._handle_retry,
                    color="primary",
                )

    def _build_csv_controls(self):
        """Build CSV-specific configuration controls."""
        session = self.import_session

        ROW_LAYOUT = "w-full items-center gap-4 mb-4"

        # Row 1: Column Separator
        with ui.row().classes(ROW_LAYOUT):
            ui.label("Column separator:").classes("text-base font-bold").style(
                "min-width: 160px"
            )
            self.separator_toggle = ui.toggle(
                {
                    ",": "Comma (,)",
                    ";": "Semicolon (;)",
                    "|": "Pipe (|)",
                    "\t": "Tab",
                },
                value=session.separator,
            )

        # Row 2: Quote Character
        with ui.row().classes(ROW_LAYOUT):
            ui.label("Quote character:").classes("text-base font-bold").style(
                "min-width: 160px"
            )
            self.quote_toggle = ui.toggle(
                {
                    '"': 'Double quote (")',
                    "'": "Single quote (')",
                },
                value=session.quote_char,
            )

        # Row 3: Has Header
        with ui.row().classes(ROW_LAYOUT):
            with (
                ui.label("Has header:")
                .classes("text-base font-bold")
                .style("min-width: 160px")
            ):
                ui.tooltip("Whether the file has a header row with column names")
            self.header_toggle = ui.toggle(
                {True: "Yes", False: "No"},
                value=session.has_header,
            )

        # Row 4: Skip Rows
        with ui.row().classes(ROW_LAYOUT):
            ui.label("Skip rows:").classes("text-base font-bold").style(
                "min-width: 160px"
            )
            self.skip_rows_input = ui.number(
                label="Number of rows to skip at start",
                value=session.skip_rows,
                min=0,
                max=100,
                step=1,
                precision=0,
                validation={
                    "Must be non-negative": lambda v: v >= 0,
                    "Cannot exceed 100": lambda v: v <= 100,
                },
            ).classes("w-48")

    def _build_excel_controls(self):
        """Build Excel-specific configuration controls."""
        session = self.import_session

        ui.label("Excel Import Options").classes("text-sm text-gray-600 mb-2")
        ui.label(f"Sheet: {session.selected_sheet}").classes("text-base")

        # Future enhancement: Add sheet selector dropdown
        ui.label("(Sheet selection coming soon)").classes("text-sm text-gray-500 mt-2")

    async def _handle_retry(self):
        """Handle retry import button click."""
        try:
            if isinstance(self.import_session, CsvImportSession):
                # Get updated values from controls
                new_separator = self.separator_toggle.value
                new_quote_char = self.quote_toggle.value
                new_has_header = self.header_toggle.value
                new_skip_rows = int(self.skip_rows_input.value)

                # Validate
                if new_skip_rows < 0 or new_skip_rows > 100:
                    ui.notify("Invalid skip rows value", type="negative")
                    return

                # Create new session with updated config
                updated_session = CsvImportSession(
                    input_file=self.selected_file,
                    separator=new_separator,
                    quote_char=new_quote_char,
                    has_header=new_has_header,
                    skip_rows=new_skip_rows,
                )

                # Call retry callback with updated session
                await self.on_retry(updated_session)

                # Close dialog
                self.close()

            elif isinstance(self.import_session, ExcelImportSession):
                # For Excel, just use existing session (no changes yet)
                await self.on_retry(self.import_session)
                self.close()

        except Exception as e:
            ui.notify(f"Error updating configuration: {str(e)}", type="negative")
            print(f"Config update error:\n{format_exc()}")
__init__(import_session, selected_file, on_retry)

Initialize the import options dialog.

Parameters:

Name Type Description Default
import_session
CsvImportSession | ExcelImportSession

Current import session with detected settings

required
selected_file
BytesIO

BytesIO buffer of the file being imported

required
on_retry
Callable[[CsvImportSession | ExcelImportSession], None]

Callback function called when user clicks "Retry Import" with the updated import session as parameter

required
Source code in src/cibmangotree/gui/components/import_options.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def __init__(
    self,
    import_session: CsvImportSession | ExcelImportSession,
    selected_file: BytesIO,
    on_retry: Callable[[CsvImportSession | ExcelImportSession], None],
):
    """
    Initialize the import options dialog.

    Args:
        import_session: Current import session with detected settings
        selected_file: BytesIO buffer of the file being imported
        on_retry: Callback function called when user clicks "Retry Import"
                 with the updated import session as parameter
    """
    super().__init__()

    self.import_session = import_session
    self.selected_file = selected_file
    self.on_retry = on_retry

    # Build dialog UI
    with (
        self,
        ui.card().classes("w-full").style("min-width: 600px; max-width: 800px"),
    ):
        ui.label("Import Configuration").classes("text-h5 mb-4")

        if isinstance(import_session, CsvImportSession):
            self._build_csv_controls()
        elif isinstance(import_session, ExcelImportSession):
            self._build_excel_controls()

        # Action buttons
        with ui.row().classes("w-full justify-end gap-2 mt-6"):
            ui.button("Cancel", on_click=self.close).props("outline")
            ui.button(
                "Retry Import",
                icon="refresh",
                on_click=self._handle_retry,
                color="primary",
            )

manage_analyses

Classes:

Name Description
ManageAnalysisDialog

Dialog for managing analyses (view and delete).

ManageAnalysisDialog

Bases: dialog

Dialog for managing analyses (view and delete).

Displays a list of analyses for the current project in a grid and allows users to select and delete one or more analyses.

Methods:

Name Description
__init__

Initialize the Manage Analysis dialog.

Source code in src/cibmangotree/gui/components/manage_analyses.py
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
class ManageAnalysisDialog(ui.dialog):
    """
    Dialog for managing analyses (view and delete).

    Displays a list of analyses for the current project in a grid and allows
    users to select and delete one or more analyses.
    """

    def __init__(self, session: GuiSession) -> None:
        """
        Initialize the Manage Analysis dialog.

        Args:
            session: GUI session containing app context and state
        """
        super().__init__()

        self.session = session
        now = datetime.now()
        self.analysis_contexts: list[AnalysisContext] = (
            session.current_project.list_analyses()
            if session.current_project is not None
            else []
        )
        # Track IDs of analyses deleted during this dialog session
        self.deleted_ids: set = set()

        # Build dialog UI
        with self, ui.card().classes("w-full"):
            # Dialog title
            ui.label("Manage Analyses").classes("text-h6 q-mb-md")

            # Check if there are analyses to display
            if not self.analysis_contexts:
                ui.label("No analyses found").classes("text-grey q-mb-md")
            else:
                # Analyses grid — multiRow selection enabled
                self.grid = ui.aggrid(
                    {
                        "columnDefs": [
                            {"headerName": "Analyzer Name", "field": "name"},
                            {"headerName": "Date Created", "field": "date"},
                            {"headerName": "ID", "field": "analysis_id", "hide": True},
                        ],
                        "rowData": [
                            {
                                "name": ctx.display_name,
                                "date": (
                                    present_timestamp(ctx.create_time, now)
                                    if ctx.create_time
                                    else "Unknown"
                                ),
                                "analysis_id": ctx.id,
                            }
                            for ctx in self.analysis_contexts
                        ],
                        "rowSelection": {"mode": "multiRow"},
                    },
                    theme="quartz",
                ).classes("w-full h-96")

            # Action buttons
            with ui.row().classes("w-full justify-end gap-2 mt-4"):
                ui.button(
                    "Close",
                    on_click=self._handle_close,
                    color="secondary",
                ).props("outline")

                ui.button(
                    "Delete Selected", on_click=self._handle_delete, color="negative"
                )

    async def _handle_delete(self) -> None:
        """Handle delete button click — confirm then delete all selected analyses."""
        selected_rows = await self.grid.get_selected_rows()

        if not selected_rows:
            ui.notify("Please select one or more analyses to delete", type="warning")
            return

        count = len(selected_rows)

        # Show a single confirmation for all selected rows
        confirmed = await self._show_delete_confirmation(count, selected_rows)
        if not confirmed:
            return

        errors: list[str] = []
        newly_deleted: list[str] = []

        for row in selected_rows:
            analysis_id = row["analysis_id"]
            analysis_name = row["name"]

            analysis_context = next(
                (a for a in self.analysis_contexts if a.id == analysis_id), None
            )

            if not analysis_context:
                errors.append(f"'{analysis_name}' not found")
                continue

            try:
                analysis_context.delete()
                if analysis_context.is_deleted:
                    self.deleted_ids.add(analysis_id)
                    newly_deleted.append(analysis_id)
            except Exception as e:
                errors.append(f"'{analysis_name}': {e}")

        # Update the dialog grid in place — remove deleted rows
        if newly_deleted:
            self.grid.options["rowData"] = [
                row
                for row in self.grid.options["rowData"]
                if row["analysis_id"] not in newly_deleted
            ]
            self.grid.update()

        if errors:
            ui.notify(f"Some deletions failed: {'; '.join(errors)}", type="negative")
        elif newly_deleted:
            label = "analysis" if len(newly_deleted) == 1 else "analyses"
            ui.notify(
                f"Deleted {len(newly_deleted)} {label} successfully.", type="positive"
            )

    async def _show_delete_confirmation(self, count: int, rows: list[dict]) -> bool:
        """
        Show confirmation dialog before deleting analyses.

        Args:
            count: Number of analyses selected for deletion
            rows: Selected row data dicts

        Returns:
            True if user confirmed deletion, False otherwise
        """
        if count == 1:
            description = f"analysis '{rows[0]['name']}'"
        else:
            description = f"{count} analyses"

        with ui.dialog() as dialog, ui.card():
            ui.label(f"Are you sure you want to delete {description}?").classes(
                "q-mb-md"
            )
            ui.label("This action cannot be undone.").classes("text-warning q-mb-lg")

            with ui.row().classes("w-full justify-end gap-2"):
                ui.button(
                    "Cancel",
                    on_click=lambda: dialog.submit(False),
                    color="secondary",
                ).props("outline")

                ui.button(
                    "Delete", on_click=lambda: dialog.submit(True), color="negative"
                )

        return await dialog

    def _handle_close(self) -> None:
        """Close the dialog, returning the set of deleted analysis IDs to the caller."""
        self.submit(self.deleted_ids)
__init__(session)

Initialize the Manage Analysis dialog.

Parameters:

Name Type Description Default
session
GuiSession

GUI session containing app context and state

required
Source code in src/cibmangotree/gui/components/manage_analyses.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def __init__(self, session: GuiSession) -> None:
    """
    Initialize the Manage Analysis dialog.

    Args:
        session: GUI session containing app context and state
    """
    super().__init__()

    self.session = session
    now = datetime.now()
    self.analysis_contexts: list[AnalysisContext] = (
        session.current_project.list_analyses()
        if session.current_project is not None
        else []
    )
    # Track IDs of analyses deleted during this dialog session
    self.deleted_ids: set = set()

    # Build dialog UI
    with self, ui.card().classes("w-full"):
        # Dialog title
        ui.label("Manage Analyses").classes("text-h6 q-mb-md")

        # Check if there are analyses to display
        if not self.analysis_contexts:
            ui.label("No analyses found").classes("text-grey q-mb-md")
        else:
            # Analyses grid — multiRow selection enabled
            self.grid = ui.aggrid(
                {
                    "columnDefs": [
                        {"headerName": "Analyzer Name", "field": "name"},
                        {"headerName": "Date Created", "field": "date"},
                        {"headerName": "ID", "field": "analysis_id", "hide": True},
                    ],
                    "rowData": [
                        {
                            "name": ctx.display_name,
                            "date": (
                                present_timestamp(ctx.create_time, now)
                                if ctx.create_time
                                else "Unknown"
                            ),
                            "analysis_id": ctx.id,
                        }
                        for ctx in self.analysis_contexts
                    ],
                    "rowSelection": {"mode": "multiRow"},
                },
                theme="quartz",
            ).classes("w-full h-96")

        # Action buttons
        with ui.row().classes("w-full justify-end gap-2 mt-4"):
            ui.button(
                "Close",
                on_click=self._handle_close,
                color="secondary",
            ).props("outline")

            ui.button(
                "Delete Selected", on_click=self._handle_delete, color="negative"
            )

manage_projects

Classes:

Name Description
ManageProjectsDialog

Dialog for managing projects (view and delete).

ManageProjectsDialog

Bases: dialog

Dialog for managing projects (view and delete).

Displays a list of projects in a grid and allows users to select and delete projects.

Methods:

Name Description
__init__

Initialize the Manage Projects dialog.

Source code in src/cibmangotree/gui/components/manage_projects.py
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
class ManageProjectsDialog(ui.dialog):
    """
    Dialog for managing projects (view and delete).

    Displays a list of projects in a grid and allows users to select
    and delete projects.
    """

    def __init__(self, session: GuiSession) -> None:
        """
        Initialize the Manage Projects dialog.

        Args:
            session: GUI session containing app context and state
        """
        super().__init__()

        self.session = session
        self.project_contexts = self.session.app.list_projects()

        # Build dialog UI
        with self, ui.card().classes("w-full"):
            # Dialog title
            ui.label("Manage Projects").classes("text-h6 q-mb-md")

            # Check if there are projects to display
            if not self.project_contexts:
                ui.label("No projects found").classes("text-grey q-mb-md")
            else:
                # Projects grid
                self.grid = ui.aggrid(
                    {
                        "columnDefs": [
                            {"headerName": "Project Name", "field": "project_name"},
                            {"headerName": "Project ID", "field": "project_id"},
                        ],
                        "rowData": [
                            {
                                "project_name": proj.display_name,
                                "project_id": proj.id,
                            }
                            for proj in self.project_contexts
                        ],
                        "rowSelection": {"mode": "singleRow"},
                    },
                    theme="quartz",
                ).classes("w-full h-96")

            # Action buttons
            with ui.row().classes("w-full justify-end gap-2 mt-4"):
                ui.button(
                    "Cancel",
                    on_click=self._handle_cancel,
                    color="secondary",
                ).props("outline")

                ui.button("Delete", on_click=self._handle_delete, color="primary")

    async def _handle_delete(self) -> None:
        """Handle delete button click - show confirmation then delete selected project."""
        # Get selected row
        selected_rows = await self.grid.get_selected_rows()

        if not selected_rows:
            ui.notify("Please select a project to delete", type="warning")
            return

        selected_row = selected_rows[0]
        project_id = selected_row["project_id"]
        project_name = selected_row["project_name"]

        # Show confirmation dialog
        confirmed = await self._show_delete_confirmation(project_name, project_id)

        if not confirmed:
            return

        try:
            # Find the project context or fetch None if not found
            project_context = next(
                (p for p in self.project_contexts if p.id == project_id), None
            )

            if not project_context:
                ui.notify("Project not found", type="negative")
                return

            # Delete the project via ProjectContext API
            project_context.delete()

            # Close dialog and return information for notification pop up
            self.submit(
                (project_context.is_deleted, project_name, project_id)
            )  # Return True to indicate changes were made

        except Exception as e:
            ui.notify(f"Error deleting project: {str(e)}", type="negative")

    async def _show_delete_confirmation(self, project_name: str, project_id) -> bool:
        """
        Show confirmation dialog before deleting a project.

        Args:
            project_name: Name of the project to delete

        Returns:
            True if user confirmed deletion, False otherwise
        """

        with ui.dialog() as dialog, ui.card():
            ui.label(
                f"Are you sure you want to delete project '{project_name}' (ID: {project_id})?"
            ).classes("q-mb-md")
            ui.label("This action cannot be undone.").classes("text-warning q-mb-lg")

            with ui.row().classes("w-full justify-end gap-2"):
                ui.button(
                    "Cancel",
                    on_click=lambda: dialog.submit(False),
                    color="secondary",
                ).props("outline")

                ui.button(
                    "Delete", on_click=lambda: dialog.submit(True), color="negative"
                )

        return await dialog

    def _handle_cancel(self):
        """Handle cancel button click - close dialog without changes."""
        self.submit(False)  # Return False to indicate no changes
__init__(session)

Initialize the Manage Projects dialog.

Parameters:

Name Type Description Default
session
GuiSession

GUI session containing app context and state

required
Source code in src/cibmangotree/gui/components/manage_projects.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def __init__(self, session: GuiSession) -> None:
    """
    Initialize the Manage Projects dialog.

    Args:
        session: GUI session containing app context and state
    """
    super().__init__()

    self.session = session
    self.project_contexts = self.session.app.list_projects()

    # Build dialog UI
    with self, ui.card().classes("w-full"):
        # Dialog title
        ui.label("Manage Projects").classes("text-h6 q-mb-md")

        # Check if there are projects to display
        if not self.project_contexts:
            ui.label("No projects found").classes("text-grey q-mb-md")
        else:
            # Projects grid
            self.grid = ui.aggrid(
                {
                    "columnDefs": [
                        {"headerName": "Project Name", "field": "project_name"},
                        {"headerName": "Project ID", "field": "project_id"},
                    ],
                    "rowData": [
                        {
                            "project_name": proj.display_name,
                            "project_id": proj.id,
                        }
                        for proj in self.project_contexts
                    ],
                    "rowSelection": {"mode": "singleRow"},
                },
                theme="quartz",
            ).classes("w-full h-96")

        # Action buttons
        with ui.row().classes("w-full justify-end gap-2 mt-4"):
            ui.button(
                "Cancel",
                on_click=self._handle_cancel,
                color="secondary",
            ).props("outline")

            ui.button("Delete", on_click=self._handle_delete, color="primary")

stepper_steps

Modules:

Name Description
analyzer_step
column_mapping_step
params_step
run_step

Classes:

Name Description
AnalyzerSelectionStep

Step 1: Select analyzer from available options.

ColumnMappingStep

Step 2: Map user columns to analyzer input columns.

ParamsConfigStep

Step 3: Configure analyzer parameters.

RunAnalysisStep

Step 4: Execute the analysis with progress tracking.

AnalyzerSelectionStep

Step 1: Select analyzer from available options.

Methods:

Name Description
is_valid

Check if an analyzer is selected.

render

Render the step content.

save_state

Save selection to session. Returns True if successful.

Source code in src/cibmangotree/gui/components/stepper_steps/analyzer_step.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class AnalyzerSelectionStep:
    """Step 1: Select analyzer from available options."""

    def __init__(self, session: GuiSession):
        self.session = session
        self.button_group: ToggleButtonGroup | None = None

    @ui.refreshable_method
    def render(self) -> None:
        """Render the step content."""
        analyzers = self.session.app.context.suite.primary_anlyzers

        if not analyzers:
            ui.label("No analyzers available").classes("text-grey")
            return

        analyzer_options = {
            analyzer.name: analyzer.short_description for analyzer in analyzers
        }
        analyzer_long_descriptions = {
            analyzer.name: analyzer.long_description for analyzer in analyzers
        }

        self.button_group = ToggleButtonGroup()

        with ui.column().classes("items-center w-full"):
            with ui.element().classes("w-[64rem] max-w-full"):
                with ui.row().classes("items-center justify-center gap-4 w-full"):
                    for analyzer_name in analyzer_options.keys():
                        self.button_group.add_button(analyzer_name)

                with ui.element().classes("pt-12 flex justify-center w-full"):
                    DEFAULT_TEXT = (
                        "No analyzer selected. Click button above to select it."
                    )

                    with ui.card().classes("w-[40rem] shadow-none"):
                        with ui.scroll_area().classes("max-h-48"):
                            ui.label().bind_text_from(
                                target_object=self.button_group,
                                target_name="selected_text",
                                backward=lambda text: analyzer_long_descriptions.get(
                                    text, DEFAULT_TEXT
                                ),
                            ).classes("text-grey")

    def is_valid(self) -> bool:
        """Check if an analyzer is selected."""
        return (
            self.button_group is not None
            and self.button_group.get_selected_text() is not None
        )

    def save_state(self) -> bool:
        """Save selection to session. Returns True if successful."""
        if not self.button_group:
            return False

        new_selection = self.button_group.get_selected_text()

        if not new_selection:
            return False

        analyzers = self.session.app.context.suite.primary_anlyzers
        selected_analyzer = next(
            (a for a in analyzers if a.name == new_selection), None
        )

        if not selected_analyzer:
            return False

        self.session.selected_analyzer = selected_analyzer
        self.session.selected_analyzer_name = new_selection
        return True
is_valid()

Check if an analyzer is selected.

Source code in src/cibmangotree/gui/components/stepper_steps/analyzer_step.py
53
54
55
56
57
58
def is_valid(self) -> bool:
    """Check if an analyzer is selected."""
    return (
        self.button_group is not None
        and self.button_group.get_selected_text() is not None
    )
render()

Render the step content.

Source code in src/cibmangotree/gui/components/stepper_steps/analyzer_step.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@ui.refreshable_method
def render(self) -> None:
    """Render the step content."""
    analyzers = self.session.app.context.suite.primary_anlyzers

    if not analyzers:
        ui.label("No analyzers available").classes("text-grey")
        return

    analyzer_options = {
        analyzer.name: analyzer.short_description for analyzer in analyzers
    }
    analyzer_long_descriptions = {
        analyzer.name: analyzer.long_description for analyzer in analyzers
    }

    self.button_group = ToggleButtonGroup()

    with ui.column().classes("items-center w-full"):
        with ui.element().classes("w-[64rem] max-w-full"):
            with ui.row().classes("items-center justify-center gap-4 w-full"):
                for analyzer_name in analyzer_options.keys():
                    self.button_group.add_button(analyzer_name)

            with ui.element().classes("pt-12 flex justify-center w-full"):
                DEFAULT_TEXT = (
                    "No analyzer selected. Click button above to select it."
                )

                with ui.card().classes("w-[40rem] shadow-none"):
                    with ui.scroll_area().classes("max-h-48"):
                        ui.label().bind_text_from(
                            target_object=self.button_group,
                            target_name="selected_text",
                            backward=lambda text: analyzer_long_descriptions.get(
                                text, DEFAULT_TEXT
                            ),
                        ).classes("text-grey")
save_state()

Save selection to session. Returns True if successful.

Source code in src/cibmangotree/gui/components/stepper_steps/analyzer_step.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def save_state(self) -> bool:
    """Save selection to session. Returns True if successful."""
    if not self.button_group:
        return False

    new_selection = self.button_group.get_selected_text()

    if not new_selection:
        return False

    analyzers = self.session.app.context.suite.primary_anlyzers
    selected_analyzer = next(
        (a for a in analyzers if a.name == new_selection), None
    )

    if not selected_analyzer:
        return False

    self.session.selected_analyzer = selected_analyzer
    self.session.selected_analyzer_name = new_selection
    return True

ColumnMappingStep

Step 2: Map user columns to analyzer input columns.

Methods:

Name Description
is_valid

Check if all required columns are mapped.

render

Render the step content with column cards and preview.

save_state

Save column mapping to session.

Source code in src/cibmangotree/gui/components/stepper_steps/column_mapping_step.py
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
class ColumnMappingStep:
    """Step 2: Map user columns to analyzer input columns."""

    def __init__(self, session: GuiSession):
        self.session = session
        self.column_dropdowns: dict = {}
        self.preview_container = None

    @ui.refreshable_method
    def render(self) -> None:
        """Render the step content with column cards and preview."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project

        if not analyzer:
            ui.label("Please select an analyzer first").classes("text-grey")
            return

        if not project:
            ui.label("No project selected").classes("text-grey")
            return

        input_columns = analyzer.input.columns
        user_columns = project.columns

        draft_column_mapping = column_automap(user_columns, input_columns)

        with (
            ui.column()
            .classes("w-full items-center gap-6")
            .style("max-width: 960px; margin: 0 auto;")
        ):
            ui.label("Map Your Data Columns").classes("text-lg font-bold mb-4")

            with ui.row().classes("flex-wrap gap-6 justify-center w-full"):
                for input_col in input_columns:
                    self._build_column_card(
                        input_col, user_columns, draft_column_mapping
                    )

            self.preview_container = ui.column().classes("w-full")
            self._update_preview()

    def _build_column_card(self, input_col, user_columns, draft_column_mapping) -> None:
        """Build a single column mapping card."""
        with ui.card().classes("w-52 p-4 no-shadow border border-gray-200"):
            with ui.row().classes("items-center gap-1"):
                ui.label(input_col.human_readable_name_or_fallback()).classes(
                    "text-bold"
                )
                if input_col.description:
                    with ui.icon("info").classes("text-grey-6 cursor-pointer"):
                        ui.tooltip(input_col.description)

            compatible_columns = [
                user_col
                for user_col in user_columns
                if get_data_type_compatibility_score(
                    input_col.data_type, user_col.data_type
                )
                is not None
            ]

            dropdown_options = {
                f"{user_col.name}": user_col.name for user_col in compatible_columns
            }

            default_value = None
            if input_col.name in draft_column_mapping:
                mapped_col_name = draft_column_mapping[input_col.name]
                default_value = next(
                    (k for k, v in dropdown_options.items() if v == mapped_col_name),
                    None,
                )

            dropdown = (
                ui.select(
                    options=list(dropdown_options.keys()),
                    value=default_value,
                    on_change=lambda: self._update_preview(),
                )
                .classes("w-full mt-2")
                .props("use-chips")
            )

            self.column_dropdowns[input_col.name] = (dropdown, dropdown_options)

    def _build_preview_df(self) -> pl.DataFrame:
        """Build preview DataFrame with currently mapped columns."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project

        if not analyzer or not project:
            return pl.DataFrame()

        current_mapping = {}
        for input_col_name, (dropdown, options) in self.column_dropdowns.items():
            if dropdown.value:
                current_mapping[input_col_name] = options[dropdown.value]

        tmp_col = list(project.column_dict.values())[0]
        N_PREVIEW_ROWS = min(5, tmp_col.data.len())

        preview_data = {}
        for analyzer_col in analyzer.input.columns:
            col_name = analyzer_col.human_readable_name_or_fallback()
            user_col_name = current_mapping.get(analyzer_col.name)

            if user_col_name and user_col_name in project.column_dict:
                user_col = project.column_dict[user_col_name]
                preview_data[col_name] = user_col.head(
                    N_PREVIEW_ROWS
                ).apply_semantic_transform()
            else:
                preview_data[col_name] = [None] * N_PREVIEW_ROWS

        return pl.DataFrame(preview_data)

    def _update_preview(self) -> None:
        """Rebuild preview when dropdown changes."""
        if self.preview_container is None:
            return

        self.preview_container.clear()
        with self.preview_container:
            preview_df = self._build_preview_df()
            preview_title = (
                "Data Preview (first 5 rows)"
                if len(preview_df) > 5
                else "Data Preview (all rows)"
            )
            ui.label(preview_title).classes("text-sm text-grey-7")

            grid = ui.aggrid.from_polars(
                preview_df,
                theme="quartz",
                auto_size_columns=True,
            ).classes("w-full h-64")
            grid.on(
                "firstDataRendered",
                lambda: grid.run_grid_method("sizeColumnsToFit"),
            )

    def is_valid(self) -> bool:
        """Check if all required columns are mapped."""
        analyzer = self.session.selected_analyzer
        if not analyzer:
            return False

        required_columns = [col.name for col in analyzer.input.columns]
        mapped_columns = [
            col_name
            for col_name, (dropdown, _) in self.column_dropdowns.items()
            if dropdown.value
        ]

        return all(col in mapped_columns for col in required_columns)

    def save_state(self) -> bool:
        """Save column mapping to session."""
        final_mapping = {}
        for input_col_name, (dropdown, options) in self.column_dropdowns.items():
            if dropdown.value:
                final_mapping[input_col_name] = options[dropdown.value]

        self.session.column_mapping = final_mapping
        return True
is_valid()

Check if all required columns are mapped.

Source code in src/cibmangotree/gui/components/stepper_steps/column_mapping_step.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def is_valid(self) -> bool:
    """Check if all required columns are mapped."""
    analyzer = self.session.selected_analyzer
    if not analyzer:
        return False

    required_columns = [col.name for col in analyzer.input.columns]
    mapped_columns = [
        col_name
        for col_name, (dropdown, _) in self.column_dropdowns.items()
        if dropdown.value
    ]

    return all(col in mapped_columns for col in required_columns)
render()

Render the step content with column cards and preview.

Source code in src/cibmangotree/gui/components/stepper_steps/column_mapping_step.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@ui.refreshable_method
def render(self) -> None:
    """Render the step content with column cards and preview."""
    analyzer = self.session.selected_analyzer
    project = self.session.current_project

    if not analyzer:
        ui.label("Please select an analyzer first").classes("text-grey")
        return

    if not project:
        ui.label("No project selected").classes("text-grey")
        return

    input_columns = analyzer.input.columns
    user_columns = project.columns

    draft_column_mapping = column_automap(user_columns, input_columns)

    with (
        ui.column()
        .classes("w-full items-center gap-6")
        .style("max-width: 960px; margin: 0 auto;")
    ):
        ui.label("Map Your Data Columns").classes("text-lg font-bold mb-4")

        with ui.row().classes("flex-wrap gap-6 justify-center w-full"):
            for input_col in input_columns:
                self._build_column_card(
                    input_col, user_columns, draft_column_mapping
                )

        self.preview_container = ui.column().classes("w-full")
        self._update_preview()
save_state()

Save column mapping to session.

Source code in src/cibmangotree/gui/components/stepper_steps/column_mapping_step.py
169
170
171
172
173
174
175
176
177
def save_state(self) -> bool:
    """Save column mapping to session."""
    final_mapping = {}
    for input_col_name, (dropdown, options) in self.column_dropdowns.items():
        if dropdown.value:
            final_mapping[input_col_name] = options[dropdown.value]

    self.session.column_mapping = final_mapping
    return True

ParamsConfigStep

Step 3: Configure analyzer parameters.

Methods:

Name Description
has_params

Check if analyzer has configurable parameters.

is_valid

Always valid - params are optional.

render

Render parameter configuration or 'no params' message.

save_state

Save params to session.

Source code in src/cibmangotree/gui/components/stepper_steps/params_step.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
class ParamsConfigStep:
    """Step 3: Configure analyzer parameters."""

    def __init__(self, session: GuiSession):
        self.session = session
        self.params_card: AnalysisParamsCard | None = None
        self._param_values: dict = {}

    @ui.refreshable_method
    def render(self) -> None:
        """Render parameter configuration or 'no params' message."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project
        column_mapping = self.session.column_mapping

        if not analyzer:
            ui.label("Please select an analyzer first").classes("text-grey")
            return

        if not analyzer.params:
            ui.label("This analyzer has no configurable parameters.").classes(
                "text-grey-7"
            )
            self.session.analysis_params = {}
            return

        if not project or not column_mapping:
            ui.label("Please map columns first").classes("text-grey")
            return

        with TemporaryDirectory() as temp_dir:
            default_parameters_context = PrimaryAnalyzerDefaultParametersContext(
                analyzer=analyzer,
                store=self.session.app.context.storage,
                temp_dir=temp_dir,
                input_columns={
                    analyzer_column_name: InputColumnProvider(
                        user_column_name=user_column_name,
                        semantic=project.column_dict[user_column_name].semantic,
                    )
                    for analyzer_column_name, user_column_name in column_mapping.items()
                },
            )

            analyzer_decl = self.session.app.context.suite.get_primary_analyzer(
                analyzer.id
            )

            if not analyzer_decl:
                ui.label(f"Analyzer `{analyzer.id}` not found").classes("text-negative")
                return

            param_values = {
                **{
                    param_spec.id: static_param_default_value
                    for param_spec in analyzer_decl.params
                    if (static_param_default_value := param_spec.default) is not None
                },
                **analyzer_decl.default_params(default_parameters_context),
            }
            param_values = {
                param_id: param_value
                for param_id, param_value in param_values.items()
                if param_value is not None
            }

            self._param_values = param_values

        with (
            ui.column()
            .classes("w-full items-center gap-6")
            .style("max-width: 960px; margin: 0 auto;")
        ):
            ui.label(f"Configure {analyzer.name} Parameters").classes(
                "text-lg font-bold mb-4"
            )

            self.params_card = AnalysisParamsCard(
                params=analyzer.params, default_values=self._param_values
            )

    def is_valid(self) -> bool:
        """Always valid - params are optional."""
        return True

    def save_state(self) -> bool:
        """Save params to session."""
        if self.params_card:
            self.session.analysis_params = self.params_card.get_param_values()
        else:
            self.session.analysis_params = {}
        return True

    def has_params(self) -> bool:
        """Check if analyzer has configurable parameters."""
        analyzer = self.session.selected_analyzer
        return analyzer is not None and len(analyzer.params) > 0
has_params()

Check if analyzer has configurable parameters.

Source code in src/cibmangotree/gui/components/stepper_steps/params_step.py
106
107
108
109
def has_params(self) -> bool:
    """Check if analyzer has configurable parameters."""
    analyzer = self.session.selected_analyzer
    return analyzer is not None and len(analyzer.params) > 0
is_valid()

Always valid - params are optional.

Source code in src/cibmangotree/gui/components/stepper_steps/params_step.py
94
95
96
def is_valid(self) -> bool:
    """Always valid - params are optional."""
    return True
render()

Render parameter configuration or 'no params' message.

Source code in src/cibmangotree/gui/components/stepper_steps/params_step.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
@ui.refreshable_method
def render(self) -> None:
    """Render parameter configuration or 'no params' message."""
    analyzer = self.session.selected_analyzer
    project = self.session.current_project
    column_mapping = self.session.column_mapping

    if not analyzer:
        ui.label("Please select an analyzer first").classes("text-grey")
        return

    if not analyzer.params:
        ui.label("This analyzer has no configurable parameters.").classes(
            "text-grey-7"
        )
        self.session.analysis_params = {}
        return

    if not project or not column_mapping:
        ui.label("Please map columns first").classes("text-grey")
        return

    with TemporaryDirectory() as temp_dir:
        default_parameters_context = PrimaryAnalyzerDefaultParametersContext(
            analyzer=analyzer,
            store=self.session.app.context.storage,
            temp_dir=temp_dir,
            input_columns={
                analyzer_column_name: InputColumnProvider(
                    user_column_name=user_column_name,
                    semantic=project.column_dict[user_column_name].semantic,
                )
                for analyzer_column_name, user_column_name in column_mapping.items()
            },
        )

        analyzer_decl = self.session.app.context.suite.get_primary_analyzer(
            analyzer.id
        )

        if not analyzer_decl:
            ui.label(f"Analyzer `{analyzer.id}` not found").classes("text-negative")
            return

        param_values = {
            **{
                param_spec.id: static_param_default_value
                for param_spec in analyzer_decl.params
                if (static_param_default_value := param_spec.default) is not None
            },
            **analyzer_decl.default_params(default_parameters_context),
        }
        param_values = {
            param_id: param_value
            for param_id, param_value in param_values.items()
            if param_value is not None
        }

        self._param_values = param_values

    with (
        ui.column()
        .classes("w-full items-center gap-6")
        .style("max-width: 960px; margin: 0 auto;")
    ):
        ui.label(f"Configure {analyzer.name} Parameters").classes(
            "text-lg font-bold mb-4"
        )

        self.params_card = AnalysisParamsCard(
            params=analyzer.params, default_values=self._param_values
        )
save_state()

Save params to session.

Source code in src/cibmangotree/gui/components/stepper_steps/params_step.py
 98
 99
100
101
102
103
104
def save_state(self) -> bool:
    """Save params to session."""
    if self.params_card:
        self.session.analysis_params = self.params_card.get_param_values()
    else:
        self.session.analysis_params = {}
    return True

RunAnalysisStep

Step 4: Execute the analysis with progress tracking.

Methods:

Name Description
is_valid

Check if all prerequisites are configured.

render

Render the run analysis button and summary.

Source code in src/cibmangotree/gui/components/stepper_steps/run_step.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
class RunAnalysisStep:
    """Step 4: Execute the analysis with progress tracking."""

    def __init__(self, session: GuiSession, page: GuiPage):
        self.session = session
        self.page = page

    @ui.refreshable_method
    def render(self) -> None:
        """Render the run analysis button and summary."""
        analyzer = self.session.selected_analyzer
        column_mapping = self.session.column_mapping
        params = self.session.analysis_params

        if not analyzer:
            ui.label("Please select an analyzer first").classes("text-grey")
            return

        if not column_mapping:
            ui.label("Please map columns first").classes("text-grey")
            return

        with (
            ui.column()
            .classes("w-full items-center gap-6")
            .style("max-width: 960px; margin: 0 auto;")
        ):
            ui.label("Configuration Summary").classes("text-lg font-bold mb-4")

            with ui.card().classes("w-full p-4 no-shadow border border-gray-200"):
                with ui.column().classes("gap-2"):
                    ui.label(
                        f"Analyzer: {analyzer.name if analyzer else 'Not selected'}"
                    ).classes("text-sm")
                    ui.label(
                        f"Columns: {len(column_mapping) if column_mapping else 0} mapped"
                    ).classes("text-sm")
                    ui.label(
                        f"Parameters: {len(params) if params else 0} configured"
                    ).classes("text-sm")

            ui.button(
                "Run Analysis",
                icon="play_arrow",
                color="primary",
                on_click=self._start_analysis,
            ).classes("text-base")

    def is_valid(self) -> bool:
        """Check if all prerequisites are configured."""
        return (
            self.session.selected_analyzer is not None
            and self.session.column_mapping is not None
            and self.session.analysis_params is not None
        )

    async def _start_analysis(self) -> None:
        """Opens dialog and runs the analysis in a separate process."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project

        if not analyzer or not project:
            self.page.notify_error("Missing analyzer or project")
            return

        try:
            analysis = project.create_analysis(
                analyzer.id,
                self.session.column_mapping,
                self.session.analysis_params,
            )
        except Exception as e:
            self.page.notify_error(f"Failed to create analysis: {str(e)}")
            print(f"Analysis creation error:\n{format_exc()}")
            return

        secondary_analyzers = (
            self.session.app.context.suite.find_toposorted_secondary_analyzers(analyzer)
        )
        secondary_analyzer_ids = [sec.id for sec in secondary_analyzers]

        manager = Manager()
        queue = manager.Queue()
        cancel_event = manager.Event()

        input_columns_data = {
            analyzer_col_name: (
                user_col_name,
                project.column_dict[user_col_name].semantic.semantic_name,
            )
            for analyzer_col_name, user_col_name in analysis.column_mapping.items()
        }

        with (
            ui.dialog().props("persistent") as dialog,
            ui.card()
            .classes("items-center justify-center gap-6")
            .style("width: 600px; max-width: 90vw; padding: 2rem;"),
        ):
            analyzer_header = ui.label(analyzer.name).classes("text-xl font-semibold")
            status_label = (
                ui.label("Initializing...")
                .classes("text-base text-medium")
                .style(f"color: {MANGO_ORANGE}")
            )

            step_list_container = ui.column().classes("w-full gap-1 mt-4")

            log_container = ui.column().classes("w-full gap-1 mt-2")

            with ui.row().classes("gap-4 mt-4"):
                cancel_btn = ui.button(
                    "Cancel Analysis",
                    icon="stop",
                    color="secondary",
                    on_click=lambda: cancel_event.set(),
                ).props("outline")

                success_btn = ui.button(
                    "Continue",
                    icon="arrow_forward",
                    color="primary",
                    on_click=lambda: (
                        dialog.close(),
                        self.page.navigate_to(gui_routes.post_analysis),
                    ),
                )
                success_btn.set_visibility(False)

        analysis_complete = False
        step_rows: dict[str, tuple[ui.spinner, ui.icon, ui.label]] = {}
        current_step_name: str = None

        def _poll_queue():
            nonlocal analysis_complete, current_step_name

            try:
                msg_dict = queue.get_nowait()
            except Empty:
                return

            msg = AnalysisQueueMessage(**msg_dict)

            if msg.type == "analyzer_start":
                analyzer_header.text = msg.analyzer_name or "Analyzer"
                status_label.text = "Analysis starting..."
                step_rows.clear()
                current_step_name = None

            elif msg.type == "analyzer_finish":
                pass

            elif msg.type == "step_start":
                step_name = msg.step_name or "Processing..."

                if current_step_name and current_step_name in step_rows:
                    _, _, prev_label = step_rows[current_step_name]
                    prev_label.classes(add="text-gray-600", remove="text-medium")
                    prev_label.style("")

                current_step_name = step_name
                status_label.text = "Running analysis..."

                with step_list_container:
                    with ui.row().classes("items-center gap-2"):
                        spinner = ui.spinner("gears", size="sm")
                        checkmark = ui.icon(
                            "check_circle", color=MANGO_DARK_GREEN, size="sm"
                        )
                        checkmark.set_visibility(False)
                        label = ui.label(step_name).classes("text-medium")
                step_rows[step_name] = (spinner, checkmark, label)

            elif msg.type == "step_finish":
                if current_step_name and current_step_name in step_rows:
                    spinner, checkmark, label = step_rows[current_step_name]
                    spinner.set_visibility(False)
                    checkmark.set_visibility(True)
                    label.classes(add="text-gray-600", remove="text-medium")
                    label.style("")
                    label.text = current_step_name
                current_step_name = None

            elif msg.type == "step_progress":
                if current_step_name and current_step_name in step_rows:
                    _, _, label = step_rows[current_step_name]
                    progress_pct = (msg.step_progress or 0) * 100
                    label.text = f"{current_step_name} ({progress_pct:.0f}%)"

            elif msg.type == "log":
                with log_container:
                    label = ui.label(msg.message).classes("text-sm")
                    if msg.progress is not None:
                        label.text = f"{msg.message} ({msg.progress * 100:.0f}%)"

            elif msg.type == "error":
                with log_container:
                    ui.label(f"Error: {msg.message}").classes(
                        "text-negative font-bold text-sm"
                    )
                cancel_btn.disable()
                analysis_complete = True

            elif msg.type in ("complete", "cancelled"):
                analysis_complete = True
                status_label.set_visibility(False)
                if msg.type == "complete":
                    self.session.current_analysis = analysis.model
                    success_btn.set_visibility(True)
                    self.page.notify_success("Analysis completed!")
                else:
                    self.page.notify_warning("Analysis was canceled")
                cancel_btn.disable()

        async def run_analysis_task():
            try:
                result = await run.cpu_bound(
                    AnalysisContext.run_worker,
                    analysis.model,
                    analyzer.id,
                    analysis.column_mapping,
                    input_columns_data,
                    secondary_analyzer_ids,
                    analysis.app_context.storage,
                    queue,
                    cancel_event,
                )
                analysis.model.is_draft = result.is_draft
            except Exception as e:
                self.page.notify_error(f"Analysis error: {str(e)}")
                print(f"Analysis error:\n{format_exc()}")
                with log_container:
                    ui.label(f"Error: {str(e)}").classes(
                        "text-negative font-bold text-sm"
                    )
                cancel_btn.disable()
            finally:
                if analysis.is_draft:
                    analysis.delete()

        dialog.open()
        timer = ui.timer(QUEUE_POLL_INTERVAL, _poll_queue)

        await run_analysis_task()

        while not analysis_complete:
            await sleep(QUEUE_POLL_INTERVAL)

        timer.cancel()
is_valid()

Check if all prerequisites are configured.

Source code in src/cibmangotree/gui/components/stepper_steps/run_step.py
65
66
67
68
69
70
71
def is_valid(self) -> bool:
    """Check if all prerequisites are configured."""
    return (
        self.session.selected_analyzer is not None
        and self.session.column_mapping is not None
        and self.session.analysis_params is not None
    )
render()

Render the run analysis button and summary.

Source code in src/cibmangotree/gui/components/stepper_steps/run_step.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@ui.refreshable_method
def render(self) -> None:
    """Render the run analysis button and summary."""
    analyzer = self.session.selected_analyzer
    column_mapping = self.session.column_mapping
    params = self.session.analysis_params

    if not analyzer:
        ui.label("Please select an analyzer first").classes("text-grey")
        return

    if not column_mapping:
        ui.label("Please map columns first").classes("text-grey")
        return

    with (
        ui.column()
        .classes("w-full items-center gap-6")
        .style("max-width: 960px; margin: 0 auto;")
    ):
        ui.label("Configuration Summary").classes("text-lg font-bold mb-4")

        with ui.card().classes("w-full p-4 no-shadow border border-gray-200"):
            with ui.column().classes("gap-2"):
                ui.label(
                    f"Analyzer: {analyzer.name if analyzer else 'Not selected'}"
                ).classes("text-sm")
                ui.label(
                    f"Columns: {len(column_mapping) if column_mapping else 0} mapped"
                ).classes("text-sm")
                ui.label(
                    f"Parameters: {len(params) if params else 0} configured"
                ).classes("text-sm")

        ui.button(
            "Run Analysis",
            icon="play_arrow",
            color="primary",
            on_click=self._start_analysis,
        ).classes("text-base")

analyzer_step

Classes:

Name Description
AnalyzerSelectionStep

Step 1: Select analyzer from available options.

AnalyzerSelectionStep

Step 1: Select analyzer from available options.

Methods:

Name Description
is_valid

Check if an analyzer is selected.

render

Render the step content.

save_state

Save selection to session. Returns True if successful.

Source code in src/cibmangotree/gui/components/stepper_steps/analyzer_step.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class AnalyzerSelectionStep:
    """Step 1: Select analyzer from available options."""

    def __init__(self, session: GuiSession):
        self.session = session
        self.button_group: ToggleButtonGroup | None = None

    @ui.refreshable_method
    def render(self) -> None:
        """Render the step content."""
        analyzers = self.session.app.context.suite.primary_anlyzers

        if not analyzers:
            ui.label("No analyzers available").classes("text-grey")
            return

        analyzer_options = {
            analyzer.name: analyzer.short_description for analyzer in analyzers
        }
        analyzer_long_descriptions = {
            analyzer.name: analyzer.long_description for analyzer in analyzers
        }

        self.button_group = ToggleButtonGroup()

        with ui.column().classes("items-center w-full"):
            with ui.element().classes("w-[64rem] max-w-full"):
                with ui.row().classes("items-center justify-center gap-4 w-full"):
                    for analyzer_name in analyzer_options.keys():
                        self.button_group.add_button(analyzer_name)

                with ui.element().classes("pt-12 flex justify-center w-full"):
                    DEFAULT_TEXT = (
                        "No analyzer selected. Click button above to select it."
                    )

                    with ui.card().classes("w-[40rem] shadow-none"):
                        with ui.scroll_area().classes("max-h-48"):
                            ui.label().bind_text_from(
                                target_object=self.button_group,
                                target_name="selected_text",
                                backward=lambda text: analyzer_long_descriptions.get(
                                    text, DEFAULT_TEXT
                                ),
                            ).classes("text-grey")

    def is_valid(self) -> bool:
        """Check if an analyzer is selected."""
        return (
            self.button_group is not None
            and self.button_group.get_selected_text() is not None
        )

    def save_state(self) -> bool:
        """Save selection to session. Returns True if successful."""
        if not self.button_group:
            return False

        new_selection = self.button_group.get_selected_text()

        if not new_selection:
            return False

        analyzers = self.session.app.context.suite.primary_anlyzers
        selected_analyzer = next(
            (a for a in analyzers if a.name == new_selection), None
        )

        if not selected_analyzer:
            return False

        self.session.selected_analyzer = selected_analyzer
        self.session.selected_analyzer_name = new_selection
        return True
is_valid()

Check if an analyzer is selected.

Source code in src/cibmangotree/gui/components/stepper_steps/analyzer_step.py
53
54
55
56
57
58
def is_valid(self) -> bool:
    """Check if an analyzer is selected."""
    return (
        self.button_group is not None
        and self.button_group.get_selected_text() is not None
    )
render()

Render the step content.

Source code in src/cibmangotree/gui/components/stepper_steps/analyzer_step.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@ui.refreshable_method
def render(self) -> None:
    """Render the step content."""
    analyzers = self.session.app.context.suite.primary_anlyzers

    if not analyzers:
        ui.label("No analyzers available").classes("text-grey")
        return

    analyzer_options = {
        analyzer.name: analyzer.short_description for analyzer in analyzers
    }
    analyzer_long_descriptions = {
        analyzer.name: analyzer.long_description for analyzer in analyzers
    }

    self.button_group = ToggleButtonGroup()

    with ui.column().classes("items-center w-full"):
        with ui.element().classes("w-[64rem] max-w-full"):
            with ui.row().classes("items-center justify-center gap-4 w-full"):
                for analyzer_name in analyzer_options.keys():
                    self.button_group.add_button(analyzer_name)

            with ui.element().classes("pt-12 flex justify-center w-full"):
                DEFAULT_TEXT = (
                    "No analyzer selected. Click button above to select it."
                )

                with ui.card().classes("w-[40rem] shadow-none"):
                    with ui.scroll_area().classes("max-h-48"):
                        ui.label().bind_text_from(
                            target_object=self.button_group,
                            target_name="selected_text",
                            backward=lambda text: analyzer_long_descriptions.get(
                                text, DEFAULT_TEXT
                            ),
                        ).classes("text-grey")
save_state()

Save selection to session. Returns True if successful.

Source code in src/cibmangotree/gui/components/stepper_steps/analyzer_step.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def save_state(self) -> bool:
    """Save selection to session. Returns True if successful."""
    if not self.button_group:
        return False

    new_selection = self.button_group.get_selected_text()

    if not new_selection:
        return False

    analyzers = self.session.app.context.suite.primary_anlyzers
    selected_analyzer = next(
        (a for a in analyzers if a.name == new_selection), None
    )

    if not selected_analyzer:
        return False

    self.session.selected_analyzer = selected_analyzer
    self.session.selected_analyzer_name = new_selection
    return True

column_mapping_step

Classes:

Name Description
ColumnMappingStep

Step 2: Map user columns to analyzer input columns.

ColumnMappingStep

Step 2: Map user columns to analyzer input columns.

Methods:

Name Description
is_valid

Check if all required columns are mapped.

render

Render the step content with column cards and preview.

save_state

Save column mapping to session.

Source code in src/cibmangotree/gui/components/stepper_steps/column_mapping_step.py
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
class ColumnMappingStep:
    """Step 2: Map user columns to analyzer input columns."""

    def __init__(self, session: GuiSession):
        self.session = session
        self.column_dropdowns: dict = {}
        self.preview_container = None

    @ui.refreshable_method
    def render(self) -> None:
        """Render the step content with column cards and preview."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project

        if not analyzer:
            ui.label("Please select an analyzer first").classes("text-grey")
            return

        if not project:
            ui.label("No project selected").classes("text-grey")
            return

        input_columns = analyzer.input.columns
        user_columns = project.columns

        draft_column_mapping = column_automap(user_columns, input_columns)

        with (
            ui.column()
            .classes("w-full items-center gap-6")
            .style("max-width: 960px; margin: 0 auto;")
        ):
            ui.label("Map Your Data Columns").classes("text-lg font-bold mb-4")

            with ui.row().classes("flex-wrap gap-6 justify-center w-full"):
                for input_col in input_columns:
                    self._build_column_card(
                        input_col, user_columns, draft_column_mapping
                    )

            self.preview_container = ui.column().classes("w-full")
            self._update_preview()

    def _build_column_card(self, input_col, user_columns, draft_column_mapping) -> None:
        """Build a single column mapping card."""
        with ui.card().classes("w-52 p-4 no-shadow border border-gray-200"):
            with ui.row().classes("items-center gap-1"):
                ui.label(input_col.human_readable_name_or_fallback()).classes(
                    "text-bold"
                )
                if input_col.description:
                    with ui.icon("info").classes("text-grey-6 cursor-pointer"):
                        ui.tooltip(input_col.description)

            compatible_columns = [
                user_col
                for user_col in user_columns
                if get_data_type_compatibility_score(
                    input_col.data_type, user_col.data_type
                )
                is not None
            ]

            dropdown_options = {
                f"{user_col.name}": user_col.name for user_col in compatible_columns
            }

            default_value = None
            if input_col.name in draft_column_mapping:
                mapped_col_name = draft_column_mapping[input_col.name]
                default_value = next(
                    (k for k, v in dropdown_options.items() if v == mapped_col_name),
                    None,
                )

            dropdown = (
                ui.select(
                    options=list(dropdown_options.keys()),
                    value=default_value,
                    on_change=lambda: self._update_preview(),
                )
                .classes("w-full mt-2")
                .props("use-chips")
            )

            self.column_dropdowns[input_col.name] = (dropdown, dropdown_options)

    def _build_preview_df(self) -> pl.DataFrame:
        """Build preview DataFrame with currently mapped columns."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project

        if not analyzer or not project:
            return pl.DataFrame()

        current_mapping = {}
        for input_col_name, (dropdown, options) in self.column_dropdowns.items():
            if dropdown.value:
                current_mapping[input_col_name] = options[dropdown.value]

        tmp_col = list(project.column_dict.values())[0]
        N_PREVIEW_ROWS = min(5, tmp_col.data.len())

        preview_data = {}
        for analyzer_col in analyzer.input.columns:
            col_name = analyzer_col.human_readable_name_or_fallback()
            user_col_name = current_mapping.get(analyzer_col.name)

            if user_col_name and user_col_name in project.column_dict:
                user_col = project.column_dict[user_col_name]
                preview_data[col_name] = user_col.head(
                    N_PREVIEW_ROWS
                ).apply_semantic_transform()
            else:
                preview_data[col_name] = [None] * N_PREVIEW_ROWS

        return pl.DataFrame(preview_data)

    def _update_preview(self) -> None:
        """Rebuild preview when dropdown changes."""
        if self.preview_container is None:
            return

        self.preview_container.clear()
        with self.preview_container:
            preview_df = self._build_preview_df()
            preview_title = (
                "Data Preview (first 5 rows)"
                if len(preview_df) > 5
                else "Data Preview (all rows)"
            )
            ui.label(preview_title).classes("text-sm text-grey-7")

            grid = ui.aggrid.from_polars(
                preview_df,
                theme="quartz",
                auto_size_columns=True,
            ).classes("w-full h-64")
            grid.on(
                "firstDataRendered",
                lambda: grid.run_grid_method("sizeColumnsToFit"),
            )

    def is_valid(self) -> bool:
        """Check if all required columns are mapped."""
        analyzer = self.session.selected_analyzer
        if not analyzer:
            return False

        required_columns = [col.name for col in analyzer.input.columns]
        mapped_columns = [
            col_name
            for col_name, (dropdown, _) in self.column_dropdowns.items()
            if dropdown.value
        ]

        return all(col in mapped_columns for col in required_columns)

    def save_state(self) -> bool:
        """Save column mapping to session."""
        final_mapping = {}
        for input_col_name, (dropdown, options) in self.column_dropdowns.items():
            if dropdown.value:
                final_mapping[input_col_name] = options[dropdown.value]

        self.session.column_mapping = final_mapping
        return True
is_valid()

Check if all required columns are mapped.

Source code in src/cibmangotree/gui/components/stepper_steps/column_mapping_step.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def is_valid(self) -> bool:
    """Check if all required columns are mapped."""
    analyzer = self.session.selected_analyzer
    if not analyzer:
        return False

    required_columns = [col.name for col in analyzer.input.columns]
    mapped_columns = [
        col_name
        for col_name, (dropdown, _) in self.column_dropdowns.items()
        if dropdown.value
    ]

    return all(col in mapped_columns for col in required_columns)
render()

Render the step content with column cards and preview.

Source code in src/cibmangotree/gui/components/stepper_steps/column_mapping_step.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@ui.refreshable_method
def render(self) -> None:
    """Render the step content with column cards and preview."""
    analyzer = self.session.selected_analyzer
    project = self.session.current_project

    if not analyzer:
        ui.label("Please select an analyzer first").classes("text-grey")
        return

    if not project:
        ui.label("No project selected").classes("text-grey")
        return

    input_columns = analyzer.input.columns
    user_columns = project.columns

    draft_column_mapping = column_automap(user_columns, input_columns)

    with (
        ui.column()
        .classes("w-full items-center gap-6")
        .style("max-width: 960px; margin: 0 auto;")
    ):
        ui.label("Map Your Data Columns").classes("text-lg font-bold mb-4")

        with ui.row().classes("flex-wrap gap-6 justify-center w-full"):
            for input_col in input_columns:
                self._build_column_card(
                    input_col, user_columns, draft_column_mapping
                )

        self.preview_container = ui.column().classes("w-full")
        self._update_preview()
save_state()

Save column mapping to session.

Source code in src/cibmangotree/gui/components/stepper_steps/column_mapping_step.py
169
170
171
172
173
174
175
176
177
def save_state(self) -> bool:
    """Save column mapping to session."""
    final_mapping = {}
    for input_col_name, (dropdown, options) in self.column_dropdowns.items():
        if dropdown.value:
            final_mapping[input_col_name] = options[dropdown.value]

    self.session.column_mapping = final_mapping
    return True

params_step

Classes:

Name Description
ParamsConfigStep

Step 3: Configure analyzer parameters.

ParamsConfigStep

Step 3: Configure analyzer parameters.

Methods:

Name Description
has_params

Check if analyzer has configurable parameters.

is_valid

Always valid - params are optional.

render

Render parameter configuration or 'no params' message.

save_state

Save params to session.

Source code in src/cibmangotree/gui/components/stepper_steps/params_step.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
class ParamsConfigStep:
    """Step 3: Configure analyzer parameters."""

    def __init__(self, session: GuiSession):
        self.session = session
        self.params_card: AnalysisParamsCard | None = None
        self._param_values: dict = {}

    @ui.refreshable_method
    def render(self) -> None:
        """Render parameter configuration or 'no params' message."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project
        column_mapping = self.session.column_mapping

        if not analyzer:
            ui.label("Please select an analyzer first").classes("text-grey")
            return

        if not analyzer.params:
            ui.label("This analyzer has no configurable parameters.").classes(
                "text-grey-7"
            )
            self.session.analysis_params = {}
            return

        if not project or not column_mapping:
            ui.label("Please map columns first").classes("text-grey")
            return

        with TemporaryDirectory() as temp_dir:
            default_parameters_context = PrimaryAnalyzerDefaultParametersContext(
                analyzer=analyzer,
                store=self.session.app.context.storage,
                temp_dir=temp_dir,
                input_columns={
                    analyzer_column_name: InputColumnProvider(
                        user_column_name=user_column_name,
                        semantic=project.column_dict[user_column_name].semantic,
                    )
                    for analyzer_column_name, user_column_name in column_mapping.items()
                },
            )

            analyzer_decl = self.session.app.context.suite.get_primary_analyzer(
                analyzer.id
            )

            if not analyzer_decl:
                ui.label(f"Analyzer `{analyzer.id}` not found").classes("text-negative")
                return

            param_values = {
                **{
                    param_spec.id: static_param_default_value
                    for param_spec in analyzer_decl.params
                    if (static_param_default_value := param_spec.default) is not None
                },
                **analyzer_decl.default_params(default_parameters_context),
            }
            param_values = {
                param_id: param_value
                for param_id, param_value in param_values.items()
                if param_value is not None
            }

            self._param_values = param_values

        with (
            ui.column()
            .classes("w-full items-center gap-6")
            .style("max-width: 960px; margin: 0 auto;")
        ):
            ui.label(f"Configure {analyzer.name} Parameters").classes(
                "text-lg font-bold mb-4"
            )

            self.params_card = AnalysisParamsCard(
                params=analyzer.params, default_values=self._param_values
            )

    def is_valid(self) -> bool:
        """Always valid - params are optional."""
        return True

    def save_state(self) -> bool:
        """Save params to session."""
        if self.params_card:
            self.session.analysis_params = self.params_card.get_param_values()
        else:
            self.session.analysis_params = {}
        return True

    def has_params(self) -> bool:
        """Check if analyzer has configurable parameters."""
        analyzer = self.session.selected_analyzer
        return analyzer is not None and len(analyzer.params) > 0
has_params()

Check if analyzer has configurable parameters.

Source code in src/cibmangotree/gui/components/stepper_steps/params_step.py
106
107
108
109
def has_params(self) -> bool:
    """Check if analyzer has configurable parameters."""
    analyzer = self.session.selected_analyzer
    return analyzer is not None and len(analyzer.params) > 0
is_valid()

Always valid - params are optional.

Source code in src/cibmangotree/gui/components/stepper_steps/params_step.py
94
95
96
def is_valid(self) -> bool:
    """Always valid - params are optional."""
    return True
render()

Render parameter configuration or 'no params' message.

Source code in src/cibmangotree/gui/components/stepper_steps/params_step.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
@ui.refreshable_method
def render(self) -> None:
    """Render parameter configuration or 'no params' message."""
    analyzer = self.session.selected_analyzer
    project = self.session.current_project
    column_mapping = self.session.column_mapping

    if not analyzer:
        ui.label("Please select an analyzer first").classes("text-grey")
        return

    if not analyzer.params:
        ui.label("This analyzer has no configurable parameters.").classes(
            "text-grey-7"
        )
        self.session.analysis_params = {}
        return

    if not project or not column_mapping:
        ui.label("Please map columns first").classes("text-grey")
        return

    with TemporaryDirectory() as temp_dir:
        default_parameters_context = PrimaryAnalyzerDefaultParametersContext(
            analyzer=analyzer,
            store=self.session.app.context.storage,
            temp_dir=temp_dir,
            input_columns={
                analyzer_column_name: InputColumnProvider(
                    user_column_name=user_column_name,
                    semantic=project.column_dict[user_column_name].semantic,
                )
                for analyzer_column_name, user_column_name in column_mapping.items()
            },
        )

        analyzer_decl = self.session.app.context.suite.get_primary_analyzer(
            analyzer.id
        )

        if not analyzer_decl:
            ui.label(f"Analyzer `{analyzer.id}` not found").classes("text-negative")
            return

        param_values = {
            **{
                param_spec.id: static_param_default_value
                for param_spec in analyzer_decl.params
                if (static_param_default_value := param_spec.default) is not None
            },
            **analyzer_decl.default_params(default_parameters_context),
        }
        param_values = {
            param_id: param_value
            for param_id, param_value in param_values.items()
            if param_value is not None
        }

        self._param_values = param_values

    with (
        ui.column()
        .classes("w-full items-center gap-6")
        .style("max-width: 960px; margin: 0 auto;")
    ):
        ui.label(f"Configure {analyzer.name} Parameters").classes(
            "text-lg font-bold mb-4"
        )

        self.params_card = AnalysisParamsCard(
            params=analyzer.params, default_values=self._param_values
        )
save_state()

Save params to session.

Source code in src/cibmangotree/gui/components/stepper_steps/params_step.py
 98
 99
100
101
102
103
104
def save_state(self) -> bool:
    """Save params to session."""
    if self.params_card:
        self.session.analysis_params = self.params_card.get_param_values()
    else:
        self.session.analysis_params = {}
    return True

run_step

Classes:

Name Description
RunAnalysisStep

Step 4: Execute the analysis with progress tracking.

RunAnalysisStep

Step 4: Execute the analysis with progress tracking.

Methods:

Name Description
is_valid

Check if all prerequisites are configured.

render

Render the run analysis button and summary.

Source code in src/cibmangotree/gui/components/stepper_steps/run_step.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
class RunAnalysisStep:
    """Step 4: Execute the analysis with progress tracking."""

    def __init__(self, session: GuiSession, page: GuiPage):
        self.session = session
        self.page = page

    @ui.refreshable_method
    def render(self) -> None:
        """Render the run analysis button and summary."""
        analyzer = self.session.selected_analyzer
        column_mapping = self.session.column_mapping
        params = self.session.analysis_params

        if not analyzer:
            ui.label("Please select an analyzer first").classes("text-grey")
            return

        if not column_mapping:
            ui.label("Please map columns first").classes("text-grey")
            return

        with (
            ui.column()
            .classes("w-full items-center gap-6")
            .style("max-width: 960px; margin: 0 auto;")
        ):
            ui.label("Configuration Summary").classes("text-lg font-bold mb-4")

            with ui.card().classes("w-full p-4 no-shadow border border-gray-200"):
                with ui.column().classes("gap-2"):
                    ui.label(
                        f"Analyzer: {analyzer.name if analyzer else 'Not selected'}"
                    ).classes("text-sm")
                    ui.label(
                        f"Columns: {len(column_mapping) if column_mapping else 0} mapped"
                    ).classes("text-sm")
                    ui.label(
                        f"Parameters: {len(params) if params else 0} configured"
                    ).classes("text-sm")

            ui.button(
                "Run Analysis",
                icon="play_arrow",
                color="primary",
                on_click=self._start_analysis,
            ).classes("text-base")

    def is_valid(self) -> bool:
        """Check if all prerequisites are configured."""
        return (
            self.session.selected_analyzer is not None
            and self.session.column_mapping is not None
            and self.session.analysis_params is not None
        )

    async def _start_analysis(self) -> None:
        """Opens dialog and runs the analysis in a separate process."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project

        if not analyzer or not project:
            self.page.notify_error("Missing analyzer or project")
            return

        try:
            analysis = project.create_analysis(
                analyzer.id,
                self.session.column_mapping,
                self.session.analysis_params,
            )
        except Exception as e:
            self.page.notify_error(f"Failed to create analysis: {str(e)}")
            print(f"Analysis creation error:\n{format_exc()}")
            return

        secondary_analyzers = (
            self.session.app.context.suite.find_toposorted_secondary_analyzers(analyzer)
        )
        secondary_analyzer_ids = [sec.id for sec in secondary_analyzers]

        manager = Manager()
        queue = manager.Queue()
        cancel_event = manager.Event()

        input_columns_data = {
            analyzer_col_name: (
                user_col_name,
                project.column_dict[user_col_name].semantic.semantic_name,
            )
            for analyzer_col_name, user_col_name in analysis.column_mapping.items()
        }

        with (
            ui.dialog().props("persistent") as dialog,
            ui.card()
            .classes("items-center justify-center gap-6")
            .style("width: 600px; max-width: 90vw; padding: 2rem;"),
        ):
            analyzer_header = ui.label(analyzer.name).classes("text-xl font-semibold")
            status_label = (
                ui.label("Initializing...")
                .classes("text-base text-medium")
                .style(f"color: {MANGO_ORANGE}")
            )

            step_list_container = ui.column().classes("w-full gap-1 mt-4")

            log_container = ui.column().classes("w-full gap-1 mt-2")

            with ui.row().classes("gap-4 mt-4"):
                cancel_btn = ui.button(
                    "Cancel Analysis",
                    icon="stop",
                    color="secondary",
                    on_click=lambda: cancel_event.set(),
                ).props("outline")

                success_btn = ui.button(
                    "Continue",
                    icon="arrow_forward",
                    color="primary",
                    on_click=lambda: (
                        dialog.close(),
                        self.page.navigate_to(gui_routes.post_analysis),
                    ),
                )
                success_btn.set_visibility(False)

        analysis_complete = False
        step_rows: dict[str, tuple[ui.spinner, ui.icon, ui.label]] = {}
        current_step_name: str = None

        def _poll_queue():
            nonlocal analysis_complete, current_step_name

            try:
                msg_dict = queue.get_nowait()
            except Empty:
                return

            msg = AnalysisQueueMessage(**msg_dict)

            if msg.type == "analyzer_start":
                analyzer_header.text = msg.analyzer_name or "Analyzer"
                status_label.text = "Analysis starting..."
                step_rows.clear()
                current_step_name = None

            elif msg.type == "analyzer_finish":
                pass

            elif msg.type == "step_start":
                step_name = msg.step_name or "Processing..."

                if current_step_name and current_step_name in step_rows:
                    _, _, prev_label = step_rows[current_step_name]
                    prev_label.classes(add="text-gray-600", remove="text-medium")
                    prev_label.style("")

                current_step_name = step_name
                status_label.text = "Running analysis..."

                with step_list_container:
                    with ui.row().classes("items-center gap-2"):
                        spinner = ui.spinner("gears", size="sm")
                        checkmark = ui.icon(
                            "check_circle", color=MANGO_DARK_GREEN, size="sm"
                        )
                        checkmark.set_visibility(False)
                        label = ui.label(step_name).classes("text-medium")
                step_rows[step_name] = (spinner, checkmark, label)

            elif msg.type == "step_finish":
                if current_step_name and current_step_name in step_rows:
                    spinner, checkmark, label = step_rows[current_step_name]
                    spinner.set_visibility(False)
                    checkmark.set_visibility(True)
                    label.classes(add="text-gray-600", remove="text-medium")
                    label.style("")
                    label.text = current_step_name
                current_step_name = None

            elif msg.type == "step_progress":
                if current_step_name and current_step_name in step_rows:
                    _, _, label = step_rows[current_step_name]
                    progress_pct = (msg.step_progress or 0) * 100
                    label.text = f"{current_step_name} ({progress_pct:.0f}%)"

            elif msg.type == "log":
                with log_container:
                    label = ui.label(msg.message).classes("text-sm")
                    if msg.progress is not None:
                        label.text = f"{msg.message} ({msg.progress * 100:.0f}%)"

            elif msg.type == "error":
                with log_container:
                    ui.label(f"Error: {msg.message}").classes(
                        "text-negative font-bold text-sm"
                    )
                cancel_btn.disable()
                analysis_complete = True

            elif msg.type in ("complete", "cancelled"):
                analysis_complete = True
                status_label.set_visibility(False)
                if msg.type == "complete":
                    self.session.current_analysis = analysis.model
                    success_btn.set_visibility(True)
                    self.page.notify_success("Analysis completed!")
                else:
                    self.page.notify_warning("Analysis was canceled")
                cancel_btn.disable()

        async def run_analysis_task():
            try:
                result = await run.cpu_bound(
                    AnalysisContext.run_worker,
                    analysis.model,
                    analyzer.id,
                    analysis.column_mapping,
                    input_columns_data,
                    secondary_analyzer_ids,
                    analysis.app_context.storage,
                    queue,
                    cancel_event,
                )
                analysis.model.is_draft = result.is_draft
            except Exception as e:
                self.page.notify_error(f"Analysis error: {str(e)}")
                print(f"Analysis error:\n{format_exc()}")
                with log_container:
                    ui.label(f"Error: {str(e)}").classes(
                        "text-negative font-bold text-sm"
                    )
                cancel_btn.disable()
            finally:
                if analysis.is_draft:
                    analysis.delete()

        dialog.open()
        timer = ui.timer(QUEUE_POLL_INTERVAL, _poll_queue)

        await run_analysis_task()

        while not analysis_complete:
            await sleep(QUEUE_POLL_INTERVAL)

        timer.cancel()
is_valid()

Check if all prerequisites are configured.

Source code in src/cibmangotree/gui/components/stepper_steps/run_step.py
65
66
67
68
69
70
71
def is_valid(self) -> bool:
    """Check if all prerequisites are configured."""
    return (
        self.session.selected_analyzer is not None
        and self.session.column_mapping is not None
        and self.session.analysis_params is not None
    )
render()

Render the run analysis button and summary.

Source code in src/cibmangotree/gui/components/stepper_steps/run_step.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@ui.refreshable_method
def render(self) -> None:
    """Render the run analysis button and summary."""
    analyzer = self.session.selected_analyzer
    column_mapping = self.session.column_mapping
    params = self.session.analysis_params

    if not analyzer:
        ui.label("Please select an analyzer first").classes("text-grey")
        return

    if not column_mapping:
        ui.label("Please map columns first").classes("text-grey")
        return

    with (
        ui.column()
        .classes("w-full items-center gap-6")
        .style("max-width: 960px; margin: 0 auto;")
    ):
        ui.label("Configuration Summary").classes("text-lg font-bold mb-4")

        with ui.card().classes("w-full p-4 no-shadow border border-gray-200"):
            with ui.column().classes("gap-2"):
                ui.label(
                    f"Analyzer: {analyzer.name if analyzer else 'Not selected'}"
                ).classes("text-sm")
                ui.label(
                    f"Columns: {len(column_mapping) if column_mapping else 0} mapped"
                ).classes("text-sm")
                ui.label(
                    f"Parameters: {len(params) if params else 0} configured"
                ).classes("text-sm")

        ui.button(
            "Run Analysis",
            icon="play_arrow",
            color="primary",
            on_click=self._start_analysis,
        ).classes("text-base")

toggle

Classes:

Name Description
ToggleButton
ToggleButtonGroup

Manages a group of toggle buttons with mutual exclusivity.

ToggleButton

Bases: button

Methods:

Name Description
set_active

Set the button state externally.

toggle

Toggle the button state.

Source code in src/cibmangotree/gui/components/toggle.py
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class ToggleButton(ui.button):
    def __init__(self, *args, group=None, **kwargs) -> None:
        self._state = False
        self._group = group
        super().__init__(*args, **kwargs)
        self.on("click", self._handle_click)

    def _handle_click(self) -> None:
        """Handle button click, coordinating with group if present."""
        if self._group:
            self._group.select(self)
        else:
            self.toggle()

    def toggle(self) -> None:
        """Toggle the button state."""
        self._state = not self._state
        self.update()

    def set_active(self, active: bool) -> None:
        """Set the button state externally."""
        self._state = active
        self.update()

    def update(self) -> None:
        if self._group:
            # Group mode: green when active, grey when inactive
            self.props(f"color={'primary' if self._state else 'grey'}")
        else:
            # Standalone mode: orange/red toggle
            self.props(f"color={'primary' if self._state else 'red'}")
        super().update()
set_active(active)

Set the button state externally.

Source code in src/cibmangotree/gui/components/toggle.py
23
24
25
26
def set_active(self, active: bool) -> None:
    """Set the button state externally."""
    self._state = active
    self.update()
toggle()

Toggle the button state.

Source code in src/cibmangotree/gui/components/toggle.py
18
19
20
21
def toggle(self) -> None:
    """Toggle the button state."""
    self._state = not self._state
    self.update()

ToggleButtonGroup

Manages a group of toggle buttons with mutual exclusivity.

Methods:

Name Description
add_button

Add a button to the group.

get_selected

Get the currently selected button.

get_selected_text

Get the text of the currently selected button.

select

Select a button, deselecting all others.

Source code in src/cibmangotree/gui/components/toggle.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
class ToggleButtonGroup:
    """Manages a group of toggle buttons with mutual exclusivity."""

    def __init__(self):
        self.buttons = []
        self.selected = None
        self.selected_text = None

    def add_button(self, text: str, **kwargs) -> ToggleButton:
        """Add a button to the group."""
        btn = ToggleButton(text, group=self, **kwargs)
        self.buttons.append(btn)
        return btn

    def select(self, button: ToggleButton) -> None:
        """Select a button, deselecting all others."""
        for btn in self.buttons:
            btn.set_active(False)
        button.set_active(True)
        self.selected = button
        self.selected_text = button.text

    def get_selected(self) -> ToggleButton | None:
        """Get the currently selected button."""
        return self.selected

    def get_selected_text(self) -> str | None:
        """Get the text of the currently selected button."""
        return self.selected.text if self.selected else None
add_button(text, **kwargs)

Add a button to the group.

Source code in src/cibmangotree/gui/components/toggle.py
46
47
48
49
50
def add_button(self, text: str, **kwargs) -> ToggleButton:
    """Add a button to the group."""
    btn = ToggleButton(text, group=self, **kwargs)
    self.buttons.append(btn)
    return btn
get_selected()

Get the currently selected button.

Source code in src/cibmangotree/gui/components/toggle.py
60
61
62
def get_selected(self) -> ToggleButton | None:
    """Get the currently selected button."""
    return self.selected
get_selected_text()

Get the text of the currently selected button.

Source code in src/cibmangotree/gui/components/toggle.py
64
65
66
def get_selected_text(self) -> str | None:
    """Get the text of the currently selected button."""
    return self.selected.text if self.selected else None
select(button)

Select a button, deselecting all others.

Source code in src/cibmangotree/gui/components/toggle.py
52
53
54
55
56
57
58
def select(self, button: ToggleButton) -> None:
    """Select a button, deselecting all others."""
    for btn in self.buttons:
        btn.set_active(False)
    button.set_active(True)
    self.selected = button
    self.selected_text = button.text

cibmangotree.gui.pages

Modules:

Name Description
analysis_config_and_run
analysis_workflow
analyzer_previous
analyzer_select
dataset_preview
importer
project_select
start

Classes:

Name Description
AnalysisConfigAndRunPage

Combined analysis configuration using a stepper.

ImportDatasetPage

Dataset import page for selecting a file.

PreviewDatasetPage

Data preview page showing a sample of the imported dataset.

SelectAnalyzerForkPage

A forking page with two buttons for either advancing to start a new analysis or selecting an old one

SelectPreviousAnalyzerPage

Page for selecting a previous analysis to review.

SelectProjectPage

Projects list page showing existing projects.

StartPage

Main/home page of the application.

AnalysisConfigAndRunPage

Bases: GuiPage

Combined analysis configuration using a stepper.

Methods:

Name Description
render_content

Render the stepper with all configuration steps.

Source code in src/cibmangotree/gui/pages/analysis_config_and_run.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
class AnalysisConfigAndRunPage(GuiPage):
    """Combined analysis configuration using a stepper."""

    stepper: Any = None
    steps: dict = {}

    def __init__(self, session: GuiSession):
        config_title = "Configure Analysis"
        super().__init__(
            session=session,
            route=gui_routes.configure_analysis,
            title=(
                f"{session.current_project.display_name}: {config_title}"
                if session.current_project is not None
                else config_title
            ),
            show_back_button=True,
            back_route=gui_routes.select_analyzer_fork,
            show_footer=True,
        )

    def requires_exit_confirmation(self) -> bool:
        if self.session.analysis_loaded_from_storage:
            return False
        return self.session.selected_analyzer is not None

    def get_exit_confirmation_message(self) -> str:
        return "Your analysis has not been saved yet. Leave anyway?"

    def on_exit(self) -> None:
        self.session.reset_analysis_workflow()

    def render_content(self) -> None:
        """Render the stepper with all configuration steps."""
        if not self.require_project():
            return

        with self.centered_content(max_width="1200px", justify="start", padding="2rem"):
            with (
                ui.stepper()
                .props("horizontal animated")
                .classes("w-full")
                .on_value_change(self._on_step_change) as stepper
            ):
                self.stepper = stepper

                self._render_analyzer_step()
                self._render_column_mapping_step()
                self._render_params_step()
                self._render_run_step()

    def _on_step_change(self, event) -> None:
        """Refresh the step content when navigating to it."""
        step_name = (
            event.value.name if hasattr(event.value, "name") else str(event.value)
        )
        step_key = STEP_NAMES.get(step_name)
        if step_key and step_key in self.steps:
            self.steps[step_key].render.refresh()

    def _render_analyzer_step(self) -> None:
        """Render Step 1: Analyzer Selection."""
        with ui.step("Select Analyzer", icon="science"):
            with ui.element().classes("pt-12 w-full items-center"):
                self.steps["analyzer"] = AnalyzerSelectionStep(self.session)
                self.steps["analyzer"].render()

            with ui.stepper_navigation():
                ui.button(
                    "Next",
                    icon="arrow_forward",
                    color="primary",
                    on_click=self._on_next_analyzer,
                )

    def _render_column_mapping_step(self) -> None:
        """Render Step 2: Column Mapping."""
        with ui.step("Map Columns", icon="pivot_table_chart"):
            with ui.element().classes("pt-6 w-full items-center"):
                self.steps["columns"] = ColumnMappingStep(self.session)
                self.steps["columns"].render()

            with ui.stepper_navigation():
                ui.button(
                    "Next",
                    icon="arrow_forward",
                    color="primary",
                    on_click=self._on_next_columns,
                )
                ui.button("Back", on_click=self.stepper.previous).props("flat")

    def _render_params_step(self) -> None:
        """Render Step 3: Parameter Configuration."""
        with ui.step("Configure Parameters", icon="tune"):
            with ui.element().classes("pt-6 w-full items-center"):
                self.steps["params"] = ParamsConfigStep(self.session)
                self.steps["params"].render()

            with ui.stepper_navigation():
                ui.button(
                    "Next",
                    icon="arrow_forward",
                    color="primary",
                    on_click=self._on_next_params,
                )
                ui.button("Back", on_click=self.stepper.previous).props("flat")

    def _render_run_step(self) -> None:
        """Render Step 4: Run Analysis."""
        with ui.step("Run Analysis", icon="play_arrow"):
            with ui.element().classes("pt-6 w-full items-center"):
                self.steps["run"] = RunAnalysisStep(
                    session=self.session,
                    page=self,
                )
                self.steps["run"].render()

            with ui.stepper_navigation():
                ui.button("Back", on_click=self.stepper.previous).props("flat")

    def _on_next_analyzer(self) -> None:
        """Handle Next from analyzer selection step."""
        step = self.steps.get("analyzer")
        if not step:
            return

        if not step.is_valid():
            self.notify_warning("Please select an analyzer")
            return

        if step.save_state():
            self.stepper.next()

    def _on_next_columns(self) -> None:
        """Handle Next from column mapping step."""
        step = self.steps.get("columns")
        if not step:
            return

        if not step.is_valid():
            self.notify_warning("Please map all required columns")
            return

        if step.save_state():
            self.stepper.next()

    def _on_next_params(self) -> None:
        """Handle Next from parameters step."""
        step = self.steps.get("params")
        if not step:
            return

        if step.save_state():
            self.stepper.next()

render_content()

Render the stepper with all configuration steps.

Source code in src/cibmangotree/gui/pages/analysis_config_and_run.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def render_content(self) -> None:
    """Render the stepper with all configuration steps."""
    if not self.require_project():
        return

    with self.centered_content(max_width="1200px", justify="start", padding="2rem"):
        with (
            ui.stepper()
            .props("horizontal animated")
            .classes("w-full")
            .on_value_change(self._on_step_change) as stepper
        ):
            self.stepper = stepper

            self._render_analyzer_step()
            self._render_column_mapping_step()
            self._render_params_step()
            self._render_run_step()

ImportDatasetPage

Bases: GuiPage

Dataset import page for selecting a file.

Allows users to: 1. Browse for CSV/Excel files 2. View file information 3. Proceed to data preview

Methods:

Name Description
render_content

Render file selection interface.

Source code in src/cibmangotree/gui/pages/importer.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class ImportDatasetPage(GuiPage):
    """
    Dataset import page for selecting a file.

    Allows users to:
    1. Browse for CSV/Excel files
    2. View file information
    3. Proceed to data preview
    """

    def __init__(self, session: GuiSession):
        super().__init__(
            session=session,
            route=gui_routes.import_dataset,
            title="Import Dataset",
            show_back_button=True,
            back_route=gui_routes.new_project,
            show_footer=True,
        )

    def requires_exit_confirmation(self) -> bool:
        if self.session.project_loaded_from_storage:
            return False
        return (
            self.session.current_project is not None
            or self.session.selected_file is not None
        )

    def get_exit_confirmation_message(self) -> str:
        return "No project has been created yet. Leave anyway?"

    def on_exit(self) -> None:
        self.session.reset_project_workflow()

    def render_content(self) -> None:
        """Render file selection interface."""
        # Page state - store selected file path locally
        selected_file_path = None

        # Main content - centered vertically and horizontally
        with self.centered_content(max_width="800px"):
            ui.label("Choose a dataset file.").classes("text-lg")

            # File info card (initially hidden)
            file_info_card = ui.card().style("display: none;")
            with file_info_card:
                file_name_label = ui.label().classes("text-sm")
                file_path_label = ui.label().classes("text-sm")
                file_size_label = ui.label().classes("text-sm")
                file_modified_label = ui.label().classes("text-sm")

                with ui.row().classes("w-full justify-end gap-2 mt-4"):
                    change_file_btn = ui.button(
                        "Pick a different file",
                        icon="edit",
                        color="secondary",
                        on_click=lambda: None,
                    ).props("outline")
                    preview_btn = ui.button(
                        "Next: Preview Data", icon="arrow_forward", color="primary"
                    )

            async def handle_upload(upload: UploadFile) -> None:
                file_contents: bytes = await upload.read()
                self.session.selected_file_content_type = upload.content_type
                self.session.selected_file_name = upload.filename
                self.session.selected_file = BytesIO(file_contents)

            upload_button = UploadButton(
                handle_upload,
                "Browse Files",
                icon="folder_open",
                redirect_url=gui_routes.preview_dataset,
            )

render_content()

Render file selection interface.

Source code in src/cibmangotree/gui/pages/importer.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def render_content(self) -> None:
    """Render file selection interface."""
    # Page state - store selected file path locally
    selected_file_path = None

    # Main content - centered vertically and horizontally
    with self.centered_content(max_width="800px"):
        ui.label("Choose a dataset file.").classes("text-lg")

        # File info card (initially hidden)
        file_info_card = ui.card().style("display: none;")
        with file_info_card:
            file_name_label = ui.label().classes("text-sm")
            file_path_label = ui.label().classes("text-sm")
            file_size_label = ui.label().classes("text-sm")
            file_modified_label = ui.label().classes("text-sm")

            with ui.row().classes("w-full justify-end gap-2 mt-4"):
                change_file_btn = ui.button(
                    "Pick a different file",
                    icon="edit",
                    color="secondary",
                    on_click=lambda: None,
                ).props("outline")
                preview_btn = ui.button(
                    "Next: Preview Data", icon="arrow_forward", color="primary"
                )

        async def handle_upload(upload: UploadFile) -> None:
            file_contents: bytes = await upload.read()
            self.session.selected_file_content_type = upload.content_type
            self.session.selected_file_name = upload.filename
            self.session.selected_file = BytesIO(file_contents)

        upload_button = UploadButton(
            handle_upload,
            "Browse Files",
            icon="folder_open",
            redirect_url=gui_routes.preview_dataset,
        )

PreviewDatasetPage

Bases: GuiPage

Data preview page showing a sample of the imported dataset.

Allows users to: 1. View first 5 rows of data with column info 2. Adjust import options (delimiter, encoding, etc.) 3. Confirm and create project with imported data

Source code in src/cibmangotree/gui/pages/dataset_preview.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
class PreviewDatasetPage(GuiPage):
    """
    Data preview page showing a sample of the imported dataset.

    Allows users to:
    1. View first 5 rows of data with column info
    2. Adjust import options (delimiter, encoding, etc.)
    3. Confirm and create project with imported data
    """

    def __init__(self, session: GuiSession):
        super().__init__(
            session=session,
            route=gui_routes.preview_dataset,
            title="Data Preview",
            show_back_button=True,
            back_route=gui_routes.import_dataset,
            show_footer=True,
        )

    def requires_exit_confirmation(self) -> bool:
        return self.session.selected_file is not None

    def get_exit_confirmation_message(self) -> str:
        return "No project has been created yet. Leave anyway?"

    def on_exit(self) -> None:
        self.session.reset_project_workflow()

    def _selected_file_does_not_exists(self) -> bool:
        return (
            not self.session.selected_file_content_type
            or not self.session.selected_file
            or not self.session.selected_file_name
        )

    def render_content(self) -> None:
        # Validate file is selected
        if self._selected_file_does_not_exists():
            self.notify_warning("No file selected. Redirecting...")
            self.navigate_to(gui_routes.import_dataset)
            return

        # Auto-detect importer
        importer = None
        for imp in importers:
            if imp.suggest(cast(str, self.session.selected_file_content_type)):
                importer = imp
                break

        if not importer:
            self.notify_error("Could not detect file format")
            self.navigate_to(gui_routes.import_dataset)
            return

        # Initialize import session and load preview
        try:
            import_session = importer.init_session(
                cast(BytesIO, self.session.selected_file)
            )
            if not import_session:
                raise ValueError("Failed to initialize import session")

            # Store session for later use
            self.session.import_session = import_session

            N_ROWS_FOR_PREVIEW = 5
            import_preview = import_session.load_preview(n_records=N_ROWS_FOR_PREVIEW)

            # Container for dynamic preview updates
            data_preview_container = None

            # Retry callback for import options dialog
            async def handle_retry(updated_session):
                """Handle retry from import options dialog."""
                if data_preview_container is None:
                    return

                nonlocal import_preview

                try:
                    # Update session in GuiSession
                    self.session.import_session = updated_session

                    # Reload preview with new settings
                    import_preview = updated_session.load_preview(
                        n_records=N_ROWS_FOR_PREVIEW
                    )

                    # Clear and rebuild data preview
                    data_preview_container.clear()
                    with data_preview_container:
                        self._make_preview_grid(import_preview)

                    self.notify_success("Preview updated successfully!")

                except Exception as e:
                    self.notify_error(f"Error: {str(e)}")
                    print(f"Retry import error:\n{format_exc()}")

            # Open import options dialog
            async def open_import_options():
                if (
                    self.session.import_session is None
                    or self._selected_file_does_not_exists()
                ):
                    return

                dialog = ImportOptionsDialog(
                    import_session=self.session.import_session,
                    selected_file=cast(BytesIO, self.session.selected_file),
                    on_retry=handle_retry,
                )
                await dialog

            # Import and create project
            async def import_data_create_project():
                try:
                    # Create project using session data
                    project = self.session.app.create_project(
                        name=(
                            self.session.new_project_name
                            if self.session.new_project_name is not None
                            else ""
                        ),
                        importer_session=self.session.import_session,
                    )

                    # Store project in session
                    self.session.current_project = project

                    # Navigate to analyzer selection
                    self.navigate_to(gui_routes.configure_analysis)

                except Exception as e:
                    self.notify_error(f"Error creating project: {str(e)}")
                    print(f"Project creation error:\n{format_exc()}")

            # Main content area - centered
            with self.centered_content(
                max_width="1200px", height="70vh", padding="2rem"
            ):
                # Data Preview (with container for dynamic updates)
                data_preview_container = ui.column().classes("w-full")
                with data_preview_container:
                    self._make_preview_grid(import_preview)

                # Bottom Actions
                with ui.row().classes("w-full justify-center gap-2 mt-4"):
                    ui.button(
                        "Change Import Options",
                        icon="settings",
                        color="secondary",
                        on_click=open_import_options,
                    ).props("outline")

                    ui.button(
                        "Import and Create Project",
                        icon="upload",
                        color="primary",
                        on_click=import_data_create_project,
                    )

        except Exception as e:
            self.notify_error(f"Error loading preview: {str(e)}")
            print(f"Preview error:\n{format_exc()}")
            self.navigate_to(gui_routes.import_dataset)
            return

    def _make_preview_grid(self, data_frame):
        """
        Render preview data grid with column information.

        Args:
            data_frame: Polars DataFrame containing preview data
        """
        ui.label("Data Preview (first 5 rows)").classes("text-lg")

        # Count empty columns
        n_empty = sum((c[0] == 0 for c in data_frame.count().iter_columns()))
        ui.label(
            f"Nr. detected columns: {len(data_frame.columns)} ({n_empty} empty)"
        ).classes("text-sm")

        # Display data grid
        ui.aggrid.from_polars(
            data_frame, theme="quartz", auto_size_columns=False
        ).classes("w-full h-64")

SelectAnalyzerForkPage

Bases: GuiPage

A forking page with two buttons for either advancing to start a new analysis or selecting an old one

Source code in src/cibmangotree/gui/pages/analyzer_select.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class SelectAnalyzerForkPage(GuiPage):
    """A forking page with two buttons for either advancing to start a new analysis or selecting an old one"""

    def __init__(self, session: GuiSession):
        super().__init__(
            session=session,
            route=gui_routes.select_analyzer_fork,
            title=(
                session.current_project.display_name
                if session.current_project is not None
                else ""
            ),
            show_back_button=True,
            back_route=gui_routes.select_project,
            show_footer=True,
        )

    def on_exit(self) -> None:
        self.session.reset_analysis_workflow()

    def render_content(self):
        # Main content area - centered vertically
        with self.centered_content():
            two_button_choice_fork_content(
                prompt="What do you want to do next?",
                left_button_label="Start a New Test",
                left_button_icon="computer",
                left_button_on_click=lambda: self.navigate_to(
                    gui_routes.configure_analysis
                ),
                right_button_label="Review a Previous Test",
                right_button_on_click=lambda: self.navigate_to(
                    gui_routes.select_previous_analyzer
                ),
                right_button_icon="refresh",
            )

SelectPreviousAnalyzerPage

Bases: GuiPage

Page for selecting a previous analysis to review.

Methods:

Name Description
render_content

Render previous analysis selection interface.

Source code in src/cibmangotree/gui/pages/analyzer_previous.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
class SelectPreviousAnalyzerPage(GuiPage):
    """
    Page for selecting a previous analysis to review.
    """

    grid: ui.aggrid | None = None
    analysis_contexts: list[AnalysisContext] = []

    def __init__(self, session: GuiSession):
        select_previous_title: str = "Select Previous Analysis"
        super().__init__(
            session=session,
            route=gui_routes.select_previous_analyzer,
            title=(
                f"{session.current_project.display_name}: {select_previous_title}"
                if session.current_project is not None
                else select_previous_title
            ),
            show_back_button=True,
            back_route=gui_routes.select_analyzer_fork,
            show_footer=True,
        )

    def requires_exit_confirmation(self) -> bool:
        return self.session.current_analysis is not None

    def get_exit_confirmation_message(self) -> str:
        return "Your analysis selection will be lost. Leave anyway?"

    def on_exit(self) -> None:
        self.session.reset_analysis_workflow()

    def render_content(self) -> None:
        """Render previous analysis selection interface."""
        # Ensure a project is selected
        if not self.require_project():
            return

        # Store analyses as instance state so the grid can be updated in place
        self.analysis_contexts = self.session.current_project.list_analyses()

        # Main content - centered
        with self.centered_content(max_width="800px"):
            ui.label("Review a Previous Analysis").classes("text-lg")

            if self.analysis_contexts:
                self._render_previous_analyses_grid()
            else:
                ui.label("No previous tests have been found.").classes("text-grey")

            async def _on_proceed():
                """Handle proceed button click."""
                if not self.analysis_contexts:
                    self.notify_warning("No analyses available")
                    return

                if self.grid is None:
                    return

                selected_rows = await self.grid.get_selected_rows()
                if not selected_rows:
                    self.notify_warning("Please select a previous analysis")
                    return

                selected_id = selected_rows[0].get("analysis_id")
                if not selected_id:
                    self.notify_error("Selected row is missing analysis ID")
                    return

                selected_context = next(
                    (ctx for ctx in self.analysis_contexts if ctx.id == selected_id),
                    None,
                )

                if selected_context is None:
                    self.notify_error(f"Analysis '{selected_id}' not found in project")
                    return

                if selected_context.is_draft:
                    self.notify_warning(
                        "This analysis is incomplete and cannot be viewed. "
                        "Please select a completed analysis."
                    )
                    return

                self.session.current_analysis = selected_context.model
                self.session.selected_analyzer = selected_context.analyzer_spec
                self.session.selected_analyzer_name = (
                    selected_context.analyzer_spec.name
                )
                self.session.column_mapping = selected_context.column_mapping
                self.session.analysis_params = selected_context.backfilled_param_values
                self.session.analysis_loaded_from_storage = True

                self.navigate_to(gui_routes.post_analysis)

            async def _on_manage_analyses():
                """Handle manage analyses button click."""
                dialog = ManageAnalysisDialog(session=self.session)
                deleted_ids: set = await dialog

                if not deleted_ids:
                    return

                # Remove deleted analyses from instance state
                self.analysis_contexts = [
                    ctx for ctx in self.analysis_contexts if ctx.id not in deleted_ids
                ]

                # Update the page grid in place — no page navigation needed
                if self.grid is not None:
                    now = datetime.now()
                    self.grid.options["rowData"] = [
                        {
                            "name": ctx.display_name,
                            "date": (
                                present_timestamp(ctx.create_time, now)
                                if ctx.create_time
                                else "Unknown"
                            ),
                            "analysis_id": ctx.id,
                        }
                        for ctx in self.analysis_contexts
                    ]
                    self.grid.update()

                count = len(deleted_ids)
                label = "analysis" if count == 1 else "analyses"
                self.notify_success(f"Deleted {count} {label}.")

            with ui.row().classes("gap-4"):
                ui.button(
                    "Manage Analyses",
                    icon="settings",
                    color="secondary",
                    on_click=_on_manage_analyses,
                )
                ui.button(
                    "Proceed",
                    icon="arrow_forward",
                    color="primary",
                    on_click=_on_proceed,
                )

    def _render_previous_analyses_grid(self) -> None:
        """Render grid of previous analyses."""
        now = datetime.now()

        self.grid = ui.aggrid(
            {
                "columnDefs": [
                    {"headerName": "Analyzer Name", "field": "name"},
                    {"headerName": "Date Created", "field": "date"},
                    {"headerName": "ID", "field": "analysis_id", "hide": True},
                ],
                "rowData": [
                    {
                        "name": ctx.display_name,
                        "date": (
                            present_timestamp(ctx.create_time, now)
                            if ctx.create_time
                            else "Unknown"
                        ),
                        "analysis_id": ctx.id,
                    }
                    for ctx in self.analysis_contexts
                ],
                "rowSelection": {"mode": "singleRow"},
            },
            theme="quartz",
        ).classes("w-full h-64")

render_content()

Render previous analysis selection interface.

Source code in src/cibmangotree/gui/pages/analyzer_previous.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def render_content(self) -> None:
    """Render previous analysis selection interface."""
    # Ensure a project is selected
    if not self.require_project():
        return

    # Store analyses as instance state so the grid can be updated in place
    self.analysis_contexts = self.session.current_project.list_analyses()

    # Main content - centered
    with self.centered_content(max_width="800px"):
        ui.label("Review a Previous Analysis").classes("text-lg")

        if self.analysis_contexts:
            self._render_previous_analyses_grid()
        else:
            ui.label("No previous tests have been found.").classes("text-grey")

        async def _on_proceed():
            """Handle proceed button click."""
            if not self.analysis_contexts:
                self.notify_warning("No analyses available")
                return

            if self.grid is None:
                return

            selected_rows = await self.grid.get_selected_rows()
            if not selected_rows:
                self.notify_warning("Please select a previous analysis")
                return

            selected_id = selected_rows[0].get("analysis_id")
            if not selected_id:
                self.notify_error("Selected row is missing analysis ID")
                return

            selected_context = next(
                (ctx for ctx in self.analysis_contexts if ctx.id == selected_id),
                None,
            )

            if selected_context is None:
                self.notify_error(f"Analysis '{selected_id}' not found in project")
                return

            if selected_context.is_draft:
                self.notify_warning(
                    "This analysis is incomplete and cannot be viewed. "
                    "Please select a completed analysis."
                )
                return

            self.session.current_analysis = selected_context.model
            self.session.selected_analyzer = selected_context.analyzer_spec
            self.session.selected_analyzer_name = (
                selected_context.analyzer_spec.name
            )
            self.session.column_mapping = selected_context.column_mapping
            self.session.analysis_params = selected_context.backfilled_param_values
            self.session.analysis_loaded_from_storage = True

            self.navigate_to(gui_routes.post_analysis)

        async def _on_manage_analyses():
            """Handle manage analyses button click."""
            dialog = ManageAnalysisDialog(session=self.session)
            deleted_ids: set = await dialog

            if not deleted_ids:
                return

            # Remove deleted analyses from instance state
            self.analysis_contexts = [
                ctx for ctx in self.analysis_contexts if ctx.id not in deleted_ids
            ]

            # Update the page grid in place — no page navigation needed
            if self.grid is not None:
                now = datetime.now()
                self.grid.options["rowData"] = [
                    {
                        "name": ctx.display_name,
                        "date": (
                            present_timestamp(ctx.create_time, now)
                            if ctx.create_time
                            else "Unknown"
                        ),
                        "analysis_id": ctx.id,
                    }
                    for ctx in self.analysis_contexts
                ]
                self.grid.update()

            count = len(deleted_ids)
            label = "analysis" if count == 1 else "analyses"
            self.notify_success(f"Deleted {count} {label}.")

        with ui.row().classes("gap-4"):
            ui.button(
                "Manage Analyses",
                icon="settings",
                color="secondary",
                on_click=_on_manage_analyses,
            )
            ui.button(
                "Proceed",
                icon="arrow_forward",
                color="primary",
                on_click=_on_proceed,
            )

SelectProjectPage

Bases: GuiPage

Projects list page showing existing projects.

Allows users to select an existing project to work with.

Methods:

Name Description
render_content

Render projects list with selection interface.

Source code in src/cibmangotree/gui/pages/project_select.py
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
class SelectProjectPage(GuiPage):
    """
    Projects list page showing existing projects.

    Allows users to select an existing project to work with.
    """

    def __init__(self, session: GuiSession):
        super().__init__(
            session=session,
            route="/select_project",
            title="CIB Mango Tree",
            show_back_button=True,
            back_route="/",
            show_footer=True,
        )

    def on_exit(self) -> None:
        self.session.reset_analysis_workflow()

    def render_content(self) -> None:
        """Render projects list with selection interface."""
        # Projects list - centered
        with (
            ui.row()
            .classes("items-center center-justify")
            .style("max-width: 600px; margin: 0 auto;")
        ):
            # Get projects from app via session
            projects = self.session.app.list_projects()

            if not projects:
                # No projects found - show message
                with ui.column().classes("items-center q-mt-lg"):
                    ui.label("No existing projects found.").classes("text-grey")
                    ui.label("Create a new project to get started.").classes(
                        "text-grey"
                    )
            else:
                # Create dropdown with project names
                project_options = {
                    project.display_name: project for project in projects
                }

                with self.centered_content(max_width="600px"):
                    selected_project = (
                        ui.select(
                            label="Select a project",
                            options=list(project_options.keys()),
                            with_input=True,
                        )
                        .classes("q-mt-md")
                        .style("width: 100%; max-width: 400px")
                    )

                    def on_project_selected():
                        """Handle project selection and navigate to analyzer page."""
                        if selected_project.value:
                            # Store selected project in session
                            self.session.current_project = project_options[
                                selected_project.value
                            ]
                            self.session.project_loaded_from_storage = True
                            self.notify_success(
                                f"Selected project: {self.session.current_project.display_name}"
                            )
                            self.navigate_to(gui_routes.select_analyzer_fork)

                    async def open_manage_projects():
                        """Open the Manage Projects dialog."""
                        from cibmangotree.gui.components.manage_projects import (
                            ManageProjectsDialog,
                        )

                        dialog = ManageProjectsDialog(session=self.session)
                        result = await dialog

                        # If a project_id exists, show notification and refresh
                        # result -> (is_deleted, project_name, project_id)
                        if isinstance(result, tuple) and result[0]:
                            self.notify_success(
                                f"Successfully deleted project: {result[1]} (ID: {result[2]})"
                            )

                    with ui.row().classes("items-center center-justify"):
                        ui.button(
                            "Manage Projects",
                            on_click=open_manage_projects,
                            icon="settings",
                            color="secondary",
                        ).props("outline").classes("q-mt-md")

                        ui.button(
                            "Open Project",
                            on_click=on_project_selected,
                            icon="arrow_forward",
                            color="primary",
                        ).classes("q-mt-md")

render_content()

Render projects list with selection interface.

Source code in src/cibmangotree/gui/pages/project_select.py
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def render_content(self) -> None:
    """Render projects list with selection interface."""
    # Projects list - centered
    with (
        ui.row()
        .classes("items-center center-justify")
        .style("max-width: 600px; margin: 0 auto;")
    ):
        # Get projects from app via session
        projects = self.session.app.list_projects()

        if not projects:
            # No projects found - show message
            with ui.column().classes("items-center q-mt-lg"):
                ui.label("No existing projects found.").classes("text-grey")
                ui.label("Create a new project to get started.").classes(
                    "text-grey"
                )
        else:
            # Create dropdown with project names
            project_options = {
                project.display_name: project for project in projects
            }

            with self.centered_content(max_width="600px"):
                selected_project = (
                    ui.select(
                        label="Select a project",
                        options=list(project_options.keys()),
                        with_input=True,
                    )
                    .classes("q-mt-md")
                    .style("width: 100%; max-width: 400px")
                )

                def on_project_selected():
                    """Handle project selection and navigate to analyzer page."""
                    if selected_project.value:
                        # Store selected project in session
                        self.session.current_project = project_options[
                            selected_project.value
                        ]
                        self.session.project_loaded_from_storage = True
                        self.notify_success(
                            f"Selected project: {self.session.current_project.display_name}"
                        )
                        self.navigate_to(gui_routes.select_analyzer_fork)

                async def open_manage_projects():
                    """Open the Manage Projects dialog."""
                    from cibmangotree.gui.components.manage_projects import (
                        ManageProjectsDialog,
                    )

                    dialog = ManageProjectsDialog(session=self.session)
                    result = await dialog

                    # If a project_id exists, show notification and refresh
                    # result -> (is_deleted, project_name, project_id)
                    if isinstance(result, tuple) and result[0]:
                        self.notify_success(
                            f"Successfully deleted project: {result[1]} (ID: {result[2]})"
                        )

                with ui.row().classes("items-center center-justify"):
                    ui.button(
                        "Manage Projects",
                        on_click=open_manage_projects,
                        icon="settings",
                        color="secondary",
                    ).props("outline").classes("q-mt-md")

                    ui.button(
                        "Open Project",
                        on_click=on_project_selected,
                        icon="arrow_forward",
                        color="primary",
                    ).classes("q-mt-md")

StartPage

Bases: GuiPage

Main/home page of the application.

Displays welcome message and primary navigation buttons for creating a new project or viewing existing projects.

Methods:

Name Description
render_content

Render main page content with action buttons.

Source code in src/cibmangotree/gui/pages/start.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class StartPage(GuiPage):
    """
    Main/home page of the application.

    Displays welcome message and primary navigation buttons for
    creating a new project or viewing existing projects.
    """

    def __init__(self, session: GuiSession):
        super().__init__(
            session=session,
            route="/",
            title="CIB Mango Tree",
            show_back_button=False,  # Home page - no back navigation
            show_footer=True,
        )

    def render_content(self) -> None:
        """Render main page content with action buttons."""
        # Main content area - centered vertically
        with self.centered_content():
            # Hero logo
            ui.html(self._load_svg_icon("cibmt_logo"), sanitize=False).classes(
                "size-36 q-mb-xl"
            )

            # Action buttons row
            with ui.row().classes("gap-4"):
                ui.button(
                    "New Project",
                    on_click=lambda: self.navigate_to(gui_routes.new_project),
                    icon="add",
                    color="primary",
                )

                ui.button(
                    "Show Existing Projects",
                    on_click=lambda: self.navigate_to(gui_routes.select_project),
                    icon="folder",
                    color="primary",
                )

render_content()

Render main page content with action buttons.

Source code in src/cibmangotree/gui/pages/start.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
def render_content(self) -> None:
    """Render main page content with action buttons."""
    # Main content area - centered vertically
    with self.centered_content():
        # Hero logo
        ui.html(self._load_svg_icon("cibmt_logo"), sanitize=False).classes(
            "size-36 q-mb-xl"
        )

        # Action buttons row
        with ui.row().classes("gap-4"):
            ui.button(
                "New Project",
                on_click=lambda: self.navigate_to(gui_routes.new_project),
                icon="add",
                color="primary",
            )

            ui.button(
                "Show Existing Projects",
                on_click=lambda: self.navigate_to(gui_routes.select_project),
                icon="folder",
                color="primary",
            )

analysis_config_and_run

Classes:

Name Description
AnalysisConfigAndRunPage

Combined analysis configuration using a stepper.

AnalysisConfigAndRunPage

Bases: GuiPage

Combined analysis configuration using a stepper.

Methods:

Name Description
render_content

Render the stepper with all configuration steps.

Source code in src/cibmangotree/gui/pages/analysis_config_and_run.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
class AnalysisConfigAndRunPage(GuiPage):
    """Combined analysis configuration using a stepper."""

    stepper: Any = None
    steps: dict = {}

    def __init__(self, session: GuiSession):
        config_title = "Configure Analysis"
        super().__init__(
            session=session,
            route=gui_routes.configure_analysis,
            title=(
                f"{session.current_project.display_name}: {config_title}"
                if session.current_project is not None
                else config_title
            ),
            show_back_button=True,
            back_route=gui_routes.select_analyzer_fork,
            show_footer=True,
        )

    def requires_exit_confirmation(self) -> bool:
        if self.session.analysis_loaded_from_storage:
            return False
        return self.session.selected_analyzer is not None

    def get_exit_confirmation_message(self) -> str:
        return "Your analysis has not been saved yet. Leave anyway?"

    def on_exit(self) -> None:
        self.session.reset_analysis_workflow()

    def render_content(self) -> None:
        """Render the stepper with all configuration steps."""
        if not self.require_project():
            return

        with self.centered_content(max_width="1200px", justify="start", padding="2rem"):
            with (
                ui.stepper()
                .props("horizontal animated")
                .classes("w-full")
                .on_value_change(self._on_step_change) as stepper
            ):
                self.stepper = stepper

                self._render_analyzer_step()
                self._render_column_mapping_step()
                self._render_params_step()
                self._render_run_step()

    def _on_step_change(self, event) -> None:
        """Refresh the step content when navigating to it."""
        step_name = (
            event.value.name if hasattr(event.value, "name") else str(event.value)
        )
        step_key = STEP_NAMES.get(step_name)
        if step_key and step_key in self.steps:
            self.steps[step_key].render.refresh()

    def _render_analyzer_step(self) -> None:
        """Render Step 1: Analyzer Selection."""
        with ui.step("Select Analyzer", icon="science"):
            with ui.element().classes("pt-12 w-full items-center"):
                self.steps["analyzer"] = AnalyzerSelectionStep(self.session)
                self.steps["analyzer"].render()

            with ui.stepper_navigation():
                ui.button(
                    "Next",
                    icon="arrow_forward",
                    color="primary",
                    on_click=self._on_next_analyzer,
                )

    def _render_column_mapping_step(self) -> None:
        """Render Step 2: Column Mapping."""
        with ui.step("Map Columns", icon="pivot_table_chart"):
            with ui.element().classes("pt-6 w-full items-center"):
                self.steps["columns"] = ColumnMappingStep(self.session)
                self.steps["columns"].render()

            with ui.stepper_navigation():
                ui.button(
                    "Next",
                    icon="arrow_forward",
                    color="primary",
                    on_click=self._on_next_columns,
                )
                ui.button("Back", on_click=self.stepper.previous).props("flat")

    def _render_params_step(self) -> None:
        """Render Step 3: Parameter Configuration."""
        with ui.step("Configure Parameters", icon="tune"):
            with ui.element().classes("pt-6 w-full items-center"):
                self.steps["params"] = ParamsConfigStep(self.session)
                self.steps["params"].render()

            with ui.stepper_navigation():
                ui.button(
                    "Next",
                    icon="arrow_forward",
                    color="primary",
                    on_click=self._on_next_params,
                )
                ui.button("Back", on_click=self.stepper.previous).props("flat")

    def _render_run_step(self) -> None:
        """Render Step 4: Run Analysis."""
        with ui.step("Run Analysis", icon="play_arrow"):
            with ui.element().classes("pt-6 w-full items-center"):
                self.steps["run"] = RunAnalysisStep(
                    session=self.session,
                    page=self,
                )
                self.steps["run"].render()

            with ui.stepper_navigation():
                ui.button("Back", on_click=self.stepper.previous).props("flat")

    def _on_next_analyzer(self) -> None:
        """Handle Next from analyzer selection step."""
        step = self.steps.get("analyzer")
        if not step:
            return

        if not step.is_valid():
            self.notify_warning("Please select an analyzer")
            return

        if step.save_state():
            self.stepper.next()

    def _on_next_columns(self) -> None:
        """Handle Next from column mapping step."""
        step = self.steps.get("columns")
        if not step:
            return

        if not step.is_valid():
            self.notify_warning("Please map all required columns")
            return

        if step.save_state():
            self.stepper.next()

    def _on_next_params(self) -> None:
        """Handle Next from parameters step."""
        step = self.steps.get("params")
        if not step:
            return

        if step.save_state():
            self.stepper.next()
render_content()

Render the stepper with all configuration steps.

Source code in src/cibmangotree/gui/pages/analysis_config_and_run.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def render_content(self) -> None:
    """Render the stepper with all configuration steps."""
    if not self.require_project():
        return

    with self.centered_content(max_width="1200px", justify="start", padding="2rem"):
        with (
            ui.stepper()
            .props("horizontal animated")
            .classes("w-full")
            .on_value_change(self._on_step_change) as stepper
        ):
            self.stepper = stepper

            self._render_analyzer_step()
            self._render_column_mapping_step()
            self._render_params_step()
            self._render_run_step()

analysis_workflow

Modules:

Name Description
analyzer_step
column_mapping_step
params_step
run_step

Classes:

Name Description
AnalyzerSelectionStep

Step 1: Select analyzer from available options.

ColumnMappingStep

Step 2: Map user columns to analyzer input columns.

ParamsConfigStep

Step 3: Configure analyzer parameters.

RunAnalysisStep

Step 4: Execute the analysis with progress tracking.

AnalyzerSelectionStep

Step 1: Select analyzer from available options.

Methods:

Name Description
is_valid

Check if an analyzer is selected.

render

Render the step content.

save_state

Save selection to session. Returns True if successful.

Source code in src/cibmangotree/gui/pages/analysis_workflow/analyzer_step.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class AnalyzerSelectionStep:
    """Step 1: Select analyzer from available options."""

    def __init__(self, session: GuiSession):
        self.session = session
        self.button_group: ToggleButtonGroup | None = None

    @ui.refreshable_method
    def render(self) -> None:
        """Render the step content."""
        analyzers = self.session.app.context.suite.primary_anlyzers

        if not analyzers:
            ui.label("No analyzers available").classes("text-grey")
            return

        analyzer_options = {
            analyzer.name: analyzer.short_description for analyzer in analyzers
        }
        analyzer_long_descriptions = {
            analyzer.name: analyzer.long_description for analyzer in analyzers
        }

        self.button_group = ToggleButtonGroup()

        with ui.column().classes("items-center w-full"):
            with ui.element().classes("w-[64rem] max-w-full"):
                with ui.row().classes("items-center justify-center gap-4 w-full"):
                    for analyzer_name in analyzer_options.keys():
                        self.button_group.add_button(analyzer_name)

                with ui.element().classes("pt-12 flex justify-center w-full"):
                    DEFAULT_TEXT = (
                        "No analyzer selected. Click button above to select it."
                    )

                    with ui.card().classes("w-[40rem] shadow-none"):
                        with ui.scroll_area().classes("max-h-48"):
                            ui.label().bind_text_from(
                                target_object=self.button_group,
                                target_name="selected_text",
                                backward=lambda text: analyzer_long_descriptions.get(
                                    text, DEFAULT_TEXT
                                ),
                            ).classes("text-grey")

    def is_valid(self) -> bool:
        """Check if an analyzer is selected."""
        return (
            self.button_group is not None
            and self.button_group.get_selected_text() is not None
        )

    def save_state(self) -> bool:
        """Save selection to session. Returns True if successful."""
        if not self.button_group:
            return False

        new_selection = self.button_group.get_selected_text()

        if not new_selection:
            return False

        analyzers = self.session.app.context.suite.primary_anlyzers
        selected_analyzer = next(
            (a for a in analyzers if a.name == new_selection), None
        )

        if not selected_analyzer:
            return False

        self.session.selected_analyzer = selected_analyzer
        self.session.selected_analyzer_name = new_selection
        return True
is_valid()

Check if an analyzer is selected.

Source code in src/cibmangotree/gui/pages/analysis_workflow/analyzer_step.py
53
54
55
56
57
58
def is_valid(self) -> bool:
    """Check if an analyzer is selected."""
    return (
        self.button_group is not None
        and self.button_group.get_selected_text() is not None
    )
render()

Render the step content.

Source code in src/cibmangotree/gui/pages/analysis_workflow/analyzer_step.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@ui.refreshable_method
def render(self) -> None:
    """Render the step content."""
    analyzers = self.session.app.context.suite.primary_anlyzers

    if not analyzers:
        ui.label("No analyzers available").classes("text-grey")
        return

    analyzer_options = {
        analyzer.name: analyzer.short_description for analyzer in analyzers
    }
    analyzer_long_descriptions = {
        analyzer.name: analyzer.long_description for analyzer in analyzers
    }

    self.button_group = ToggleButtonGroup()

    with ui.column().classes("items-center w-full"):
        with ui.element().classes("w-[64rem] max-w-full"):
            with ui.row().classes("items-center justify-center gap-4 w-full"):
                for analyzer_name in analyzer_options.keys():
                    self.button_group.add_button(analyzer_name)

            with ui.element().classes("pt-12 flex justify-center w-full"):
                DEFAULT_TEXT = (
                    "No analyzer selected. Click button above to select it."
                )

                with ui.card().classes("w-[40rem] shadow-none"):
                    with ui.scroll_area().classes("max-h-48"):
                        ui.label().bind_text_from(
                            target_object=self.button_group,
                            target_name="selected_text",
                            backward=lambda text: analyzer_long_descriptions.get(
                                text, DEFAULT_TEXT
                            ),
                        ).classes("text-grey")
save_state()

Save selection to session. Returns True if successful.

Source code in src/cibmangotree/gui/pages/analysis_workflow/analyzer_step.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def save_state(self) -> bool:
    """Save selection to session. Returns True if successful."""
    if not self.button_group:
        return False

    new_selection = self.button_group.get_selected_text()

    if not new_selection:
        return False

    analyzers = self.session.app.context.suite.primary_anlyzers
    selected_analyzer = next(
        (a for a in analyzers if a.name == new_selection), None
    )

    if not selected_analyzer:
        return False

    self.session.selected_analyzer = selected_analyzer
    self.session.selected_analyzer_name = new_selection
    return True

ColumnMappingStep

Step 2: Map user columns to analyzer input columns.

Methods:

Name Description
is_valid

Check if all required columns are mapped.

render

Render the step content with column cards and preview.

save_state

Save column mapping to session.

Source code in src/cibmangotree/gui/pages/analysis_workflow/column_mapping_step.py
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
class ColumnMappingStep:
    """Step 2: Map user columns to analyzer input columns."""

    def __init__(self, session: GuiSession):
        self.session = session
        self.column_dropdowns: dict = {}
        self.preview_container = None

    @ui.refreshable_method
    def render(self) -> None:
        """Render the step content with column cards and preview."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project

        if not analyzer:
            ui.label("Please select an analyzer first").classes("text-grey")
            return

        if not project:
            ui.label("No project selected").classes("text-grey")
            return

        input_columns = analyzer.input.columns
        user_columns = project.columns

        draft_column_mapping = column_automap(user_columns, input_columns)

        with (
            ui.column()
            .classes("w-full items-center gap-6")
            .style("max-width: 960px; margin: 0 auto;")
        ):
            ui.label("Map Your Data Columns").classes("text-lg font-bold mb-4")

            with ui.row().classes("flex-wrap gap-6 justify-center w-full"):
                for input_col in input_columns:
                    self._build_column_card(
                        input_col, user_columns, draft_column_mapping
                    )

            self.preview_container = ui.column().classes("w-full")
            self._update_preview()

    def _build_column_card(self, input_col, user_columns, draft_column_mapping) -> None:
        """Build a single column mapping card."""
        with ui.card().classes("w-52 p-4 no-shadow border border-gray-200"):
            with ui.row().classes("items-center gap-1"):
                ui.label(input_col.human_readable_name_or_fallback()).classes(
                    "text-bold"
                )
                if input_col.description:
                    with ui.icon("info").classes("text-grey-6 cursor-pointer"):
                        ui.tooltip(input_col.description)

            compatible_columns = [
                user_col
                for user_col in user_columns
                if get_data_type_compatibility_score(
                    input_col.data_type, user_col.data_type
                )
                is not None
            ]

            dropdown_options = {
                f"{user_col.name}": user_col.name for user_col in compatible_columns
            }

            default_value = None
            if input_col.name in draft_column_mapping:
                mapped_col_name = draft_column_mapping[input_col.name]
                default_value = next(
                    (k for k, v in dropdown_options.items() if v == mapped_col_name),
                    None,
                )

            dropdown = (
                ui.select(
                    options=list(dropdown_options.keys()),
                    value=default_value,
                    on_change=lambda: self._update_preview(),
                )
                .classes("w-full mt-2")
                .props("use-chips")
            )

            self.column_dropdowns[input_col.name] = (dropdown, dropdown_options)

    def _build_preview_df(self) -> pl.DataFrame:
        """Build preview DataFrame with currently mapped columns."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project

        if not analyzer or not project:
            return pl.DataFrame()

        current_mapping = {}
        for input_col_name, (dropdown, options) in self.column_dropdowns.items():
            if dropdown.value:
                current_mapping[input_col_name] = options[dropdown.value]

        tmp_col = list(project.column_dict.values())[0]
        N_PREVIEW_ROWS = min(5, tmp_col.data.len())

        preview_data = {}
        for analyzer_col in analyzer.input.columns:
            col_name = analyzer_col.human_readable_name_or_fallback()
            user_col_name = current_mapping.get(analyzer_col.name)

            if user_col_name and user_col_name in project.column_dict:
                user_col = project.column_dict[user_col_name]
                preview_data[col_name] = user_col.head(
                    N_PREVIEW_ROWS
                ).apply_semantic_transform()
            else:
                preview_data[col_name] = [None] * N_PREVIEW_ROWS

        return pl.DataFrame(preview_data)

    def _update_preview(self) -> None:
        """Rebuild preview when dropdown changes."""
        if self.preview_container is None:
            return

        self.preview_container.clear()
        with self.preview_container:
            preview_df = self._build_preview_df()
            preview_title = (
                "Data Preview (first 5 rows)"
                if len(preview_df) > 5
                else "Data Preview (all rows)"
            )
            ui.label(preview_title).classes("text-sm text-grey-7")

            grid = ui.aggrid.from_polars(
                preview_df,
                theme="quartz",
                auto_size_columns=True,
            ).classes("w-full h-64")
            grid.on(
                "firstDataRendered",
                lambda: grid.run_grid_method("sizeColumnsToFit"),
            )

    def is_valid(self) -> bool:
        """Check if all required columns are mapped."""
        analyzer = self.session.selected_analyzer
        if not analyzer:
            return False

        required_columns = [col.name for col in analyzer.input.columns]
        mapped_columns = [
            col_name
            for col_name, (dropdown, _) in self.column_dropdowns.items()
            if dropdown.value
        ]

        return all(col in mapped_columns for col in required_columns)

    def save_state(self) -> bool:
        """Save column mapping to session."""
        final_mapping = {}
        for input_col_name, (dropdown, options) in self.column_dropdowns.items():
            if dropdown.value:
                final_mapping[input_col_name] = options[dropdown.value]

        self.session.column_mapping = final_mapping
        return True
is_valid()

Check if all required columns are mapped.

Source code in src/cibmangotree/gui/pages/analysis_workflow/column_mapping_step.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def is_valid(self) -> bool:
    """Check if all required columns are mapped."""
    analyzer = self.session.selected_analyzer
    if not analyzer:
        return False

    required_columns = [col.name for col in analyzer.input.columns]
    mapped_columns = [
        col_name
        for col_name, (dropdown, _) in self.column_dropdowns.items()
        if dropdown.value
    ]

    return all(col in mapped_columns for col in required_columns)
render()

Render the step content with column cards and preview.

Source code in src/cibmangotree/gui/pages/analysis_workflow/column_mapping_step.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@ui.refreshable_method
def render(self) -> None:
    """Render the step content with column cards and preview."""
    analyzer = self.session.selected_analyzer
    project = self.session.current_project

    if not analyzer:
        ui.label("Please select an analyzer first").classes("text-grey")
        return

    if not project:
        ui.label("No project selected").classes("text-grey")
        return

    input_columns = analyzer.input.columns
    user_columns = project.columns

    draft_column_mapping = column_automap(user_columns, input_columns)

    with (
        ui.column()
        .classes("w-full items-center gap-6")
        .style("max-width: 960px; margin: 0 auto;")
    ):
        ui.label("Map Your Data Columns").classes("text-lg font-bold mb-4")

        with ui.row().classes("flex-wrap gap-6 justify-center w-full"):
            for input_col in input_columns:
                self._build_column_card(
                    input_col, user_columns, draft_column_mapping
                )

        self.preview_container = ui.column().classes("w-full")
        self._update_preview()
save_state()

Save column mapping to session.

Source code in src/cibmangotree/gui/pages/analysis_workflow/column_mapping_step.py
169
170
171
172
173
174
175
176
177
def save_state(self) -> bool:
    """Save column mapping to session."""
    final_mapping = {}
    for input_col_name, (dropdown, options) in self.column_dropdowns.items():
        if dropdown.value:
            final_mapping[input_col_name] = options[dropdown.value]

    self.session.column_mapping = final_mapping
    return True

ParamsConfigStep

Step 3: Configure analyzer parameters.

Methods:

Name Description
has_params

Check if analyzer has configurable parameters.

is_valid

Always valid - params are optional.

render

Render parameter configuration or 'no params' message.

save_state

Save params to session.

Source code in src/cibmangotree/gui/pages/analysis_workflow/params_step.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
class ParamsConfigStep:
    """Step 3: Configure analyzer parameters."""

    def __init__(self, session: GuiSession):
        self.session = session
        self.params_card: AnalysisParamsCard | None = None
        self._param_values: dict = {}

    @ui.refreshable_method
    def render(self) -> None:
        """Render parameter configuration or 'no params' message."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project
        column_mapping = self.session.column_mapping

        if not analyzer:
            ui.label("Please select an analyzer first").classes("text-grey")
            return

        if not analyzer.params:
            ui.label("This analyzer has no configurable parameters.").classes(
                "text-grey-7"
            )
            self.session.analysis_params = {}
            return

        if not project or not column_mapping:
            ui.label("Please map columns first").classes("text-grey")
            return

        with TemporaryDirectory() as temp_dir:
            default_parameters_context = PrimaryAnalyzerDefaultParametersContext(
                analyzer=analyzer,
                store=self.session.app.context.storage,
                temp_dir=temp_dir,
                input_columns={
                    analyzer_column_name: InputColumnProvider(
                        user_column_name=user_column_name,
                        semantic=project.column_dict[user_column_name].semantic,
                    )
                    for analyzer_column_name, user_column_name in column_mapping.items()
                },
            )

            analyzer_decl = self.session.app.context.suite.get_primary_analyzer(
                analyzer.id
            )

            if not analyzer_decl:
                ui.label(f"Analyzer `{analyzer.id}` not found").classes("text-negative")
                return

            param_values = {
                **{
                    param_spec.id: static_param_default_value
                    for param_spec in analyzer_decl.params
                    if (static_param_default_value := param_spec.default) is not None
                },
                **analyzer_decl.default_params(default_parameters_context),
            }
            param_values = {
                param_id: param_value
                for param_id, param_value in param_values.items()
                if param_value is not None
            }

            self._param_values = param_values

        with (
            ui.column()
            .classes("w-full items-center gap-6")
            .style("max-width: 960px; margin: 0 auto;")
        ):
            ui.label(f"Configure {analyzer.name} Parameters").classes(
                "text-lg font-bold mb-4"
            )

            self.params_card = AnalysisParamsCard(
                params=analyzer.params, default_values=self._param_values
            )

    def is_valid(self) -> bool:
        """Always valid - params are optional."""
        return True

    def save_state(self) -> bool:
        """Save params to session."""
        if self.params_card:
            self.session.analysis_params = self.params_card.get_param_values()
        else:
            self.session.analysis_params = {}
        return True

    def has_params(self) -> bool:
        """Check if analyzer has configurable parameters."""
        analyzer = self.session.selected_analyzer
        return analyzer is not None and len(analyzer.params) > 0
has_params()

Check if analyzer has configurable parameters.

Source code in src/cibmangotree/gui/pages/analysis_workflow/params_step.py
106
107
108
109
def has_params(self) -> bool:
    """Check if analyzer has configurable parameters."""
    analyzer = self.session.selected_analyzer
    return analyzer is not None and len(analyzer.params) > 0
is_valid()

Always valid - params are optional.

Source code in src/cibmangotree/gui/pages/analysis_workflow/params_step.py
94
95
96
def is_valid(self) -> bool:
    """Always valid - params are optional."""
    return True
render()

Render parameter configuration or 'no params' message.

Source code in src/cibmangotree/gui/pages/analysis_workflow/params_step.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
@ui.refreshable_method
def render(self) -> None:
    """Render parameter configuration or 'no params' message."""
    analyzer = self.session.selected_analyzer
    project = self.session.current_project
    column_mapping = self.session.column_mapping

    if not analyzer:
        ui.label("Please select an analyzer first").classes("text-grey")
        return

    if not analyzer.params:
        ui.label("This analyzer has no configurable parameters.").classes(
            "text-grey-7"
        )
        self.session.analysis_params = {}
        return

    if not project or not column_mapping:
        ui.label("Please map columns first").classes("text-grey")
        return

    with TemporaryDirectory() as temp_dir:
        default_parameters_context = PrimaryAnalyzerDefaultParametersContext(
            analyzer=analyzer,
            store=self.session.app.context.storage,
            temp_dir=temp_dir,
            input_columns={
                analyzer_column_name: InputColumnProvider(
                    user_column_name=user_column_name,
                    semantic=project.column_dict[user_column_name].semantic,
                )
                for analyzer_column_name, user_column_name in column_mapping.items()
            },
        )

        analyzer_decl = self.session.app.context.suite.get_primary_analyzer(
            analyzer.id
        )

        if not analyzer_decl:
            ui.label(f"Analyzer `{analyzer.id}` not found").classes("text-negative")
            return

        param_values = {
            **{
                param_spec.id: static_param_default_value
                for param_spec in analyzer_decl.params
                if (static_param_default_value := param_spec.default) is not None
            },
            **analyzer_decl.default_params(default_parameters_context),
        }
        param_values = {
            param_id: param_value
            for param_id, param_value in param_values.items()
            if param_value is not None
        }

        self._param_values = param_values

    with (
        ui.column()
        .classes("w-full items-center gap-6")
        .style("max-width: 960px; margin: 0 auto;")
    ):
        ui.label(f"Configure {analyzer.name} Parameters").classes(
            "text-lg font-bold mb-4"
        )

        self.params_card = AnalysisParamsCard(
            params=analyzer.params, default_values=self._param_values
        )
save_state()

Save params to session.

Source code in src/cibmangotree/gui/pages/analysis_workflow/params_step.py
 98
 99
100
101
102
103
104
def save_state(self) -> bool:
    """Save params to session."""
    if self.params_card:
        self.session.analysis_params = self.params_card.get_param_values()
    else:
        self.session.analysis_params = {}
    return True

RunAnalysisStep

Step 4: Execute the analysis with progress tracking.

Methods:

Name Description
is_valid

Check if all prerequisites are configured.

render

Render the run analysis button and summary.

Source code in src/cibmangotree/gui/pages/analysis_workflow/run_step.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
class RunAnalysisStep:
    """Step 4: Execute the analysis with progress tracking."""

    def __init__(self, session: GuiSession, page: GuiPage):
        self.session = session
        self.page = page

    @ui.refreshable_method
    def render(self) -> None:
        """Render the run analysis button and summary."""
        analyzer = self.session.selected_analyzer
        column_mapping = self.session.column_mapping
        params = self.session.analysis_params

        if not analyzer:
            ui.label("Please select an analyzer first").classes("text-grey")
            return

        if not column_mapping:
            ui.label("Please map columns first").classes("text-grey")
            return

        with (
            ui.column()
            .classes("w-full items-center gap-6")
            .style("max-width: 960px; margin: 0 auto;")
        ):
            ui.label("Configuration Summary").classes("text-lg font-bold mb-4")

            with ui.card().classes("w-full p-4 no-shadow border border-gray-200"):
                with ui.column().classes("gap-2"):
                    ui.label(
                        f"Analyzer: {analyzer.name if analyzer else 'Not selected'}"
                    ).classes("text-sm")
                    ui.label(
                        f"Columns: {len(column_mapping) if column_mapping else 0} mapped"
                    ).classes("text-sm")
                    ui.label(
                        f"Parameters: {len(params) if params else 0} configured"
                    ).classes("text-sm")

            ui.button(
                "Run Analysis",
                icon="play_arrow",
                color="primary",
                on_click=self._start_analysis,
            ).classes("text-base")

    def is_valid(self) -> bool:
        """Check if all prerequisites are configured."""
        return (
            self.session.selected_analyzer is not None
            and self.session.column_mapping is not None
            and self.session.analysis_params is not None
        )

    async def _start_analysis(self) -> None:
        """Opens dialog and runs the analysis in a separate process."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project

        if not analyzer or not project:
            self.page.notify_error("Missing analyzer or project")
            return

        try:
            analysis = project.create_analysis(
                analyzer.id,
                self.session.column_mapping,
                self.session.analysis_params,
            )
        except Exception as e:
            self.page.notify_error(f"Failed to create analysis: {str(e)}")
            print(f"Analysis creation error:\n{format_exc()}")
            return

        secondary_analyzers = (
            self.session.app.context.suite.find_toposorted_secondary_analyzers(analyzer)
        )
        secondary_analyzer_ids = [sec.id for sec in secondary_analyzers]

        manager = Manager()
        queue = manager.Queue()
        cancel_event = manager.Event()

        input_columns_data = {
            analyzer_col_name: (
                user_col_name,
                project.column_dict[user_col_name].semantic.semantic_name,
            )
            for analyzer_col_name, user_col_name in analysis.column_mapping.items()
        }

        with (
            ui.dialog().props("persistent") as dialog,
            ui.card()
            .classes("items-center justify-center gap-6")
            .style("width: 600px; max-width: 90vw; padding: 2rem;"),
        ):
            analyzer_header = ui.label(analyzer.name).classes("text-xl font-semibold")
            status_label = (
                ui.label("Initializing...")
                .classes("text-base text-medium")
                .style(f"color: {MANGO_ORANGE}")
            )

            step_list_container = ui.column().classes("w-full gap-1 mt-4")

            log_container = ui.column().classes("w-full gap-1 mt-2")

            with ui.row().classes("gap-4 mt-4"):
                cancel_btn = ui.button(
                    "Cancel Analysis",
                    icon="stop",
                    color="secondary",
                    on_click=lambda: cancel_event.set(),
                ).props("outline")

                success_btn = ui.button(
                    "Continue",
                    icon="arrow_forward",
                    color="primary",
                    on_click=lambda: (
                        dialog.close(),
                        self.page.navigate_to(gui_routes.post_analysis),
                    ),
                )
                success_btn.set_visibility(False)

        analysis_complete = False
        step_rows: dict[str, tuple[ui.spinner, ui.icon, ui.label]] = {}
        current_step_name: str = None

        def _poll_queue():
            nonlocal analysis_complete, current_step_name

            try:
                msg_dict = queue.get_nowait()
            except Empty:
                return

            msg = AnalysisQueueMessage(**msg_dict)

            if msg.type == "analyzer_start":
                analyzer_header.text = msg.analyzer_name or "Analyzer"
                status_label.text = "Analysis starting..."
                step_rows.clear()
                current_step_name = None

            elif msg.type == "analyzer_finish":
                pass

            elif msg.type == "step_start":
                step_name = msg.step_name or "Processing..."

                if current_step_name and current_step_name in step_rows:
                    _, _, prev_label = step_rows[current_step_name]
                    prev_label.classes(add="text-gray-600", remove="text-medium")
                    prev_label.style("")

                current_step_name = step_name
                status_label.text = "Running analysis..."

                with step_list_container:
                    with ui.row().classes("items-center gap-2"):
                        spinner = ui.spinner("gears", size="sm")
                        checkmark = ui.icon(
                            "check_circle", color=MANGO_DARK_GREEN, size="sm"
                        )
                        checkmark.set_visibility(False)
                        label = ui.label(step_name).classes("text-medium")
                step_rows[step_name] = (spinner, checkmark, label)

            elif msg.type == "step_finish":
                if current_step_name and current_step_name in step_rows:
                    spinner, checkmark, label = step_rows[current_step_name]
                    spinner.set_visibility(False)
                    checkmark.set_visibility(True)
                    label.classes(add="text-gray-600", remove="text-medium")
                    label.style("")
                    label.text = current_step_name
                current_step_name = None

            elif msg.type == "step_progress":
                if current_step_name and current_step_name in step_rows:
                    _, _, label = step_rows[current_step_name]
                    progress_pct = (msg.step_progress or 0) * 100
                    label.text = f"{current_step_name} ({progress_pct:.0f}%)"

            elif msg.type == "log":
                with log_container:
                    label = ui.label(msg.message).classes("text-sm")
                    if msg.progress is not None:
                        label.text = f"{msg.message} ({msg.progress * 100:.0f}%)"

            elif msg.type == "error":
                with log_container:
                    ui.label(f"Error: {msg.message}").classes(
                        "text-negative font-bold text-sm"
                    )
                cancel_btn.disable()
                analysis_complete = True

            elif msg.type in ("complete", "cancelled"):
                analysis_complete = True
                status_label.set_visibility(False)
                if msg.type == "complete":
                    self.session.current_analysis = analysis.model
                    success_btn.set_visibility(True)
                    self.page.notify_success("Analysis completed!")
                else:
                    self.page.notify_warning("Analysis was canceled")
                cancel_btn.disable()

        async def run_analysis_task():
            try:
                result = await run.cpu_bound(
                    AnalysisContext.run_worker,
                    analysis.model,
                    analyzer.id,
                    analysis.column_mapping,
                    input_columns_data,
                    secondary_analyzer_ids,
                    analysis.app_context.storage,
                    queue,
                    cancel_event,
                )
                analysis.model.is_draft = result.is_draft
            except Exception as e:
                self.page.notify_error(f"Analysis error: {str(e)}")
                print(f"Analysis error:\n{format_exc()}")
                with log_container:
                    ui.label(f"Error: {str(e)}").classes(
                        "text-negative font-bold text-sm"
                    )
                cancel_btn.disable()
            finally:
                if analysis.is_draft:
                    analysis.delete()

        dialog.open()
        timer = ui.timer(QUEUE_POLL_INTERVAL, _poll_queue)

        await run_analysis_task()

        while not analysis_complete:
            await sleep(QUEUE_POLL_INTERVAL)

        timer.cancel()
is_valid()

Check if all prerequisites are configured.

Source code in src/cibmangotree/gui/pages/analysis_workflow/run_step.py
65
66
67
68
69
70
71
def is_valid(self) -> bool:
    """Check if all prerequisites are configured."""
    return (
        self.session.selected_analyzer is not None
        and self.session.column_mapping is not None
        and self.session.analysis_params is not None
    )
render()

Render the run analysis button and summary.

Source code in src/cibmangotree/gui/pages/analysis_workflow/run_step.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@ui.refreshable_method
def render(self) -> None:
    """Render the run analysis button and summary."""
    analyzer = self.session.selected_analyzer
    column_mapping = self.session.column_mapping
    params = self.session.analysis_params

    if not analyzer:
        ui.label("Please select an analyzer first").classes("text-grey")
        return

    if not column_mapping:
        ui.label("Please map columns first").classes("text-grey")
        return

    with (
        ui.column()
        .classes("w-full items-center gap-6")
        .style("max-width: 960px; margin: 0 auto;")
    ):
        ui.label("Configuration Summary").classes("text-lg font-bold mb-4")

        with ui.card().classes("w-full p-4 no-shadow border border-gray-200"):
            with ui.column().classes("gap-2"):
                ui.label(
                    f"Analyzer: {analyzer.name if analyzer else 'Not selected'}"
                ).classes("text-sm")
                ui.label(
                    f"Columns: {len(column_mapping) if column_mapping else 0} mapped"
                ).classes("text-sm")
                ui.label(
                    f"Parameters: {len(params) if params else 0} configured"
                ).classes("text-sm")

        ui.button(
            "Run Analysis",
            icon="play_arrow",
            color="primary",
            on_click=self._start_analysis,
        ).classes("text-base")

analyzer_step

Classes:

Name Description
AnalyzerSelectionStep

Step 1: Select analyzer from available options.

AnalyzerSelectionStep

Step 1: Select analyzer from available options.

Methods:

Name Description
is_valid

Check if an analyzer is selected.

render

Render the step content.

save_state

Save selection to session. Returns True if successful.

Source code in src/cibmangotree/gui/pages/analysis_workflow/analyzer_step.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class AnalyzerSelectionStep:
    """Step 1: Select analyzer from available options."""

    def __init__(self, session: GuiSession):
        self.session = session
        self.button_group: ToggleButtonGroup | None = None

    @ui.refreshable_method
    def render(self) -> None:
        """Render the step content."""
        analyzers = self.session.app.context.suite.primary_anlyzers

        if not analyzers:
            ui.label("No analyzers available").classes("text-grey")
            return

        analyzer_options = {
            analyzer.name: analyzer.short_description for analyzer in analyzers
        }
        analyzer_long_descriptions = {
            analyzer.name: analyzer.long_description for analyzer in analyzers
        }

        self.button_group = ToggleButtonGroup()

        with ui.column().classes("items-center w-full"):
            with ui.element().classes("w-[64rem] max-w-full"):
                with ui.row().classes("items-center justify-center gap-4 w-full"):
                    for analyzer_name in analyzer_options.keys():
                        self.button_group.add_button(analyzer_name)

                with ui.element().classes("pt-12 flex justify-center w-full"):
                    DEFAULT_TEXT = (
                        "No analyzer selected. Click button above to select it."
                    )

                    with ui.card().classes("w-[40rem] shadow-none"):
                        with ui.scroll_area().classes("max-h-48"):
                            ui.label().bind_text_from(
                                target_object=self.button_group,
                                target_name="selected_text",
                                backward=lambda text: analyzer_long_descriptions.get(
                                    text, DEFAULT_TEXT
                                ),
                            ).classes("text-grey")

    def is_valid(self) -> bool:
        """Check if an analyzer is selected."""
        return (
            self.button_group is not None
            and self.button_group.get_selected_text() is not None
        )

    def save_state(self) -> bool:
        """Save selection to session. Returns True if successful."""
        if not self.button_group:
            return False

        new_selection = self.button_group.get_selected_text()

        if not new_selection:
            return False

        analyzers = self.session.app.context.suite.primary_anlyzers
        selected_analyzer = next(
            (a for a in analyzers if a.name == new_selection), None
        )

        if not selected_analyzer:
            return False

        self.session.selected_analyzer = selected_analyzer
        self.session.selected_analyzer_name = new_selection
        return True
is_valid()

Check if an analyzer is selected.

Source code in src/cibmangotree/gui/pages/analysis_workflow/analyzer_step.py
53
54
55
56
57
58
def is_valid(self) -> bool:
    """Check if an analyzer is selected."""
    return (
        self.button_group is not None
        and self.button_group.get_selected_text() is not None
    )
render()

Render the step content.

Source code in src/cibmangotree/gui/pages/analysis_workflow/analyzer_step.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@ui.refreshable_method
def render(self) -> None:
    """Render the step content."""
    analyzers = self.session.app.context.suite.primary_anlyzers

    if not analyzers:
        ui.label("No analyzers available").classes("text-grey")
        return

    analyzer_options = {
        analyzer.name: analyzer.short_description for analyzer in analyzers
    }
    analyzer_long_descriptions = {
        analyzer.name: analyzer.long_description for analyzer in analyzers
    }

    self.button_group = ToggleButtonGroup()

    with ui.column().classes("items-center w-full"):
        with ui.element().classes("w-[64rem] max-w-full"):
            with ui.row().classes("items-center justify-center gap-4 w-full"):
                for analyzer_name in analyzer_options.keys():
                    self.button_group.add_button(analyzer_name)

            with ui.element().classes("pt-12 flex justify-center w-full"):
                DEFAULT_TEXT = (
                    "No analyzer selected. Click button above to select it."
                )

                with ui.card().classes("w-[40rem] shadow-none"):
                    with ui.scroll_area().classes("max-h-48"):
                        ui.label().bind_text_from(
                            target_object=self.button_group,
                            target_name="selected_text",
                            backward=lambda text: analyzer_long_descriptions.get(
                                text, DEFAULT_TEXT
                            ),
                        ).classes("text-grey")
save_state()

Save selection to session. Returns True if successful.

Source code in src/cibmangotree/gui/pages/analysis_workflow/analyzer_step.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def save_state(self) -> bool:
    """Save selection to session. Returns True if successful."""
    if not self.button_group:
        return False

    new_selection = self.button_group.get_selected_text()

    if not new_selection:
        return False

    analyzers = self.session.app.context.suite.primary_anlyzers
    selected_analyzer = next(
        (a for a in analyzers if a.name == new_selection), None
    )

    if not selected_analyzer:
        return False

    self.session.selected_analyzer = selected_analyzer
    self.session.selected_analyzer_name = new_selection
    return True

column_mapping_step

Classes:

Name Description
ColumnMappingStep

Step 2: Map user columns to analyzer input columns.

ColumnMappingStep

Step 2: Map user columns to analyzer input columns.

Methods:

Name Description
is_valid

Check if all required columns are mapped.

render

Render the step content with column cards and preview.

save_state

Save column mapping to session.

Source code in src/cibmangotree/gui/pages/analysis_workflow/column_mapping_step.py
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
class ColumnMappingStep:
    """Step 2: Map user columns to analyzer input columns."""

    def __init__(self, session: GuiSession):
        self.session = session
        self.column_dropdowns: dict = {}
        self.preview_container = None

    @ui.refreshable_method
    def render(self) -> None:
        """Render the step content with column cards and preview."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project

        if not analyzer:
            ui.label("Please select an analyzer first").classes("text-grey")
            return

        if not project:
            ui.label("No project selected").classes("text-grey")
            return

        input_columns = analyzer.input.columns
        user_columns = project.columns

        draft_column_mapping = column_automap(user_columns, input_columns)

        with (
            ui.column()
            .classes("w-full items-center gap-6")
            .style("max-width: 960px; margin: 0 auto;")
        ):
            ui.label("Map Your Data Columns").classes("text-lg font-bold mb-4")

            with ui.row().classes("flex-wrap gap-6 justify-center w-full"):
                for input_col in input_columns:
                    self._build_column_card(
                        input_col, user_columns, draft_column_mapping
                    )

            self.preview_container = ui.column().classes("w-full")
            self._update_preview()

    def _build_column_card(self, input_col, user_columns, draft_column_mapping) -> None:
        """Build a single column mapping card."""
        with ui.card().classes("w-52 p-4 no-shadow border border-gray-200"):
            with ui.row().classes("items-center gap-1"):
                ui.label(input_col.human_readable_name_or_fallback()).classes(
                    "text-bold"
                )
                if input_col.description:
                    with ui.icon("info").classes("text-grey-6 cursor-pointer"):
                        ui.tooltip(input_col.description)

            compatible_columns = [
                user_col
                for user_col in user_columns
                if get_data_type_compatibility_score(
                    input_col.data_type, user_col.data_type
                )
                is not None
            ]

            dropdown_options = {
                f"{user_col.name}": user_col.name for user_col in compatible_columns
            }

            default_value = None
            if input_col.name in draft_column_mapping:
                mapped_col_name = draft_column_mapping[input_col.name]
                default_value = next(
                    (k for k, v in dropdown_options.items() if v == mapped_col_name),
                    None,
                )

            dropdown = (
                ui.select(
                    options=list(dropdown_options.keys()),
                    value=default_value,
                    on_change=lambda: self._update_preview(),
                )
                .classes("w-full mt-2")
                .props("use-chips")
            )

            self.column_dropdowns[input_col.name] = (dropdown, dropdown_options)

    def _build_preview_df(self) -> pl.DataFrame:
        """Build preview DataFrame with currently mapped columns."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project

        if not analyzer or not project:
            return pl.DataFrame()

        current_mapping = {}
        for input_col_name, (dropdown, options) in self.column_dropdowns.items():
            if dropdown.value:
                current_mapping[input_col_name] = options[dropdown.value]

        tmp_col = list(project.column_dict.values())[0]
        N_PREVIEW_ROWS = min(5, tmp_col.data.len())

        preview_data = {}
        for analyzer_col in analyzer.input.columns:
            col_name = analyzer_col.human_readable_name_or_fallback()
            user_col_name = current_mapping.get(analyzer_col.name)

            if user_col_name and user_col_name in project.column_dict:
                user_col = project.column_dict[user_col_name]
                preview_data[col_name] = user_col.head(
                    N_PREVIEW_ROWS
                ).apply_semantic_transform()
            else:
                preview_data[col_name] = [None] * N_PREVIEW_ROWS

        return pl.DataFrame(preview_data)

    def _update_preview(self) -> None:
        """Rebuild preview when dropdown changes."""
        if self.preview_container is None:
            return

        self.preview_container.clear()
        with self.preview_container:
            preview_df = self._build_preview_df()
            preview_title = (
                "Data Preview (first 5 rows)"
                if len(preview_df) > 5
                else "Data Preview (all rows)"
            )
            ui.label(preview_title).classes("text-sm text-grey-7")

            grid = ui.aggrid.from_polars(
                preview_df,
                theme="quartz",
                auto_size_columns=True,
            ).classes("w-full h-64")
            grid.on(
                "firstDataRendered",
                lambda: grid.run_grid_method("sizeColumnsToFit"),
            )

    def is_valid(self) -> bool:
        """Check if all required columns are mapped."""
        analyzer = self.session.selected_analyzer
        if not analyzer:
            return False

        required_columns = [col.name for col in analyzer.input.columns]
        mapped_columns = [
            col_name
            for col_name, (dropdown, _) in self.column_dropdowns.items()
            if dropdown.value
        ]

        return all(col in mapped_columns for col in required_columns)

    def save_state(self) -> bool:
        """Save column mapping to session."""
        final_mapping = {}
        for input_col_name, (dropdown, options) in self.column_dropdowns.items():
            if dropdown.value:
                final_mapping[input_col_name] = options[dropdown.value]

        self.session.column_mapping = final_mapping
        return True
is_valid()

Check if all required columns are mapped.

Source code in src/cibmangotree/gui/pages/analysis_workflow/column_mapping_step.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def is_valid(self) -> bool:
    """Check if all required columns are mapped."""
    analyzer = self.session.selected_analyzer
    if not analyzer:
        return False

    required_columns = [col.name for col in analyzer.input.columns]
    mapped_columns = [
        col_name
        for col_name, (dropdown, _) in self.column_dropdowns.items()
        if dropdown.value
    ]

    return all(col in mapped_columns for col in required_columns)
render()

Render the step content with column cards and preview.

Source code in src/cibmangotree/gui/pages/analysis_workflow/column_mapping_step.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@ui.refreshable_method
def render(self) -> None:
    """Render the step content with column cards and preview."""
    analyzer = self.session.selected_analyzer
    project = self.session.current_project

    if not analyzer:
        ui.label("Please select an analyzer first").classes("text-grey")
        return

    if not project:
        ui.label("No project selected").classes("text-grey")
        return

    input_columns = analyzer.input.columns
    user_columns = project.columns

    draft_column_mapping = column_automap(user_columns, input_columns)

    with (
        ui.column()
        .classes("w-full items-center gap-6")
        .style("max-width: 960px; margin: 0 auto;")
    ):
        ui.label("Map Your Data Columns").classes("text-lg font-bold mb-4")

        with ui.row().classes("flex-wrap gap-6 justify-center w-full"):
            for input_col in input_columns:
                self._build_column_card(
                    input_col, user_columns, draft_column_mapping
                )

        self.preview_container = ui.column().classes("w-full")
        self._update_preview()
save_state()

Save column mapping to session.

Source code in src/cibmangotree/gui/pages/analysis_workflow/column_mapping_step.py
169
170
171
172
173
174
175
176
177
def save_state(self) -> bool:
    """Save column mapping to session."""
    final_mapping = {}
    for input_col_name, (dropdown, options) in self.column_dropdowns.items():
        if dropdown.value:
            final_mapping[input_col_name] = options[dropdown.value]

    self.session.column_mapping = final_mapping
    return True

params_step

Classes:

Name Description
ParamsConfigStep

Step 3: Configure analyzer parameters.

ParamsConfigStep

Step 3: Configure analyzer parameters.

Methods:

Name Description
has_params

Check if analyzer has configurable parameters.

is_valid

Always valid - params are optional.

render

Render parameter configuration or 'no params' message.

save_state

Save params to session.

Source code in src/cibmangotree/gui/pages/analysis_workflow/params_step.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
class ParamsConfigStep:
    """Step 3: Configure analyzer parameters."""

    def __init__(self, session: GuiSession):
        self.session = session
        self.params_card: AnalysisParamsCard | None = None
        self._param_values: dict = {}

    @ui.refreshable_method
    def render(self) -> None:
        """Render parameter configuration or 'no params' message."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project
        column_mapping = self.session.column_mapping

        if not analyzer:
            ui.label("Please select an analyzer first").classes("text-grey")
            return

        if not analyzer.params:
            ui.label("This analyzer has no configurable parameters.").classes(
                "text-grey-7"
            )
            self.session.analysis_params = {}
            return

        if not project or not column_mapping:
            ui.label("Please map columns first").classes("text-grey")
            return

        with TemporaryDirectory() as temp_dir:
            default_parameters_context = PrimaryAnalyzerDefaultParametersContext(
                analyzer=analyzer,
                store=self.session.app.context.storage,
                temp_dir=temp_dir,
                input_columns={
                    analyzer_column_name: InputColumnProvider(
                        user_column_name=user_column_name,
                        semantic=project.column_dict[user_column_name].semantic,
                    )
                    for analyzer_column_name, user_column_name in column_mapping.items()
                },
            )

            analyzer_decl = self.session.app.context.suite.get_primary_analyzer(
                analyzer.id
            )

            if not analyzer_decl:
                ui.label(f"Analyzer `{analyzer.id}` not found").classes("text-negative")
                return

            param_values = {
                **{
                    param_spec.id: static_param_default_value
                    for param_spec in analyzer_decl.params
                    if (static_param_default_value := param_spec.default) is not None
                },
                **analyzer_decl.default_params(default_parameters_context),
            }
            param_values = {
                param_id: param_value
                for param_id, param_value in param_values.items()
                if param_value is not None
            }

            self._param_values = param_values

        with (
            ui.column()
            .classes("w-full items-center gap-6")
            .style("max-width: 960px; margin: 0 auto;")
        ):
            ui.label(f"Configure {analyzer.name} Parameters").classes(
                "text-lg font-bold mb-4"
            )

            self.params_card = AnalysisParamsCard(
                params=analyzer.params, default_values=self._param_values
            )

    def is_valid(self) -> bool:
        """Always valid - params are optional."""
        return True

    def save_state(self) -> bool:
        """Save params to session."""
        if self.params_card:
            self.session.analysis_params = self.params_card.get_param_values()
        else:
            self.session.analysis_params = {}
        return True

    def has_params(self) -> bool:
        """Check if analyzer has configurable parameters."""
        analyzer = self.session.selected_analyzer
        return analyzer is not None and len(analyzer.params) > 0
has_params()

Check if analyzer has configurable parameters.

Source code in src/cibmangotree/gui/pages/analysis_workflow/params_step.py
106
107
108
109
def has_params(self) -> bool:
    """Check if analyzer has configurable parameters."""
    analyzer = self.session.selected_analyzer
    return analyzer is not None and len(analyzer.params) > 0
is_valid()

Always valid - params are optional.

Source code in src/cibmangotree/gui/pages/analysis_workflow/params_step.py
94
95
96
def is_valid(self) -> bool:
    """Always valid - params are optional."""
    return True
render()

Render parameter configuration or 'no params' message.

Source code in src/cibmangotree/gui/pages/analysis_workflow/params_step.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
@ui.refreshable_method
def render(self) -> None:
    """Render parameter configuration or 'no params' message."""
    analyzer = self.session.selected_analyzer
    project = self.session.current_project
    column_mapping = self.session.column_mapping

    if not analyzer:
        ui.label("Please select an analyzer first").classes("text-grey")
        return

    if not analyzer.params:
        ui.label("This analyzer has no configurable parameters.").classes(
            "text-grey-7"
        )
        self.session.analysis_params = {}
        return

    if not project or not column_mapping:
        ui.label("Please map columns first").classes("text-grey")
        return

    with TemporaryDirectory() as temp_dir:
        default_parameters_context = PrimaryAnalyzerDefaultParametersContext(
            analyzer=analyzer,
            store=self.session.app.context.storage,
            temp_dir=temp_dir,
            input_columns={
                analyzer_column_name: InputColumnProvider(
                    user_column_name=user_column_name,
                    semantic=project.column_dict[user_column_name].semantic,
                )
                for analyzer_column_name, user_column_name in column_mapping.items()
            },
        )

        analyzer_decl = self.session.app.context.suite.get_primary_analyzer(
            analyzer.id
        )

        if not analyzer_decl:
            ui.label(f"Analyzer `{analyzer.id}` not found").classes("text-negative")
            return

        param_values = {
            **{
                param_spec.id: static_param_default_value
                for param_spec in analyzer_decl.params
                if (static_param_default_value := param_spec.default) is not None
            },
            **analyzer_decl.default_params(default_parameters_context),
        }
        param_values = {
            param_id: param_value
            for param_id, param_value in param_values.items()
            if param_value is not None
        }

        self._param_values = param_values

    with (
        ui.column()
        .classes("w-full items-center gap-6")
        .style("max-width: 960px; margin: 0 auto;")
    ):
        ui.label(f"Configure {analyzer.name} Parameters").classes(
            "text-lg font-bold mb-4"
        )

        self.params_card = AnalysisParamsCard(
            params=analyzer.params, default_values=self._param_values
        )
save_state()

Save params to session.

Source code in src/cibmangotree/gui/pages/analysis_workflow/params_step.py
 98
 99
100
101
102
103
104
def save_state(self) -> bool:
    """Save params to session."""
    if self.params_card:
        self.session.analysis_params = self.params_card.get_param_values()
    else:
        self.session.analysis_params = {}
    return True

run_step

Classes:

Name Description
RunAnalysisStep

Step 4: Execute the analysis with progress tracking.

RunAnalysisStep

Step 4: Execute the analysis with progress tracking.

Methods:

Name Description
is_valid

Check if all prerequisites are configured.

render

Render the run analysis button and summary.

Source code in src/cibmangotree/gui/pages/analysis_workflow/run_step.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
class RunAnalysisStep:
    """Step 4: Execute the analysis with progress tracking."""

    def __init__(self, session: GuiSession, page: GuiPage):
        self.session = session
        self.page = page

    @ui.refreshable_method
    def render(self) -> None:
        """Render the run analysis button and summary."""
        analyzer = self.session.selected_analyzer
        column_mapping = self.session.column_mapping
        params = self.session.analysis_params

        if not analyzer:
            ui.label("Please select an analyzer first").classes("text-grey")
            return

        if not column_mapping:
            ui.label("Please map columns first").classes("text-grey")
            return

        with (
            ui.column()
            .classes("w-full items-center gap-6")
            .style("max-width: 960px; margin: 0 auto;")
        ):
            ui.label("Configuration Summary").classes("text-lg font-bold mb-4")

            with ui.card().classes("w-full p-4 no-shadow border border-gray-200"):
                with ui.column().classes("gap-2"):
                    ui.label(
                        f"Analyzer: {analyzer.name if analyzer else 'Not selected'}"
                    ).classes("text-sm")
                    ui.label(
                        f"Columns: {len(column_mapping) if column_mapping else 0} mapped"
                    ).classes("text-sm")
                    ui.label(
                        f"Parameters: {len(params) if params else 0} configured"
                    ).classes("text-sm")

            ui.button(
                "Run Analysis",
                icon="play_arrow",
                color="primary",
                on_click=self._start_analysis,
            ).classes("text-base")

    def is_valid(self) -> bool:
        """Check if all prerequisites are configured."""
        return (
            self.session.selected_analyzer is not None
            and self.session.column_mapping is not None
            and self.session.analysis_params is not None
        )

    async def _start_analysis(self) -> None:
        """Opens dialog and runs the analysis in a separate process."""
        analyzer = self.session.selected_analyzer
        project = self.session.current_project

        if not analyzer or not project:
            self.page.notify_error("Missing analyzer or project")
            return

        try:
            analysis = project.create_analysis(
                analyzer.id,
                self.session.column_mapping,
                self.session.analysis_params,
            )
        except Exception as e:
            self.page.notify_error(f"Failed to create analysis: {str(e)}")
            print(f"Analysis creation error:\n{format_exc()}")
            return

        secondary_analyzers = (
            self.session.app.context.suite.find_toposorted_secondary_analyzers(analyzer)
        )
        secondary_analyzer_ids = [sec.id for sec in secondary_analyzers]

        manager = Manager()
        queue = manager.Queue()
        cancel_event = manager.Event()

        input_columns_data = {
            analyzer_col_name: (
                user_col_name,
                project.column_dict[user_col_name].semantic.semantic_name,
            )
            for analyzer_col_name, user_col_name in analysis.column_mapping.items()
        }

        with (
            ui.dialog().props("persistent") as dialog,
            ui.card()
            .classes("items-center justify-center gap-6")
            .style("width: 600px; max-width: 90vw; padding: 2rem;"),
        ):
            analyzer_header = ui.label(analyzer.name).classes("text-xl font-semibold")
            status_label = (
                ui.label("Initializing...")
                .classes("text-base text-medium")
                .style(f"color: {MANGO_ORANGE}")
            )

            step_list_container = ui.column().classes("w-full gap-1 mt-4")

            log_container = ui.column().classes("w-full gap-1 mt-2")

            with ui.row().classes("gap-4 mt-4"):
                cancel_btn = ui.button(
                    "Cancel Analysis",
                    icon="stop",
                    color="secondary",
                    on_click=lambda: cancel_event.set(),
                ).props("outline")

                success_btn = ui.button(
                    "Continue",
                    icon="arrow_forward",
                    color="primary",
                    on_click=lambda: (
                        dialog.close(),
                        self.page.navigate_to(gui_routes.post_analysis),
                    ),
                )
                success_btn.set_visibility(False)

        analysis_complete = False
        step_rows: dict[str, tuple[ui.spinner, ui.icon, ui.label]] = {}
        current_step_name: str = None

        def _poll_queue():
            nonlocal analysis_complete, current_step_name

            try:
                msg_dict = queue.get_nowait()
            except Empty:
                return

            msg = AnalysisQueueMessage(**msg_dict)

            if msg.type == "analyzer_start":
                analyzer_header.text = msg.analyzer_name or "Analyzer"
                status_label.text = "Analysis starting..."
                step_rows.clear()
                current_step_name = None

            elif msg.type == "analyzer_finish":
                pass

            elif msg.type == "step_start":
                step_name = msg.step_name or "Processing..."

                if current_step_name and current_step_name in step_rows:
                    _, _, prev_label = step_rows[current_step_name]
                    prev_label.classes(add="text-gray-600", remove="text-medium")
                    prev_label.style("")

                current_step_name = step_name
                status_label.text = "Running analysis..."

                with step_list_container:
                    with ui.row().classes("items-center gap-2"):
                        spinner = ui.spinner("gears", size="sm")
                        checkmark = ui.icon(
                            "check_circle", color=MANGO_DARK_GREEN, size="sm"
                        )
                        checkmark.set_visibility(False)
                        label = ui.label(step_name).classes("text-medium")
                step_rows[step_name] = (spinner, checkmark, label)

            elif msg.type == "step_finish":
                if current_step_name and current_step_name in step_rows:
                    spinner, checkmark, label = step_rows[current_step_name]
                    spinner.set_visibility(False)
                    checkmark.set_visibility(True)
                    label.classes(add="text-gray-600", remove="text-medium")
                    label.style("")
                    label.text = current_step_name
                current_step_name = None

            elif msg.type == "step_progress":
                if current_step_name and current_step_name in step_rows:
                    _, _, label = step_rows[current_step_name]
                    progress_pct = (msg.step_progress or 0) * 100
                    label.text = f"{current_step_name} ({progress_pct:.0f}%)"

            elif msg.type == "log":
                with log_container:
                    label = ui.label(msg.message).classes("text-sm")
                    if msg.progress is not None:
                        label.text = f"{msg.message} ({msg.progress * 100:.0f}%)"

            elif msg.type == "error":
                with log_container:
                    ui.label(f"Error: {msg.message}").classes(
                        "text-negative font-bold text-sm"
                    )
                cancel_btn.disable()
                analysis_complete = True

            elif msg.type in ("complete", "cancelled"):
                analysis_complete = True
                status_label.set_visibility(False)
                if msg.type == "complete":
                    self.session.current_analysis = analysis.model
                    success_btn.set_visibility(True)
                    self.page.notify_success("Analysis completed!")
                else:
                    self.page.notify_warning("Analysis was canceled")
                cancel_btn.disable()

        async def run_analysis_task():
            try:
                result = await run.cpu_bound(
                    AnalysisContext.run_worker,
                    analysis.model,
                    analyzer.id,
                    analysis.column_mapping,
                    input_columns_data,
                    secondary_analyzer_ids,
                    analysis.app_context.storage,
                    queue,
                    cancel_event,
                )
                analysis.model.is_draft = result.is_draft
            except Exception as e:
                self.page.notify_error(f"Analysis error: {str(e)}")
                print(f"Analysis error:\n{format_exc()}")
                with log_container:
                    ui.label(f"Error: {str(e)}").classes(
                        "text-negative font-bold text-sm"
                    )
                cancel_btn.disable()
            finally:
                if analysis.is_draft:
                    analysis.delete()

        dialog.open()
        timer = ui.timer(QUEUE_POLL_INTERVAL, _poll_queue)

        await run_analysis_task()

        while not analysis_complete:
            await sleep(QUEUE_POLL_INTERVAL)

        timer.cancel()
is_valid()

Check if all prerequisites are configured.

Source code in src/cibmangotree/gui/pages/analysis_workflow/run_step.py
65
66
67
68
69
70
71
def is_valid(self) -> bool:
    """Check if all prerequisites are configured."""
    return (
        self.session.selected_analyzer is not None
        and self.session.column_mapping is not None
        and self.session.analysis_params is not None
    )
render()

Render the run analysis button and summary.

Source code in src/cibmangotree/gui/pages/analysis_workflow/run_step.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@ui.refreshable_method
def render(self) -> None:
    """Render the run analysis button and summary."""
    analyzer = self.session.selected_analyzer
    column_mapping = self.session.column_mapping
    params = self.session.analysis_params

    if not analyzer:
        ui.label("Please select an analyzer first").classes("text-grey")
        return

    if not column_mapping:
        ui.label("Please map columns first").classes("text-grey")
        return

    with (
        ui.column()
        .classes("w-full items-center gap-6")
        .style("max-width: 960px; margin: 0 auto;")
    ):
        ui.label("Configuration Summary").classes("text-lg font-bold mb-4")

        with ui.card().classes("w-full p-4 no-shadow border border-gray-200"):
            with ui.column().classes("gap-2"):
                ui.label(
                    f"Analyzer: {analyzer.name if analyzer else 'Not selected'}"
                ).classes("text-sm")
                ui.label(
                    f"Columns: {len(column_mapping) if column_mapping else 0} mapped"
                ).classes("text-sm")
                ui.label(
                    f"Parameters: {len(params) if params else 0} configured"
                ).classes("text-sm")

        ui.button(
            "Run Analysis",
            icon="play_arrow",
            color="primary",
            on_click=self._start_analysis,
        ).classes("text-base")

analyzer_previous

Classes:

Name Description
SelectPreviousAnalyzerPage

Page for selecting a previous analysis to review.

SelectPreviousAnalyzerPage

Bases: GuiPage

Page for selecting a previous analysis to review.

Methods:

Name Description
render_content

Render previous analysis selection interface.

Source code in src/cibmangotree/gui/pages/analyzer_previous.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
class SelectPreviousAnalyzerPage(GuiPage):
    """
    Page for selecting a previous analysis to review.
    """

    grid: ui.aggrid | None = None
    analysis_contexts: list[AnalysisContext] = []

    def __init__(self, session: GuiSession):
        select_previous_title: str = "Select Previous Analysis"
        super().__init__(
            session=session,
            route=gui_routes.select_previous_analyzer,
            title=(
                f"{session.current_project.display_name}: {select_previous_title}"
                if session.current_project is not None
                else select_previous_title
            ),
            show_back_button=True,
            back_route=gui_routes.select_analyzer_fork,
            show_footer=True,
        )

    def requires_exit_confirmation(self) -> bool:
        return self.session.current_analysis is not None

    def get_exit_confirmation_message(self) -> str:
        return "Your analysis selection will be lost. Leave anyway?"

    def on_exit(self) -> None:
        self.session.reset_analysis_workflow()

    def render_content(self) -> None:
        """Render previous analysis selection interface."""
        # Ensure a project is selected
        if not self.require_project():
            return

        # Store analyses as instance state so the grid can be updated in place
        self.analysis_contexts = self.session.current_project.list_analyses()

        # Main content - centered
        with self.centered_content(max_width="800px"):
            ui.label("Review a Previous Analysis").classes("text-lg")

            if self.analysis_contexts:
                self._render_previous_analyses_grid()
            else:
                ui.label("No previous tests have been found.").classes("text-grey")

            async def _on_proceed():
                """Handle proceed button click."""
                if not self.analysis_contexts:
                    self.notify_warning("No analyses available")
                    return

                if self.grid is None:
                    return

                selected_rows = await self.grid.get_selected_rows()
                if not selected_rows:
                    self.notify_warning("Please select a previous analysis")
                    return

                selected_id = selected_rows[0].get("analysis_id")
                if not selected_id:
                    self.notify_error("Selected row is missing analysis ID")
                    return

                selected_context = next(
                    (ctx for ctx in self.analysis_contexts if ctx.id == selected_id),
                    None,
                )

                if selected_context is None:
                    self.notify_error(f"Analysis '{selected_id}' not found in project")
                    return

                if selected_context.is_draft:
                    self.notify_warning(
                        "This analysis is incomplete and cannot be viewed. "
                        "Please select a completed analysis."
                    )
                    return

                self.session.current_analysis = selected_context.model
                self.session.selected_analyzer = selected_context.analyzer_spec
                self.session.selected_analyzer_name = (
                    selected_context.analyzer_spec.name
                )
                self.session.column_mapping = selected_context.column_mapping
                self.session.analysis_params = selected_context.backfilled_param_values
                self.session.analysis_loaded_from_storage = True

                self.navigate_to(gui_routes.post_analysis)

            async def _on_manage_analyses():
                """Handle manage analyses button click."""
                dialog = ManageAnalysisDialog(session=self.session)
                deleted_ids: set = await dialog

                if not deleted_ids:
                    return

                # Remove deleted analyses from instance state
                self.analysis_contexts = [
                    ctx for ctx in self.analysis_contexts if ctx.id not in deleted_ids
                ]

                # Update the page grid in place — no page navigation needed
                if self.grid is not None:
                    now = datetime.now()
                    self.grid.options["rowData"] = [
                        {
                            "name": ctx.display_name,
                            "date": (
                                present_timestamp(ctx.create_time, now)
                                if ctx.create_time
                                else "Unknown"
                            ),
                            "analysis_id": ctx.id,
                        }
                        for ctx in self.analysis_contexts
                    ]
                    self.grid.update()

                count = len(deleted_ids)
                label = "analysis" if count == 1 else "analyses"
                self.notify_success(f"Deleted {count} {label}.")

            with ui.row().classes("gap-4"):
                ui.button(
                    "Manage Analyses",
                    icon="settings",
                    color="secondary",
                    on_click=_on_manage_analyses,
                )
                ui.button(
                    "Proceed",
                    icon="arrow_forward",
                    color="primary",
                    on_click=_on_proceed,
                )

    def _render_previous_analyses_grid(self) -> None:
        """Render grid of previous analyses."""
        now = datetime.now()

        self.grid = ui.aggrid(
            {
                "columnDefs": [
                    {"headerName": "Analyzer Name", "field": "name"},
                    {"headerName": "Date Created", "field": "date"},
                    {"headerName": "ID", "field": "analysis_id", "hide": True},
                ],
                "rowData": [
                    {
                        "name": ctx.display_name,
                        "date": (
                            present_timestamp(ctx.create_time, now)
                            if ctx.create_time
                            else "Unknown"
                        ),
                        "analysis_id": ctx.id,
                    }
                    for ctx in self.analysis_contexts
                ],
                "rowSelection": {"mode": "singleRow"},
            },
            theme="quartz",
        ).classes("w-full h-64")
render_content()

Render previous analysis selection interface.

Source code in src/cibmangotree/gui/pages/analyzer_previous.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def render_content(self) -> None:
    """Render previous analysis selection interface."""
    # Ensure a project is selected
    if not self.require_project():
        return

    # Store analyses as instance state so the grid can be updated in place
    self.analysis_contexts = self.session.current_project.list_analyses()

    # Main content - centered
    with self.centered_content(max_width="800px"):
        ui.label("Review a Previous Analysis").classes("text-lg")

        if self.analysis_contexts:
            self._render_previous_analyses_grid()
        else:
            ui.label("No previous tests have been found.").classes("text-grey")

        async def _on_proceed():
            """Handle proceed button click."""
            if not self.analysis_contexts:
                self.notify_warning("No analyses available")
                return

            if self.grid is None:
                return

            selected_rows = await self.grid.get_selected_rows()
            if not selected_rows:
                self.notify_warning("Please select a previous analysis")
                return

            selected_id = selected_rows[0].get("analysis_id")
            if not selected_id:
                self.notify_error("Selected row is missing analysis ID")
                return

            selected_context = next(
                (ctx for ctx in self.analysis_contexts if ctx.id == selected_id),
                None,
            )

            if selected_context is None:
                self.notify_error(f"Analysis '{selected_id}' not found in project")
                return

            if selected_context.is_draft:
                self.notify_warning(
                    "This analysis is incomplete and cannot be viewed. "
                    "Please select a completed analysis."
                )
                return

            self.session.current_analysis = selected_context.model
            self.session.selected_analyzer = selected_context.analyzer_spec
            self.session.selected_analyzer_name = (
                selected_context.analyzer_spec.name
            )
            self.session.column_mapping = selected_context.column_mapping
            self.session.analysis_params = selected_context.backfilled_param_values
            self.session.analysis_loaded_from_storage = True

            self.navigate_to(gui_routes.post_analysis)

        async def _on_manage_analyses():
            """Handle manage analyses button click."""
            dialog = ManageAnalysisDialog(session=self.session)
            deleted_ids: set = await dialog

            if not deleted_ids:
                return

            # Remove deleted analyses from instance state
            self.analysis_contexts = [
                ctx for ctx in self.analysis_contexts if ctx.id not in deleted_ids
            ]

            # Update the page grid in place — no page navigation needed
            if self.grid is not None:
                now = datetime.now()
                self.grid.options["rowData"] = [
                    {
                        "name": ctx.display_name,
                        "date": (
                            present_timestamp(ctx.create_time, now)
                            if ctx.create_time
                            else "Unknown"
                        ),
                        "analysis_id": ctx.id,
                    }
                    for ctx in self.analysis_contexts
                ]
                self.grid.update()

            count = len(deleted_ids)
            label = "analysis" if count == 1 else "analyses"
            self.notify_success(f"Deleted {count} {label}.")

        with ui.row().classes("gap-4"):
            ui.button(
                "Manage Analyses",
                icon="settings",
                color="secondary",
                on_click=_on_manage_analyses,
            )
            ui.button(
                "Proceed",
                icon="arrow_forward",
                color="primary",
                on_click=_on_proceed,
            )

analyzer_select

Classes:

Name Description
SelectAnalyzerForkPage

A forking page with two buttons for either advancing to start a new analysis or selecting an old one

SelectAnalyzerForkPage

Bases: GuiPage

A forking page with two buttons for either advancing to start a new analysis or selecting an old one

Source code in src/cibmangotree/gui/pages/analyzer_select.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class SelectAnalyzerForkPage(GuiPage):
    """A forking page with two buttons for either advancing to start a new analysis or selecting an old one"""

    def __init__(self, session: GuiSession):
        super().__init__(
            session=session,
            route=gui_routes.select_analyzer_fork,
            title=(
                session.current_project.display_name
                if session.current_project is not None
                else ""
            ),
            show_back_button=True,
            back_route=gui_routes.select_project,
            show_footer=True,
        )

    def on_exit(self) -> None:
        self.session.reset_analysis_workflow()

    def render_content(self):
        # Main content area - centered vertically
        with self.centered_content():
            two_button_choice_fork_content(
                prompt="What do you want to do next?",
                left_button_label="Start a New Test",
                left_button_icon="computer",
                left_button_on_click=lambda: self.navigate_to(
                    gui_routes.configure_analysis
                ),
                right_button_label="Review a Previous Test",
                right_button_on_click=lambda: self.navigate_to(
                    gui_routes.select_previous_analyzer
                ),
                right_button_icon="refresh",
            )

dataset_preview

Classes:

Name Description
PreviewDatasetPage

Data preview page showing a sample of the imported dataset.

PreviewDatasetPage

Bases: GuiPage

Data preview page showing a sample of the imported dataset.

Allows users to: 1. View first 5 rows of data with column info 2. Adjust import options (delimiter, encoding, etc.) 3. Confirm and create project with imported data

Source code in src/cibmangotree/gui/pages/dataset_preview.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
class PreviewDatasetPage(GuiPage):
    """
    Data preview page showing a sample of the imported dataset.

    Allows users to:
    1. View first 5 rows of data with column info
    2. Adjust import options (delimiter, encoding, etc.)
    3. Confirm and create project with imported data
    """

    def __init__(self, session: GuiSession):
        super().__init__(
            session=session,
            route=gui_routes.preview_dataset,
            title="Data Preview",
            show_back_button=True,
            back_route=gui_routes.import_dataset,
            show_footer=True,
        )

    def requires_exit_confirmation(self) -> bool:
        return self.session.selected_file is not None

    def get_exit_confirmation_message(self) -> str:
        return "No project has been created yet. Leave anyway?"

    def on_exit(self) -> None:
        self.session.reset_project_workflow()

    def _selected_file_does_not_exists(self) -> bool:
        return (
            not self.session.selected_file_content_type
            or not self.session.selected_file
            or not self.session.selected_file_name
        )

    def render_content(self) -> None:
        # Validate file is selected
        if self._selected_file_does_not_exists():
            self.notify_warning("No file selected. Redirecting...")
            self.navigate_to(gui_routes.import_dataset)
            return

        # Auto-detect importer
        importer = None
        for imp in importers:
            if imp.suggest(cast(str, self.session.selected_file_content_type)):
                importer = imp
                break

        if not importer:
            self.notify_error("Could not detect file format")
            self.navigate_to(gui_routes.import_dataset)
            return

        # Initialize import session and load preview
        try:
            import_session = importer.init_session(
                cast(BytesIO, self.session.selected_file)
            )
            if not import_session:
                raise ValueError("Failed to initialize import session")

            # Store session for later use
            self.session.import_session = import_session

            N_ROWS_FOR_PREVIEW = 5
            import_preview = import_session.load_preview(n_records=N_ROWS_FOR_PREVIEW)

            # Container for dynamic preview updates
            data_preview_container = None

            # Retry callback for import options dialog
            async def handle_retry(updated_session):
                """Handle retry from import options dialog."""
                if data_preview_container is None:
                    return

                nonlocal import_preview

                try:
                    # Update session in GuiSession
                    self.session.import_session = updated_session

                    # Reload preview with new settings
                    import_preview = updated_session.load_preview(
                        n_records=N_ROWS_FOR_PREVIEW
                    )

                    # Clear and rebuild data preview
                    data_preview_container.clear()
                    with data_preview_container:
                        self._make_preview_grid(import_preview)

                    self.notify_success("Preview updated successfully!")

                except Exception as e:
                    self.notify_error(f"Error: {str(e)}")
                    print(f"Retry import error:\n{format_exc()}")

            # Open import options dialog
            async def open_import_options():
                if (
                    self.session.import_session is None
                    or self._selected_file_does_not_exists()
                ):
                    return

                dialog = ImportOptionsDialog(
                    import_session=self.session.import_session,
                    selected_file=cast(BytesIO, self.session.selected_file),
                    on_retry=handle_retry,
                )
                await dialog

            # Import and create project
            async def import_data_create_project():
                try:
                    # Create project using session data
                    project = self.session.app.create_project(
                        name=(
                            self.session.new_project_name
                            if self.session.new_project_name is not None
                            else ""
                        ),
                        importer_session=self.session.import_session,
                    )

                    # Store project in session
                    self.session.current_project = project

                    # Navigate to analyzer selection
                    self.navigate_to(gui_routes.configure_analysis)

                except Exception as e:
                    self.notify_error(f"Error creating project: {str(e)}")
                    print(f"Project creation error:\n{format_exc()}")

            # Main content area - centered
            with self.centered_content(
                max_width="1200px", height="70vh", padding="2rem"
            ):
                # Data Preview (with container for dynamic updates)
                data_preview_container = ui.column().classes("w-full")
                with data_preview_container:
                    self._make_preview_grid(import_preview)

                # Bottom Actions
                with ui.row().classes("w-full justify-center gap-2 mt-4"):
                    ui.button(
                        "Change Import Options",
                        icon="settings",
                        color="secondary",
                        on_click=open_import_options,
                    ).props("outline")

                    ui.button(
                        "Import and Create Project",
                        icon="upload",
                        color="primary",
                        on_click=import_data_create_project,
                    )

        except Exception as e:
            self.notify_error(f"Error loading preview: {str(e)}")
            print(f"Preview error:\n{format_exc()}")
            self.navigate_to(gui_routes.import_dataset)
            return

    def _make_preview_grid(self, data_frame):
        """
        Render preview data grid with column information.

        Args:
            data_frame: Polars DataFrame containing preview data
        """
        ui.label("Data Preview (first 5 rows)").classes("text-lg")

        # Count empty columns
        n_empty = sum((c[0] == 0 for c in data_frame.count().iter_columns()))
        ui.label(
            f"Nr. detected columns: {len(data_frame.columns)} ({n_empty} empty)"
        ).classes("text-sm")

        # Display data grid
        ui.aggrid.from_polars(
            data_frame, theme="quartz", auto_size_columns=False
        ).classes("w-full h-64")

importer

Classes:

Name Description
ImportDatasetPage

Dataset import page for selecting a file.

ImportDatasetPage

Bases: GuiPage

Dataset import page for selecting a file.

Allows users to: 1. Browse for CSV/Excel files 2. View file information 3. Proceed to data preview

Methods:

Name Description
render_content

Render file selection interface.

Source code in src/cibmangotree/gui/pages/importer.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class ImportDatasetPage(GuiPage):
    """
    Dataset import page for selecting a file.

    Allows users to:
    1. Browse for CSV/Excel files
    2. View file information
    3. Proceed to data preview
    """

    def __init__(self, session: GuiSession):
        super().__init__(
            session=session,
            route=gui_routes.import_dataset,
            title="Import Dataset",
            show_back_button=True,
            back_route=gui_routes.new_project,
            show_footer=True,
        )

    def requires_exit_confirmation(self) -> bool:
        if self.session.project_loaded_from_storage:
            return False
        return (
            self.session.current_project is not None
            or self.session.selected_file is not None
        )

    def get_exit_confirmation_message(self) -> str:
        return "No project has been created yet. Leave anyway?"

    def on_exit(self) -> None:
        self.session.reset_project_workflow()

    def render_content(self) -> None:
        """Render file selection interface."""
        # Page state - store selected file path locally
        selected_file_path = None

        # Main content - centered vertically and horizontally
        with self.centered_content(max_width="800px"):
            ui.label("Choose a dataset file.").classes("text-lg")

            # File info card (initially hidden)
            file_info_card = ui.card().style("display: none;")
            with file_info_card:
                file_name_label = ui.label().classes("text-sm")
                file_path_label = ui.label().classes("text-sm")
                file_size_label = ui.label().classes("text-sm")
                file_modified_label = ui.label().classes("text-sm")

                with ui.row().classes("w-full justify-end gap-2 mt-4"):
                    change_file_btn = ui.button(
                        "Pick a different file",
                        icon="edit",
                        color="secondary",
                        on_click=lambda: None,
                    ).props("outline")
                    preview_btn = ui.button(
                        "Next: Preview Data", icon="arrow_forward", color="primary"
                    )

            async def handle_upload(upload: UploadFile) -> None:
                file_contents: bytes = await upload.read()
                self.session.selected_file_content_type = upload.content_type
                self.session.selected_file_name = upload.filename
                self.session.selected_file = BytesIO(file_contents)

            upload_button = UploadButton(
                handle_upload,
                "Browse Files",
                icon="folder_open",
                redirect_url=gui_routes.preview_dataset,
            )
render_content()

Render file selection interface.

Source code in src/cibmangotree/gui/pages/importer.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def render_content(self) -> None:
    """Render file selection interface."""
    # Page state - store selected file path locally
    selected_file_path = None

    # Main content - centered vertically and horizontally
    with self.centered_content(max_width="800px"):
        ui.label("Choose a dataset file.").classes("text-lg")

        # File info card (initially hidden)
        file_info_card = ui.card().style("display: none;")
        with file_info_card:
            file_name_label = ui.label().classes("text-sm")
            file_path_label = ui.label().classes("text-sm")
            file_size_label = ui.label().classes("text-sm")
            file_modified_label = ui.label().classes("text-sm")

            with ui.row().classes("w-full justify-end gap-2 mt-4"):
                change_file_btn = ui.button(
                    "Pick a different file",
                    icon="edit",
                    color="secondary",
                    on_click=lambda: None,
                ).props("outline")
                preview_btn = ui.button(
                    "Next: Preview Data", icon="arrow_forward", color="primary"
                )

        async def handle_upload(upload: UploadFile) -> None:
            file_contents: bytes = await upload.read()
            self.session.selected_file_content_type = upload.content_type
            self.session.selected_file_name = upload.filename
            self.session.selected_file = BytesIO(file_contents)

        upload_button = UploadButton(
            handle_upload,
            "Browse Files",
            icon="folder_open",
            redirect_url=gui_routes.preview_dataset,
        )

project_select

Classes:

Name Description
SelectProjectPage

Projects list page showing existing projects.

SelectProjectPage

Bases: GuiPage

Projects list page showing existing projects.

Allows users to select an existing project to work with.

Methods:

Name Description
render_content

Render projects list with selection interface.

Source code in src/cibmangotree/gui/pages/project_select.py
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
class SelectProjectPage(GuiPage):
    """
    Projects list page showing existing projects.

    Allows users to select an existing project to work with.
    """

    def __init__(self, session: GuiSession):
        super().__init__(
            session=session,
            route="/select_project",
            title="CIB Mango Tree",
            show_back_button=True,
            back_route="/",
            show_footer=True,
        )

    def on_exit(self) -> None:
        self.session.reset_analysis_workflow()

    def render_content(self) -> None:
        """Render projects list with selection interface."""
        # Projects list - centered
        with (
            ui.row()
            .classes("items-center center-justify")
            .style("max-width: 600px; margin: 0 auto;")
        ):
            # Get projects from app via session
            projects = self.session.app.list_projects()

            if not projects:
                # No projects found - show message
                with ui.column().classes("items-center q-mt-lg"):
                    ui.label("No existing projects found.").classes("text-grey")
                    ui.label("Create a new project to get started.").classes(
                        "text-grey"
                    )
            else:
                # Create dropdown with project names
                project_options = {
                    project.display_name: project for project in projects
                }

                with self.centered_content(max_width="600px"):
                    selected_project = (
                        ui.select(
                            label="Select a project",
                            options=list(project_options.keys()),
                            with_input=True,
                        )
                        .classes("q-mt-md")
                        .style("width: 100%; max-width: 400px")
                    )

                    def on_project_selected():
                        """Handle project selection and navigate to analyzer page."""
                        if selected_project.value:
                            # Store selected project in session
                            self.session.current_project = project_options[
                                selected_project.value
                            ]
                            self.session.project_loaded_from_storage = True
                            self.notify_success(
                                f"Selected project: {self.session.current_project.display_name}"
                            )
                            self.navigate_to(gui_routes.select_analyzer_fork)

                    async def open_manage_projects():
                        """Open the Manage Projects dialog."""
                        from cibmangotree.gui.components.manage_projects import (
                            ManageProjectsDialog,
                        )

                        dialog = ManageProjectsDialog(session=self.session)
                        result = await dialog

                        # If a project_id exists, show notification and refresh
                        # result -> (is_deleted, project_name, project_id)
                        if isinstance(result, tuple) and result[0]:
                            self.notify_success(
                                f"Successfully deleted project: {result[1]} (ID: {result[2]})"
                            )

                    with ui.row().classes("items-center center-justify"):
                        ui.button(
                            "Manage Projects",
                            on_click=open_manage_projects,
                            icon="settings",
                            color="secondary",
                        ).props("outline").classes("q-mt-md")

                        ui.button(
                            "Open Project",
                            on_click=on_project_selected,
                            icon="arrow_forward",
                            color="primary",
                        ).classes("q-mt-md")
render_content()

Render projects list with selection interface.

Source code in src/cibmangotree/gui/pages/project_select.py
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def render_content(self) -> None:
    """Render projects list with selection interface."""
    # Projects list - centered
    with (
        ui.row()
        .classes("items-center center-justify")
        .style("max-width: 600px; margin: 0 auto;")
    ):
        # Get projects from app via session
        projects = self.session.app.list_projects()

        if not projects:
            # No projects found - show message
            with ui.column().classes("items-center q-mt-lg"):
                ui.label("No existing projects found.").classes("text-grey")
                ui.label("Create a new project to get started.").classes(
                    "text-grey"
                )
        else:
            # Create dropdown with project names
            project_options = {
                project.display_name: project for project in projects
            }

            with self.centered_content(max_width="600px"):
                selected_project = (
                    ui.select(
                        label="Select a project",
                        options=list(project_options.keys()),
                        with_input=True,
                    )
                    .classes("q-mt-md")
                    .style("width: 100%; max-width: 400px")
                )

                def on_project_selected():
                    """Handle project selection and navigate to analyzer page."""
                    if selected_project.value:
                        # Store selected project in session
                        self.session.current_project = project_options[
                            selected_project.value
                        ]
                        self.session.project_loaded_from_storage = True
                        self.notify_success(
                            f"Selected project: {self.session.current_project.display_name}"
                        )
                        self.navigate_to(gui_routes.select_analyzer_fork)

                async def open_manage_projects():
                    """Open the Manage Projects dialog."""
                    from cibmangotree.gui.components.manage_projects import (
                        ManageProjectsDialog,
                    )

                    dialog = ManageProjectsDialog(session=self.session)
                    result = await dialog

                    # If a project_id exists, show notification and refresh
                    # result -> (is_deleted, project_name, project_id)
                    if isinstance(result, tuple) and result[0]:
                        self.notify_success(
                            f"Successfully deleted project: {result[1]} (ID: {result[2]})"
                        )

                with ui.row().classes("items-center center-justify"):
                    ui.button(
                        "Manage Projects",
                        on_click=open_manage_projects,
                        icon="settings",
                        color="secondary",
                    ).props("outline").classes("q-mt-md")

                    ui.button(
                        "Open Project",
                        on_click=on_project_selected,
                        icon="arrow_forward",
                        color="primary",
                    ).classes("q-mt-md")

start

Classes:

Name Description
StartPage

Main/home page of the application.

StartPage

Bases: GuiPage

Main/home page of the application.

Displays welcome message and primary navigation buttons for creating a new project or viewing existing projects.

Methods:

Name Description
render_content

Render main page content with action buttons.

Source code in src/cibmangotree/gui/pages/start.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class StartPage(GuiPage):
    """
    Main/home page of the application.

    Displays welcome message and primary navigation buttons for
    creating a new project or viewing existing projects.
    """

    def __init__(self, session: GuiSession):
        super().__init__(
            session=session,
            route="/",
            title="CIB Mango Tree",
            show_back_button=False,  # Home page - no back navigation
            show_footer=True,
        )

    def render_content(self) -> None:
        """Render main page content with action buttons."""
        # Main content area - centered vertically
        with self.centered_content():
            # Hero logo
            ui.html(self._load_svg_icon("cibmt_logo"), sanitize=False).classes(
                "size-36 q-mb-xl"
            )

            # Action buttons row
            with ui.row().classes("gap-4"):
                ui.button(
                    "New Project",
                    on_click=lambda: self.navigate_to(gui_routes.new_project),
                    icon="add",
                    color="primary",
                )

                ui.button(
                    "Show Existing Projects",
                    on_click=lambda: self.navigate_to(gui_routes.select_project),
                    icon="folder",
                    color="primary",
                )
render_content()

Render main page content with action buttons.

Source code in src/cibmangotree/gui/pages/start.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
def render_content(self) -> None:
    """Render main page content with action buttons."""
    # Main content area - centered vertically
    with self.centered_content():
        # Hero logo
        ui.html(self._load_svg_icon("cibmt_logo"), sanitize=False).classes(
            "size-36 q-mb-xl"
        )

        # Action buttons row
        with ui.row().classes("gap-4"):
            ui.button(
                "New Project",
                on_click=lambda: self.navigate_to(gui_routes.new_project),
                icon="add",
                color="primary",
            )

            ui.button(
                "Show Existing Projects",
                on_click=lambda: self.navigate_to(gui_routes.select_project),
                icon="folder",
                color="primary",
            )

cibmangotree.gui.dashboards

Dashboard pages for the NiceGUI GUI.

Each analyzer that produces results has a corresponding dashboard module here. All dashboard pages inherit from BaseDashboardPage, which extends GuiPage.

Modules:

Name Description
base_dashboard

BaseDashboardPage abstract base class

hashtags

HashtagsDashboardPage for the hashtags analyzer

ngrams

NgramsDashboardPage for the n-grams analyzer

placeholder

PlaceholderDashboard shown when no dashboard exists yet

temporal

TemporalDashboardPage for the temporal analyzer (planned)

Classes:

Name Description
BaseDashboardPage

Abstract base class for all analyzer dashboard pages.

HashtagsDashboardPage

Hashtags dashboard with ranked lists instead of bar charts.

NgramsDashboardPage

Results dashboard for the N-grams (Copy-Pasta Detector) analyzer.

PlaceholderDashboard

Fallback page shown when the selected analyzer has no dashboard yet.

Functions:

Name Description
get_dashboard

Look up a registered dashboard class by analyzer ID.

BaseDashboardPage

Bases: GuiPage, ABC

Abstract base class for all analyzer dashboard pages.

Extends GuiPage with dashboard-specific defaults: - Back button navigates to the post-analysis page - Title is derived from the selected analyzer name - Footer is shown

Subclasses must set _secondary_analyzer_id to enable get_output_parquet_path() for loading analysis results.

Subclasses implement render_content() to provide the actual charts, tables, and interactive controls for each analyzer.

Usage
class NgramsDashboardPage(BaseDashboardPage):
    _secondary_analyzer_id = ngram_stats_interface.id

    def render_content(self) -> None:
        ui.label("N-grams dashboard content here")

Methods:

Name Description
get_output_parquet_path

Get path to a secondary output parquet file for the current analysis.

load_parquet_async

Load a parquet file asynchronously with loading/error handling.

render_content

Render dashboard-specific charts, tables, and controls.

Source code in src/cibmangotree/gui/dashboards/base_dashboard.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
class BaseDashboardPage(GuiPage, abc.ABC):
    """
    Abstract base class for all analyzer dashboard pages.

    Extends GuiPage with dashboard-specific defaults:
    - Back button navigates to the post-analysis page
    - Title is derived from the selected analyzer name
    - Footer is shown

    Subclasses must set _secondary_analyzer_id to enable
    get_output_parquet_path() for loading analysis results.

    Subclasses implement render_content() to provide the actual
    charts, tables, and interactive controls for each analyzer.

    Usage:
        ```python
        class NgramsDashboardPage(BaseDashboardPage):
            _secondary_analyzer_id = ngram_stats_interface.id

            def render_content(self) -> None:
                ui.label("N-grams dashboard content here")
        ```
    """

    _secondary_analyzer_id: str | None = None

    def __init__(self, session: GuiSession):
        analyzer_name = (
            session.selected_analyzer_name
            if session.selected_analyzer_name
            else "Results Dashboard"
        )
        super().__init__(
            session=session,
            route=gui_routes.dashboard,
            title=f"{analyzer_name}: Results Dashboard",
            show_back_button=True,
            back_route=gui_routes.post_analysis,
            show_footer=True,
        )

    def get_output_parquet_path(self, output_id: str) -> str | None:
        """Get path to a secondary output parquet file for the current analysis."""
        analysis = self.session.current_analysis
        if analysis is None or self._secondary_analyzer_id is None:
            return None
        storage = self.session.app.context.storage
        try:
            return storage.get_secondary_output_parquet_path(
                analysis,
                self._secondary_analyzer_id,
                output_id,
            )
        except Exception:
            return None

    async def load_parquet_async(
        self,
        output_id: str,
        on_success: Callable[["pl.DataFrame"], None],
        loading_container: ui.column | None = None,
        content_container: ui.column | None = None,
    ) -> None:
        """
        Load a parquet file asynchronously with loading/error handling.

        Args:
            output_id: Secondary output identifier to locate the parquet file.
            on_success: Callback invoked with the loaded DataFrame on success.
            loading_container: Container to show error in if loading fails.
            content_container: Paired content container to reveal on success.
        """
        import polars as pl

        path = self.get_output_parquet_path(output_id)
        if path is None:
            if loading_container is not None:
                self._show_error(loading_container, "No analysis data found.")
            return
        try:
            df = await run.io_bound(pl.read_parquet, path)
            if df.is_empty():
                if loading_container is not None:
                    self._show_error(loading_container, "No data available.")
                return
            on_success(df)
            if loading_container is not None and content_container is not None:
                self._show_content(loading_container, content_container)
        except Exception as exc:
            if loading_container is not None:
                self._show_error(loading_container, f"Could not load data: {exc}")

    def _create_loading_container(
        self, height: str = "500px"
    ) -> tuple[ui.column, ui.column]:
        """
        Create a loading spinner container and a (hidden) content container.

        Subclasses call this inside render_content() and later call
        _show_content() / _show_error() to switch states.

        Returns:
            (loading_container, content_container)
        """
        loading_container = (
            ui.column()
            .classes("w-full items-center justify-center")
            .style(f"height: {height};")
        )
        with loading_container:
            ui.spinner("pie", size="xl")
            ui.label("Loading dashboard...").classes("text-grey-6 q-mt-md")

        content_container = (
            ui.column().classes("w-full").style(f"height: {height}; display: none;")
        )

        return loading_container, content_container

    def _show_content(self, loading_container: ui.column, content_container: ui.column):
        """Switch from loading state to content display."""
        loading_container.style("display: none;")
        content_container.style("display: block;")

    def _show_error(
        self,
        loading_container: ui.column,
        message: str,
    ) -> None:
        """Replace loading spinner with an error message in-place."""
        loading_container.clear()
        with loading_container:
            ui.icon("error_outline", size="3rem").classes("text-negative")
            ui.label(message).classes("text-negative q-mt-md")

    @abc.abstractmethod
    def render_content(self) -> None:
        """Render dashboard-specific charts, tables, and controls."""
        raise NotImplementedError

get_output_parquet_path(output_id)

Get path to a secondary output parquet file for the current analysis.

Source code in src/cibmangotree/gui/dashboards/base_dashboard.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def get_output_parquet_path(self, output_id: str) -> str | None:
    """Get path to a secondary output parquet file for the current analysis."""
    analysis = self.session.current_analysis
    if analysis is None or self._secondary_analyzer_id is None:
        return None
    storage = self.session.app.context.storage
    try:
        return storage.get_secondary_output_parquet_path(
            analysis,
            self._secondary_analyzer_id,
            output_id,
        )
    except Exception:
        return None

load_parquet_async(output_id, on_success, loading_container=None, content_container=None) async

Load a parquet file asynchronously with loading/error handling.

Parameters:

Name Type Description Default
output_id
str

Secondary output identifier to locate the parquet file.

required
on_success
Callable[[DataFrame], None]

Callback invoked with the loaded DataFrame on success.

required
loading_container
column | None

Container to show error in if loading fails.

None
content_container
column | None

Paired content container to reveal on success.

None
Source code in src/cibmangotree/gui/dashboards/base_dashboard.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
async def load_parquet_async(
    self,
    output_id: str,
    on_success: Callable[["pl.DataFrame"], None],
    loading_container: ui.column | None = None,
    content_container: ui.column | None = None,
) -> None:
    """
    Load a parquet file asynchronously with loading/error handling.

    Args:
        output_id: Secondary output identifier to locate the parquet file.
        on_success: Callback invoked with the loaded DataFrame on success.
        loading_container: Container to show error in if loading fails.
        content_container: Paired content container to reveal on success.
    """
    import polars as pl

    path = self.get_output_parquet_path(output_id)
    if path is None:
        if loading_container is not None:
            self._show_error(loading_container, "No analysis data found.")
        return
    try:
        df = await run.io_bound(pl.read_parquet, path)
        if df.is_empty():
            if loading_container is not None:
                self._show_error(loading_container, "No data available.")
            return
        on_success(df)
        if loading_container is not None and content_container is not None:
            self._show_content(loading_container, content_container)
    except Exception as exc:
        if loading_container is not None:
            self._show_error(loading_container, f"Could not load data: {exc}")

render_content() abstractmethod

Render dashboard-specific charts, tables, and controls.

Source code in src/cibmangotree/gui/dashboards/base_dashboard.py
163
164
165
166
@abc.abstractmethod
def render_content(self) -> None:
    """Render dashboard-specific charts, tables, and controls."""
    raise NotImplementedError

HashtagsDashboardPage

Bases: BaseDashboardPage

Hashtags dashboard with ranked lists instead of bar charts.

Dependency chain: Gini plot click -> sets timewindow -> populates hashtag list Hashtag row click -> populates user list User row click -> populates tweet explorer

Source code in src/cibmangotree/gui/dashboards/hashtags/dashboard.py
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
class HashtagsDashboardPage(BaseDashboardPage):
    """
    Hashtags dashboard with ranked lists instead of bar charts.

    Dependency chain:
    Gini plot click -> sets timewindow -> populates hashtag list
    Hashtag row click -> populates user list
    User row click -> populates tweet explorer
    """

    def __init__(self, session: GuiSession):
        super().__init__(session=session)

        self._df_primary: pl.DataFrame | None = None
        self._df_raw: pl.DataFrame | None = None
        self._df_secondary: pl.DataFrame | None = None
        self._df_users: pl.DataFrame | None = None
        self._smooth: bool = False

        self._selected_timewindow: datetime | None = None
        self._selected_hashtag: str | None = None
        self._selected_user: str | None = None
        self._selected_gini_data_index: int | None = None

        self._gini_chart: ui.echart | None = None
        self._gini_loading: ui.column | None = None
        self._gini_content: ui.column | None = None
        self._smooth_checkbox: ui.checkbox | None = None

        self._hashtag_grid: ui.aggrid | None = None
        self._hashtag_loading: ui.column | None = None
        self._hashtag_content: ui.column | None = None
        self._hashtag_info: ui.label | None = None

        self._user_grid: ui.aggrid | None = None
        self._user_loading: ui.column | None = None
        self._user_content: ui.column | None = None
        self._user_info: ui.label | None = None

        self._tweet_grid: ui.aggrid | None = None
        self._tweet_loading: ui.column | None = None
        self._tweet_content: ui.column | None = None
        self._tweet_info: ui.label | None = None

    async def _load_and_render_async(self) -> None:
        try:
            self._df_primary = await run.io_bound(load_primary_output, self.session)
        except Exception as exc:
            if self._gini_loading is not None:
                self._show_error(
                    self._gini_loading, f"Could not load hashtag analysis: {exc}"
                )
            return

        if self._df_primary.is_empty():
            if self._gini_loading is not None:
                self._show_error(self._gini_loading, "No hashtag data available.")
            return

        try:
            option = await run.cpu_bound(
                plot_gini_echart,
                self._df_primary,
                self._smooth,
            )
        except Exception as exc:
            if self._gini_loading is not None:
                self._show_error(self._gini_loading, f"Could not build chart: {exc}")
            return

        if (
            self._gini_chart is None
            or self._gini_content is None
            or self._gini_loading is None
        ):
            return

        self._gini_chart.options.update(option)
        self._gini_chart.update()
        self._show_content(self._gini_loading, self._gini_content)

    async def _load_raw_input(self) -> None:
        if self._df_raw is not None:
            return
        try:
            self._df_raw = await run.io_bound(load_transformed_raw_input, self.session)
        except Exception as exc:
            self.notify_error(f"Could not load raw input data: {exc}")

    def _handle_smooth_change(self, e) -> None:
        self._smooth = e.value
        self._selected_gini_data_index = None
        if self._gini_chart is not None and self._df_primary is not None:
            option = plot_gini_echart(self._df_primary, smooth=self._smooth)
            self._gini_chart.options.update(option)
            self._gini_chart.update()

    def _get_raw_data_index(self, raw_ts: str) -> int | None:
        if self._df_primary is None:
            return None
        ts_list = self._df_primary[OUTPUT_COL_TIMESPAN].to_list()
        for i, ts in enumerate(ts_list):
            if ts.strftime(PRIMARY_OUTPUT_DATETIME_FORMAT) == raw_ts:
                return i
        return None

    def _draw_vertical_marker(self, raw_ts: str) -> None:
        if self._gini_chart is None:
            return
        self._gini_chart.run_chart_method(
            "setOption",
            {
                "series": [
                    {
                        "markLine": {
                            "silent": True,
                            "symbol": "none",
                            "animation": False,
                            "lineStyle": {"color": "#d62728", "width": 2},
                            "label": {
                                "position": "end",
                                "distance": 10,
                            },
                            "data": [{"xAxis": raw_ts}],
                        }
                    }
                ]
            },
            False,
        )

    def _clear_vertical_marker(self) -> None:
        if self._gini_chart is None:
            return
        self._gini_chart.run_chart_method(
            "setOption",
            {"series": [{"markLine": {"data": []}}]},
            False,
        )

    def _handle_gini_click(self, e) -> None:
        clicked_data = e.data
        if clicked_data is None:
            return

        raw_ts = clicked_data.get("raw_ts")
        if raw_ts is None:
            return

        try:
            timewindow = datetime.strptime(raw_ts, PRIMARY_OUTPUT_DATETIME_FORMAT)
        except ValueError:
            return

        data_index = self._get_raw_data_index(raw_ts)
        if data_index is None:
            return

        if self._selected_timewindow == timewindow:
            if self._selected_gini_data_index is not None:
                self._clear_vertical_marker()
            self._selected_timewindow = None
            self._selected_gini_data_index = None
            self._selected_hashtag = None
            self._selected_user = None
            self._update_hashtag_info()
            self._clear_user_grid()
            self._clear_tweet_grid()
            return

        if self._selected_gini_data_index is not None:
            self._clear_vertical_marker()

        self._selected_timewindow = timewindow
        self._selected_gini_data_index = data_index
        self._draw_vertical_marker(raw_ts)
        self._selected_hashtag = None
        self._selected_user = None

        self._update_hashtag_info()
        self._clear_user_grid()
        self._clear_tweet_grid()

        ui.timer(0, self._run_secondary_analysis, once=True)

    async def _run_secondary_analysis(self) -> None:
        if self._df_primary is None or self._selected_timewindow is None:
            return

        if self._hashtag_loading is None or self._hashtag_content is None:
            return

        try:
            df_secondary = await run.cpu_bound(
                secondary_analyzer,
                self._df_primary,
                self._selected_timewindow,
            )
        except Exception as exc:
            self._show_error(
                self._hashtag_loading, f"Could not analyze time window: {exc}"
            )
            return

        if df_secondary.is_empty():
            self._show_error(
                self._hashtag_loading, "No hashtag data for this time window."
            )
            return

        self._df_secondary = df_secondary
        self._update_hashtag_grid()
        self._show_content(self._hashtag_loading, self._hashtag_content)
        self._update_hashtag_info()

    def _update_hashtag_grid(self) -> None:
        if self._hashtag_grid is None or self._df_secondary is None:
            return

        df_display = (
            self._df_secondary.select(
                [
                    OUTPUT_COL_HASHTAGS,
                    SECONDARY_COL_HASHTAG_PERC,
                    SECONDARY_COL_USERS_ALL,
                ]
            )
            .with_columns(
                n_users=pl.col(SECONDARY_COL_USERS_ALL).list.len(),
            )
            .sort(SECONDARY_COL_HASHTAG_PERC, descending=True)
            .rename(
                {
                    OUTPUT_COL_HASHTAGS: "Hashtag",
                    SECONDARY_COL_HASHTAG_PERC: "% of hashtags",
                    "n_users": "Unique users",
                }
            )
        )

        self._hashtag_grid.options["rowData"] = df_display.to_dicts()
        self._hashtag_grid.options["columnDefs"] = [
            {"field": "Hashtag", "sortable": True, "filter": True, "resizable": True},
            {
                "field": "% of hashtags",
                "sortable": True,
                "filter": True,
                "resizable": True,
                ":valueFormatter": "(params) => params.value.toFixed(1) + '%'",
            },
            {
                "field": "Unique users",
                "sortable": True,
                "filter": True,
                "resizable": True,
            },
        ]
        self._hashtag_grid.update()

    def _handle_hashtag_click(self, e) -> None:
        data = e.args
        if not data or "data" not in data:
            return

        row_data = data["data"]
        hashtag = row_data.get("Hashtag")
        if not hashtag:
            return

        if self._selected_hashtag == hashtag:
            return

        self._selected_hashtag = hashtag
        self._selected_user = None

        self._clear_tweet_grid()
        self._update_user_grid()
        self._update_user_info()

    def _update_hashtag_info(self) -> None:
        if self._hashtag_info is None:
            return

        if self._selected_timewindow is None:
            self._hashtag_info.text = (
                "Click a point on the chart above to explore hashtags."
            )
        elif self._df_secondary is None:
            self._hashtag_info.text = "Loading hashtag data..."
        else:
            n_hashtags = len(self._df_secondary)
            date_str = self._selected_timewindow.strftime(DISPLAY_DATE_FORMAT)
            self._hashtag_info.text = (
                f"{n_hashtags} hashtags found in window starting {date_str}"
            )

    def _update_user_grid(self) -> None:
        if (
            self._user_grid is None
            or self._df_secondary is None
            or self._selected_hashtag is None
        ):
            return

        df_users = extract_users_for_hashtag(self._df_secondary, self._selected_hashtag)

        if df_users.is_empty():
            self._clear_user_grid()
            return

        self._df_users = df_users

        self._user_grid.options["rowData"] = df_users.to_dicts()
        self._user_grid.options["columnDefs"] = [
            {"field": "User", "sortable": True, "filter": True, "resizable": True},
            {"field": "Posts", "sortable": True, "filter": True, "resizable": True},
        ]
        self._user_grid.update()

        if self._user_loading is not None and self._user_content is not None:
            self._show_content(self._user_loading, self._user_content)

    def _handle_user_click(self, e) -> None:
        data = e.args
        if not data or "data" not in data:
            return

        row_data = data["data"]
        user = row_data.get("User")
        if not user:
            return

        if self._selected_user == user:
            return

        self._selected_user = user
        ui.timer(0, self._load_tweets, once=True)

    def _update_user_info(self) -> None:
        if self._user_info is None:
            return

        if self._selected_hashtag is None:
            self._user_info.text = "Click a hashtag above to see which users posted it."
        elif self._df_users is not None:
            n_users = len(self._df_users)
            self._user_info.text = f"{n_users} users posted '{self._selected_hashtag}'"
        else:
            self._user_info.text = "Loading user data..."

    def _clear_user_grid(self) -> None:
        self._selected_user = None
        self._df_users = None
        if self._user_grid is not None:
            self._user_grid.options["rowData"] = []
            self._user_grid.options["columnDefs"] = []
            self._user_grid.update()
        self._update_user_info()
        if self._user_loading is not None:
            self._show_error(self._user_loading, "Select a hashtag to see users.")

    async def _load_tweets(self) -> None:
        if (
            self._selected_user is None
            or self._selected_hashtag is None
            or self._selected_timewindow is None
        ):
            return

        if self._tweet_loading is None or self._tweet_content is None:
            return

        await self._load_raw_input()

        if self._df_raw is None:
            self._show_error(self._tweet_loading, "Could not load tweet data.")
            return

        time_step = self._get_time_step()
        if time_step is None:
            self._show_error(
                self._tweet_loading, "Could not determine time window duration."
            )
            return

        timewindow_end = self._selected_timewindow + time_step

        try:
            df_tweets = await run.cpu_bound(
                self._filter_tweets,
                self._df_raw,
                self._selected_user,
                self._selected_hashtag,
                self._selected_timewindow,
                timewindow_end,
            )
        except Exception as exc:
            self._show_error(self._tweet_loading, f"Could not filter tweets: {exc}")
            return

        if df_tweets.is_empty():
            self._show_error(self._tweet_loading, "No tweets found for this selection.")
            return

        self._update_tweet_grid(df_tweets)
        self._show_content(self._tweet_loading, self._tweet_content)
        self._update_tweet_info(count=len(df_tweets))

    @staticmethod
    def _filter_tweets(
        df_raw: pl.DataFrame,
        user: str,
        hashtag: str,
        time_start: datetime,
        time_end: datetime,
    ) -> pl.DataFrame:
        return (
            df_raw.filter(
                pl.col(COL_AUTHOR_ID) == user,
                pl.col(COL_TIME).is_between(time_start, time_end),
                pl.col(COL_POST).str.contains(hashtag, literal=True),
            )
            .with_columns(pl.col(COL_TIME).dt.strftime("%B %d, %Y %I:%M %p"))
            .select([COL_TIME, COL_POST])
            .rename({COL_TIME: "Timestamp", COL_POST: "Post"})
        )

    def _update_tweet_grid(self, df_tweets: pl.DataFrame) -> None:
        if self._tweet_grid is None:
            return

        self._tweet_grid.options["rowData"] = df_tweets.to_dicts()
        self._tweet_grid.options["columnDefs"] = [
            {
                "field": "Timestamp",
                "sortable": True,
                "filter": True,
                "resizable": True,
            },
            {
                "field": "Post",
                "sortable": False,
                "filter": True,
                "resizable": True,
                "wrapText": True,
                "autoHeight": True,
                ":tooltipValueGetter": "(params) => params.value",
            },
        ]
        self._tweet_grid.update()

    def _update_tweet_info(self, count: int) -> None:
        timewindow_end = self._selected_timewindow + self._get_time_step()
        date_end = timewindow_end.strftime(DISPLAY_DATE_FORMAT)
        date_start = self._selected_timewindow.strftime(DISPLAY_DATE_FORMAT)
        if self._tweet_info is None:
            return
        self._tweet_info.text = f"{count} posts found for account {self._selected_user} between {date_start} and {date_end}"

    def _clear_tweet_grid(self) -> None:
        self._selected_user = None
        if self._tweet_grid is not None:
            self._tweet_grid.options["rowData"] = []
            self._tweet_grid.options["columnDefs"] = []
            self._tweet_grid.update()
        if self._tweet_info is not None:
            self._tweet_info.text = "Click a user above to see their posts."
        if self._tweet_loading is not None:
            self._show_error(self._tweet_loading, "Select a user to see their posts.")

    def _get_time_step(self):
        if self._df_primary is None or len(self._df_primary) < 2:
            return None
        return (
            self._df_primary[OUTPUT_COL_TIMESPAN][1]
            - self._df_primary[OUTPUT_COL_TIMESPAN][0]
        )

    def render_content(self) -> None:
        ui.add_css("""
            .ag-row {
                cursor: pointer !important;
            }
            .ag-row-hover {
                background-color: #e3f2fd !important;
            }
            """)
        with ui.row().classes("w-full justify-center"):
            with ui.column().classes("w-3/4 q-pa-md gap-4"):
                with ui.card().classes("w-full"):
                    with ui.row().classes("w-full items-center"):
                        self._smooth_checkbox = ui.checkbox(
                            "Show smoothed line",
                            value=False,
                            on_change=self._handle_smooth_change,
                        )
                    self._gini_loading, self._gini_content = (
                        self._create_loading_container("350px")
                    )
                    with self._gini_content:
                        self._gini_chart = (
                            ui.echart(
                                {},
                                on_point_click=self._handle_gini_click,
                            )
                            .classes("w-full")
                            .style("height: 350px")
                        )

                with ui.row().classes("w-full gap-4"):
                    with ui.card().classes("flex-1"):
                        with ui.card_section():
                            ui.label("Hashtags").classes("text-h6")
                            self._hashtag_info = ui.label(
                                "Click a point on the chart above to explore hashtags."
                            ).classes("text-body2 text-grey-7 q-mb-sm")
                        self._hashtag_loading, self._hashtag_content = (
                            self._create_loading_container("300px")
                        )
                        self._show_error(
                            self._hashtag_loading,
                            "Select a time window to see hashtags.",
                        )
                        with self._hashtag_content:
                            self._hashtag_grid = (
                                ui.aggrid(
                                    {
                                        "columnDefs": [],
                                        "rowData": [],
                                        "defaultColDef": {
                                            "sortable": True,
                                            "filter": True,
                                            "resizable": True,
                                        },
                                    },
                                    theme="quartz",
                                )
                                .classes("w-full")
                                .style("height: 300px")
                            )
                            self._hashtag_grid.on(
                                "cellClicked", self._handle_hashtag_click
                            )

                    with ui.card().classes("flex-1"):
                        with ui.card_section():
                            ui.label("Users").classes("text-h6")
                            self._user_info = ui.label(
                                "Click a hashtag above to see which users posted it."
                            ).classes("text-body2 text-grey-7 q-mb-sm")
                        self._user_loading, self._user_content = (
                            self._create_loading_container("300px")
                        )
                        self._show_error(
                            self._user_loading,
                            "Select a hashtag to see users.",
                        )
                        with self._user_content:
                            self._user_grid = (
                                ui.aggrid(
                                    {
                                        "columnDefs": [],
                                        "rowData": [],
                                        "defaultColDef": {
                                            "sortable": True,
                                            "filter": True,
                                            "resizable": True,
                                        },
                                    },
                                    theme="quartz",
                                )
                                .classes("w-full")
                                .style("height: 300px")
                            )
                            self._user_grid.on("cellClicked", self._handle_user_click)

                with ui.card().classes("w-full"):
                    with ui.card_section():
                        ui.label("Tweet Explorer").classes("text-h6")
                        self._tweet_info = ui.label(
                            "Click a user above to see their posts."
                        ).classes("text-body2 text-grey-7 q-mb-sm")
                    self._tweet_loading, self._tweet_content = (
                        self._create_loading_container("300px")
                    )
                    self._show_error(
                        self._tweet_loading,
                        "Select a user to see their posts.",
                    )
                    with self._tweet_content:
                        self._tweet_grid = (
                            ui.aggrid(
                                {
                                    "columnDefs": [],
                                    "rowData": [],
                                    "defaultColDef": {
                                        "sortable": True,
                                        "filter": True,
                                        "resizable": True,
                                    },
                                    "tooltipShowDelay": 200,
                                    "tooltipSwitchShowDelay": 70,
                                },
                                theme="quartz",
                            )
                            .classes("w-full")
                            .style("height: 300px")
                        )

        ui.timer(0, self._load_and_render_async, once=True)

NgramsDashboardPage

Bases: BaseDashboardPage

Results dashboard for the N-grams (Copy-Pasta Detector) analyzer.

Renders a log-log scatter plot of n-gram frequency versus unique poster count. Each point represents one n-gram; points are coloured by n-gram length.

Interactive features: - Click a point to highlight it and filter the data grid to show all occurrences of that n-gram - Click the same point again to deselect and return to summary view - Click a different point to switch selection

Source code in src/cibmangotree/gui/dashboards/ngrams/dashboard.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
class NgramsDashboardPage(BaseDashboardPage):
    """
    Results dashboard for the N-grams (Copy-Pasta Detector) analyzer.

    Renders a log-log scatter plot of n-gram frequency versus unique poster
    count.  Each point represents one n-gram; points are coloured by n-gram
    length.

    Interactive features:
    - Click a point to highlight it and filter the data grid to show all
      occurrences of that n-gram
    - Click the same point again to deselect and return to summary view
    - Click a different point to switch selection
    """

    _secondary_analyzer_id = ngram_stats_interface.id

    def __init__(self, session: GuiSession):
        super().__init__(session=session)
        self._selected_words: str | None = None
        self._selected_series_index: int | None = None
        self._selected_data_index: int | None = None

        self._filter_text: str | None = None
        self._filter_applied: bool = False
        self._all_ngram_options: list[str] = []

        self._df_stats: pl.DataFrame | None = None
        self._df_full: pl.DataFrame | None = None
        self._df_stats_sampled: pl.DataFrame | None = None
        self._sampling_metadata: SamplingMetadata | None = None

        self._chart: ui.echart | None = None
        self._grid: ui.aggrid | None = None
        self._info_label: ui.label | None = None
        self._ngram_select: ui.input | None = None
        self._chart_loading: ui.column | None = None
        self._chart_content: ui.column | None = None
        self._grid_loading: ui.column | None = None
        self._grid_content: ui.column | None = None
        self._sampling_label: ui.label | None = None
        self._show_all_btn: ui.button | None = None

    def _get_top_n_summary(self, n: int = 100) -> pl.DataFrame:
        df = self._get_filtered_stats()
        if df.is_empty():
            return pl.DataFrame()

        return (
            make_summary_columns(df).sort("Total repetitions", descending=True).head(n)
        )

    def _get_filtered_full_data(self, words: str) -> pl.DataFrame:
        if self._df_full is None or self._df_full.is_empty():
            return pl.DataFrame()

        return self._df_full.filter(pl.col(COL_NGRAM_WORDS) == words).pipe(
            make_detail_columns
        )

    def _update_info_label(self) -> None:
        if self._info_label is None:
            return

        if self._selected_words is not None:
            if self._df_full is not None:
                count = self._df_full.filter(
                    pl.col(COL_NGRAM_WORDS) == self._selected_words
                ).height
            else:
                count = 0
            self._info_label.text = (
                f"N-gram: '{self._selected_words}' — {count:,} total repetitions"
            )
        elif self._filter_text:
            df_filtered = self._get_filtered_stats()
            count = df_filtered.height
            if count == 0:
                self._info_label.text = (
                    f"No n-grams found matching '{self._filter_text}'. "
                    "Try a different search term."
                )
            elif not self._filter_applied:
                self._info_label.text = (
                    f"Filter: '{self._filter_text}' — {count:,} matches found. "
                    "Press Enter to apply filter to chart and grid."
                )
            else:
                self._info_label.text = (
                    f"Showing {min(count, 100):,} of {count:,} n-grams "
                    f"matching '{self._filter_text}'. "
                    "Click a point to view all occurrences."
                )
        else:
            self._info_label.text = (
                "Showing top 100 n-grams by frequency. "
                "Type to search, then press Enter to filter. "
                "Click a point to view all occurrences."
            )

    def _update_grid(self) -> None:
        if self._grid is None:
            return

        if self._selected_words is None:
            df_display = self._get_top_n_summary()
        else:
            df_display = self._get_filtered_full_data(self._selected_words)

        if df_display.is_empty():
            self._grid.options["rowData"] = []
            self._grid.options["columnDefs"] = []
        else:
            self._grid.options["rowData"] = df_display.to_dicts()
            column_defs = []
            for col in df_display.columns:
                col_def = {
                    "field": col,
                    "sortable": True,
                    "filter": True,
                    "resizable": True,
                }
                if col == "Post content":
                    col_def[":tooltipValueGetter"] = "(params) => params.value"
                column_defs.append(col_def)
            self._grid.options["columnDefs"] = column_defs
        self._grid.update()

    def _highlight_point(self, series_index: int, data_index: int) -> None:
        if self._chart is None:
            return
        self._chart.run_chart_method(
            "dispatchAction",
            {"type": "highlight", "seriesIndex": series_index, "dataIndex": data_index},
        )

    def _downplay_point(self, series_index: int, data_index: int) -> None:
        self._chart.run_chart_method(
            "dispatchAction",
            {"type": "downplay", "seriesIndex": series_index, "dataIndex": data_index},
        )

    def _clear_all_highlights(self) -> None:
        self._chart.run_chart_method("dispatchAction", {"type": "downplay"})

    def _handle_point_click(self, e) -> None:
        clicked_words = e.data.get("words")
        if clicked_words is None:
            return

        series_index = e.series_index
        data_index = e.data_index

        if (
            self._selected_series_index is not None
            and self._selected_data_index is not None
        ):
            self._downplay_point(self._selected_series_index, self._selected_data_index)

        if self._selected_words == clicked_words:
            self._selected_words = None
            self._selected_series_index = None
            self._selected_data_index = None
        else:
            self._selected_words = clicked_words
            self._selected_series_index = series_index
            self._selected_data_index = data_index
            self._highlight_point(series_index, data_index)

        self._update_info_label()
        self._update_grid()

    def _handle_filter_change(self, e) -> None:
        self._filter_text = e.value if e.value else None
        self._filter_applied = False
        self._update_info_label()

    def _handle_enter_press(self, e) -> None:
        self._selected_words = None
        self._selected_series_index = None
        self._selected_data_index = None
        self._clear_all_highlights()

        self._filter_applied = True

        self._update_chart_with_filter()
        self._update_grid()
        self._update_info_label()

    def _get_filtered_stats(self) -> pl.DataFrame:
        if self._df_stats is None:
            return pl.DataFrame()

        if not self._filter_text:
            return (
                self._df_stats_sampled
                if self._df_stats_sampled is not None
                else self._df_stats
            )

        return filter_ngrams_by_text(self._df_stats, self._filter_text)

    def _handle_clear(self) -> None:
        self._filter_text = None
        self._filter_applied = False

        self._selected_words = None
        self._selected_series_index = None
        self._selected_data_index = None
        self._clear_all_highlights()

        self._update_chart_with_filter()
        self._update_grid()
        self._update_info_label()

    async def _load_and_render_async(self) -> None:
        stats_path = self.get_output_parquet_path(OUTPUT_NGRAM_STATS)
        full_path = self.get_output_parquet_path(OUTPUT_NGRAM_FULL)

        if stats_path is None:
            if self._chart_loading is not None:
                self._show_error(
                    self._chart_loading, "No analysis found in the current session."
                )
            return

        try:
            self._df_stats = await run.io_bound(pl.read_parquet, stats_path)
            if full_path:
                self._df_full = await run.io_bound(pl.read_parquet, full_path)
        except Exception as exc:
            if self._chart_loading is not None:
                self._show_error(
                    self._chart_loading, f"Could not load n-gram results: {exc}"
                )
            return

        if self._df_stats.is_empty():
            if self._chart_loading is not None:
                self._show_error(self._chart_loading, "No n-gram data available.")
            return

        try:
            self._df_stats_sampled, self._sampling_metadata = await run.cpu_bound(
                sample_ngram_data,
                self._df_stats,
                50000,
            )
            option = await run.cpu_bound(
                plot_scatter_echart,
                self._df_stats_sampled,
                False,
            )
        except Exception as exc:
            if self._chart_loading is not None:
                self._show_error(self._chart_loading, f"Could not build chart: {exc}")
            return

        if self._ngram_select is not None:
            self._all_ngram_options = (
                self._df_stats.select(pl.col(COL_NGRAM_WORDS).unique())
                .sort(COL_NGRAM_WORDS)
                .to_series()
                .to_list()
            )
            self._ngram_select.set_autocomplete(self._all_ngram_options)

        if (
            self._chart is None
            or self._chart_content is None
            or self._chart_loading is None
        ):
            return
        self._chart.options.update(option)
        self._chart.update()
        self._show_content(self._chart_loading, self._chart_content)

        self._update_sampling_info_label()
        self._update_info_label()
        self._update_grid()
        if self._grid_loading is not None and self._grid_content is not None:
            self._show_content(self._grid_loading, self._grid_content)

    def _update_sampling_info_label(self) -> None:
        if self._sampling_label is None or self._sampling_metadata is None:
            return
        self._sampling_label.text = self._sampling_metadata.sampling_message
        if self._show_all_btn is not None:
            self._show_all_btn.set_visibility(self._sampling_metadata.is_sampled)

    async def _handle_show_all_click(self) -> None:
        if self._df_stats is None:
            return

        total = len(self._df_stats)

        if total > 100_000:
            with ui.dialog() as dialog, ui.card():
                ui.label(f"Load all {total:,} data points?").classes("text-h6")
                ui.label(
                    "This may cause the browser to slow down or become unresponsive."
                ).classes("text-body2 text-grey-7")
                with ui.row().classes("gap-4 justify-end"):
                    ui.button("Cancel", on_click=dialog.close).props("flat")

                    async def _confirm():
                        dialog.close()
                        await self._load_full_dataset()

                    ui.button("Load all", on_click=_confirm, color="primary")
            dialog.open()
        else:
            await self._load_full_dataset()

    async def _load_full_dataset(self) -> None:
        if self._show_all_btn is not None:
            self._show_all_btn.set_enabled(False)
        if self._sampling_label is not None:
            self._sampling_label.text = "Loading full dataset..."

        if self._df_stats is None:
            return

        df_stats = self._df_stats

        try:
            option = await run.cpu_bound(
                plot_scatter_echart,
                df_stats,
                False,
            )
        except Exception as exc:
            self.notify_error(f"Could not load full dataset: {exc}")
            if self._show_all_btn is not None:
                self._show_all_btn.set_enabled(True)
            return

        self._df_stats_sampled = df_stats
        self._sampling_metadata = SamplingMetadata(
            total_count=len(df_stats),
            sampled_count=len(df_stats),
            is_sampled=False,
            strategy="none",
        )

        if self._chart is not None:
            self._chart.options.update(option)
            self._chart.update()
        self._update_sampling_info_label()

    def _update_chart_with_filter(self) -> None:
        if self._chart is None:
            return

        df_filtered = self._get_filtered_stats()

        if df_filtered.is_empty():
            self._chart.options.clear()
            self._chart.update()
            return

        option = plot_scatter_echart(df_filtered, enable_large_mode=False)
        self._chart.options.update(option)
        self._chart.update()

    def render_content(self) -> None:
        ui.add_css("""
            .ag-tooltip {
                white-space: normal !important;
                max-width: 450px !important;
                word-wrap: break-word !important;
            }
        """)
        with ui.row().classes("w-full justify-center"):
            with ui.column().classes("w-3/4 q-pa-md gap-4"):
                with ui.card().classes("w-full"):
                    self._ngram_select = (
                        ui.input(
                            autocomplete=[],
                            label="Search N-gram",
                            on_change=self._handle_filter_change,
                        )
                        .props('clearable autocomplete="off"')
                        .classes("w-1/4")
                        .on("keydown.enter", self._handle_enter_press)
                        .on("clear", self._handle_clear)
                    )

                    self._chart_loading, self._chart_content = (
                        self._create_loading_container("500px")
                    )
                    with self._chart_content:
                        self._chart = (
                            ui.echart({}, on_point_click=self._handle_point_click)
                            .classes("w-full")
                            .style("height: 500px")
                        )

                with ui.row().classes("w-full items-center gap-4"):
                    self._sampling_label = ui.label("").classes(
                        "text-body2 text-grey-7"
                    )
                    self._show_all_btn = ui.button(
                        "Show all data",
                        on_click=self._handle_show_all_click,
                        color="secondary",
                    ).props("outline dense")
                    self._show_all_btn.set_visibility(False)

                with ui.card().classes("w-full"):
                    with ui.card_section():
                        ui.label("Data viewer").classes("text-h6")
                    self._info_label = ui.label("Loading data...").classes(
                        "text-body2 text-grey-7 q-mb-sm"
                    )
                    self._grid_loading, self._grid_content = (
                        self._create_loading_container("400px")
                    )
                    with self._grid_content:
                        self._grid = (
                            ui.aggrid(
                                {
                                    "columnDefs": [],
                                    "rowData": [],
                                    "defaultColDef": {
                                        "sortable": True,
                                        "filter": True,
                                        "resizable": True,
                                    },
                                    "tooltipShowDelay": 200,
                                    "tooltipSwitchShowDelay": 70,
                                },
                                theme="quartz",
                            )
                            .classes("w-full")
                            .style("height: 400px")
                        )

        ui.timer(0, self._load_and_render_async, once=True)

PlaceholderDashboard

Bases: BaseDashboardPage

Fallback page shown when the selected analyzer has no dashboard yet.

Source code in src/cibmangotree/gui/dashboards/placeholder/dashboard.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class PlaceholderDashboard(BaseDashboardPage):
    """Fallback page shown when the selected analyzer has no dashboard yet."""

    def __init__(self, session: GuiSession):
        super().__init__(session=session)

    def render_content(self) -> None:
        with (
            ui.column()
            .classes("items-center justify-center")
            .style("height: 80vh; width: 100%")
        ):
            ui.icon("bar_chart", size="4rem").classes("text-grey-5")
            ui.label("Dashboard coming soon").classes("text-h6 text-grey-6 q-mt-md")
            ui.label(
                "A results dashboard for this analyzer is not yet available."
            ).classes("text-grey-5")

base_dashboard

Base class for all analyzer dashboard pages.

Provides a standard layout shell for dashboards: - Header inherited from GuiPage (with back-to-results navigation) - A full-width content area for charts and tables - Footer inherited from GuiPage

All analyzer-specific dashboard pages should subclass BaseDashboardPage and implement render_content().

Classes:

Name Description
BaseDashboardPage

Abstract base class for all analyzer dashboard pages.

BaseDashboardPage

Bases: GuiPage, ABC

Abstract base class for all analyzer dashboard pages.

Extends GuiPage with dashboard-specific defaults: - Back button navigates to the post-analysis page - Title is derived from the selected analyzer name - Footer is shown

Subclasses must set _secondary_analyzer_id to enable get_output_parquet_path() for loading analysis results.

Subclasses implement render_content() to provide the actual charts, tables, and interactive controls for each analyzer.

Usage
class NgramsDashboardPage(BaseDashboardPage):
    _secondary_analyzer_id = ngram_stats_interface.id

    def render_content(self) -> None:
        ui.label("N-grams dashboard content here")

Methods:

Name Description
get_output_parquet_path

Get path to a secondary output parquet file for the current analysis.

load_parquet_async

Load a parquet file asynchronously with loading/error handling.

render_content

Render dashboard-specific charts, tables, and controls.

Source code in src/cibmangotree/gui/dashboards/base_dashboard.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
class BaseDashboardPage(GuiPage, abc.ABC):
    """
    Abstract base class for all analyzer dashboard pages.

    Extends GuiPage with dashboard-specific defaults:
    - Back button navigates to the post-analysis page
    - Title is derived from the selected analyzer name
    - Footer is shown

    Subclasses must set _secondary_analyzer_id to enable
    get_output_parquet_path() for loading analysis results.

    Subclasses implement render_content() to provide the actual
    charts, tables, and interactive controls for each analyzer.

    Usage:
        ```python
        class NgramsDashboardPage(BaseDashboardPage):
            _secondary_analyzer_id = ngram_stats_interface.id

            def render_content(self) -> None:
                ui.label("N-grams dashboard content here")
        ```
    """

    _secondary_analyzer_id: str | None = None

    def __init__(self, session: GuiSession):
        analyzer_name = (
            session.selected_analyzer_name
            if session.selected_analyzer_name
            else "Results Dashboard"
        )
        super().__init__(
            session=session,
            route=gui_routes.dashboard,
            title=f"{analyzer_name}: Results Dashboard",
            show_back_button=True,
            back_route=gui_routes.post_analysis,
            show_footer=True,
        )

    def get_output_parquet_path(self, output_id: str) -> str | None:
        """Get path to a secondary output parquet file for the current analysis."""
        analysis = self.session.current_analysis
        if analysis is None or self._secondary_analyzer_id is None:
            return None
        storage = self.session.app.context.storage
        try:
            return storage.get_secondary_output_parquet_path(
                analysis,
                self._secondary_analyzer_id,
                output_id,
            )
        except Exception:
            return None

    async def load_parquet_async(
        self,
        output_id: str,
        on_success: Callable[["pl.DataFrame"], None],
        loading_container: ui.column | None = None,
        content_container: ui.column | None = None,
    ) -> None:
        """
        Load a parquet file asynchronously with loading/error handling.

        Args:
            output_id: Secondary output identifier to locate the parquet file.
            on_success: Callback invoked with the loaded DataFrame on success.
            loading_container: Container to show error in if loading fails.
            content_container: Paired content container to reveal on success.
        """
        import polars as pl

        path = self.get_output_parquet_path(output_id)
        if path is None:
            if loading_container is not None:
                self._show_error(loading_container, "No analysis data found.")
            return
        try:
            df = await run.io_bound(pl.read_parquet, path)
            if df.is_empty():
                if loading_container is not None:
                    self._show_error(loading_container, "No data available.")
                return
            on_success(df)
            if loading_container is not None and content_container is not None:
                self._show_content(loading_container, content_container)
        except Exception as exc:
            if loading_container is not None:
                self._show_error(loading_container, f"Could not load data: {exc}")

    def _create_loading_container(
        self, height: str = "500px"
    ) -> tuple[ui.column, ui.column]:
        """
        Create a loading spinner container and a (hidden) content container.

        Subclasses call this inside render_content() and later call
        _show_content() / _show_error() to switch states.

        Returns:
            (loading_container, content_container)
        """
        loading_container = (
            ui.column()
            .classes("w-full items-center justify-center")
            .style(f"height: {height};")
        )
        with loading_container:
            ui.spinner("pie", size="xl")
            ui.label("Loading dashboard...").classes("text-grey-6 q-mt-md")

        content_container = (
            ui.column().classes("w-full").style(f"height: {height}; display: none;")
        )

        return loading_container, content_container

    def _show_content(self, loading_container: ui.column, content_container: ui.column):
        """Switch from loading state to content display."""
        loading_container.style("display: none;")
        content_container.style("display: block;")

    def _show_error(
        self,
        loading_container: ui.column,
        message: str,
    ) -> None:
        """Replace loading spinner with an error message in-place."""
        loading_container.clear()
        with loading_container:
            ui.icon("error_outline", size="3rem").classes("text-negative")
            ui.label(message).classes("text-negative q-mt-md")

    @abc.abstractmethod
    def render_content(self) -> None:
        """Render dashboard-specific charts, tables, and controls."""
        raise NotImplementedError
get_output_parquet_path(output_id)

Get path to a secondary output parquet file for the current analysis.

Source code in src/cibmangotree/gui/dashboards/base_dashboard.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def get_output_parquet_path(self, output_id: str) -> str | None:
    """Get path to a secondary output parquet file for the current analysis."""
    analysis = self.session.current_analysis
    if analysis is None or self._secondary_analyzer_id is None:
        return None
    storage = self.session.app.context.storage
    try:
        return storage.get_secondary_output_parquet_path(
            analysis,
            self._secondary_analyzer_id,
            output_id,
        )
    except Exception:
        return None
load_parquet_async(output_id, on_success, loading_container=None, content_container=None) async

Load a parquet file asynchronously with loading/error handling.

Parameters:

Name Type Description Default
output_id
str

Secondary output identifier to locate the parquet file.

required
on_success
Callable[[DataFrame], None]

Callback invoked with the loaded DataFrame on success.

required
loading_container
column | None

Container to show error in if loading fails.

None
content_container
column | None

Paired content container to reveal on success.

None
Source code in src/cibmangotree/gui/dashboards/base_dashboard.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
async def load_parquet_async(
    self,
    output_id: str,
    on_success: Callable[["pl.DataFrame"], None],
    loading_container: ui.column | None = None,
    content_container: ui.column | None = None,
) -> None:
    """
    Load a parquet file asynchronously with loading/error handling.

    Args:
        output_id: Secondary output identifier to locate the parquet file.
        on_success: Callback invoked with the loaded DataFrame on success.
        loading_container: Container to show error in if loading fails.
        content_container: Paired content container to reveal on success.
    """
    import polars as pl

    path = self.get_output_parquet_path(output_id)
    if path is None:
        if loading_container is not None:
            self._show_error(loading_container, "No analysis data found.")
        return
    try:
        df = await run.io_bound(pl.read_parquet, path)
        if df.is_empty():
            if loading_container is not None:
                self._show_error(loading_container, "No data available.")
            return
        on_success(df)
        if loading_container is not None and content_container is not None:
            self._show_content(loading_container, content_container)
    except Exception as exc:
        if loading_container is not None:
            self._show_error(loading_container, f"Could not load data: {exc}")
render_content() abstractmethod

Render dashboard-specific charts, tables, and controls.

Source code in src/cibmangotree/gui/dashboards/base_dashboard.py
163
164
165
166
@abc.abstractmethod
def render_content(self) -> None:
    """Render dashboard-specific charts, tables, and controls."""
    raise NotImplementedError

get_dashboard(analyzer_id)

Look up a registered dashboard class by analyzer ID.

Source code in src/cibmangotree/gui/dashboards/__init__.py
26
27
28
def get_dashboard(analyzer_id: str | None) -> type[BaseDashboardPage] | None:
    """Look up a registered dashboard class by analyzer ID."""
    return _DASHBOARD_REGISTRY.get(analyzer_id) if analyzer_id else None

hashtags

Modules:

Name Description
dashboard

Hashtags analyzer dashboard page.

data

Thin data loading wrappers for the hashtags analyzer.

plots

Framework-agnostic ECharts figure builders for the hashtags analyzer.

Classes:

Name Description
HashtagsDashboardPage

Hashtags dashboard with ranked lists instead of bar charts.

HashtagsDashboardPage

Bases: BaseDashboardPage

Hashtags dashboard with ranked lists instead of bar charts.

Dependency chain: Gini plot click -> sets timewindow -> populates hashtag list Hashtag row click -> populates user list User row click -> populates tweet explorer

Source code in src/cibmangotree/gui/dashboards/hashtags/dashboard.py
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
class HashtagsDashboardPage(BaseDashboardPage):
    """
    Hashtags dashboard with ranked lists instead of bar charts.

    Dependency chain:
    Gini plot click -> sets timewindow -> populates hashtag list
    Hashtag row click -> populates user list
    User row click -> populates tweet explorer
    """

    def __init__(self, session: GuiSession):
        super().__init__(session=session)

        self._df_primary: pl.DataFrame | None = None
        self._df_raw: pl.DataFrame | None = None
        self._df_secondary: pl.DataFrame | None = None
        self._df_users: pl.DataFrame | None = None
        self._smooth: bool = False

        self._selected_timewindow: datetime | None = None
        self._selected_hashtag: str | None = None
        self._selected_user: str | None = None
        self._selected_gini_data_index: int | None = None

        self._gini_chart: ui.echart | None = None
        self._gini_loading: ui.column | None = None
        self._gini_content: ui.column | None = None
        self._smooth_checkbox: ui.checkbox | None = None

        self._hashtag_grid: ui.aggrid | None = None
        self._hashtag_loading: ui.column | None = None
        self._hashtag_content: ui.column | None = None
        self._hashtag_info: ui.label | None = None

        self._user_grid: ui.aggrid | None = None
        self._user_loading: ui.column | None = None
        self._user_content: ui.column | None = None
        self._user_info: ui.label | None = None

        self._tweet_grid: ui.aggrid | None = None
        self._tweet_loading: ui.column | None = None
        self._tweet_content: ui.column | None = None
        self._tweet_info: ui.label | None = None

    async def _load_and_render_async(self) -> None:
        try:
            self._df_primary = await run.io_bound(load_primary_output, self.session)
        except Exception as exc:
            if self._gini_loading is not None:
                self._show_error(
                    self._gini_loading, f"Could not load hashtag analysis: {exc}"
                )
            return

        if self._df_primary.is_empty():
            if self._gini_loading is not None:
                self._show_error(self._gini_loading, "No hashtag data available.")
            return

        try:
            option = await run.cpu_bound(
                plot_gini_echart,
                self._df_primary,
                self._smooth,
            )
        except Exception as exc:
            if self._gini_loading is not None:
                self._show_error(self._gini_loading, f"Could not build chart: {exc}")
            return

        if (
            self._gini_chart is None
            or self._gini_content is None
            or self._gini_loading is None
        ):
            return

        self._gini_chart.options.update(option)
        self._gini_chart.update()
        self._show_content(self._gini_loading, self._gini_content)

    async def _load_raw_input(self) -> None:
        if self._df_raw is not None:
            return
        try:
            self._df_raw = await run.io_bound(load_transformed_raw_input, self.session)
        except Exception as exc:
            self.notify_error(f"Could not load raw input data: {exc}")

    def _handle_smooth_change(self, e) -> None:
        self._smooth = e.value
        self._selected_gini_data_index = None
        if self._gini_chart is not None and self._df_primary is not None:
            option = plot_gini_echart(self._df_primary, smooth=self._smooth)
            self._gini_chart.options.update(option)
            self._gini_chart.update()

    def _get_raw_data_index(self, raw_ts: str) -> int | None:
        if self._df_primary is None:
            return None
        ts_list = self._df_primary[OUTPUT_COL_TIMESPAN].to_list()
        for i, ts in enumerate(ts_list):
            if ts.strftime(PRIMARY_OUTPUT_DATETIME_FORMAT) == raw_ts:
                return i
        return None

    def _draw_vertical_marker(self, raw_ts: str) -> None:
        if self._gini_chart is None:
            return
        self._gini_chart.run_chart_method(
            "setOption",
            {
                "series": [
                    {
                        "markLine": {
                            "silent": True,
                            "symbol": "none",
                            "animation": False,
                            "lineStyle": {"color": "#d62728", "width": 2},
                            "label": {
                                "position": "end",
                                "distance": 10,
                            },
                            "data": [{"xAxis": raw_ts}],
                        }
                    }
                ]
            },
            False,
        )

    def _clear_vertical_marker(self) -> None:
        if self._gini_chart is None:
            return
        self._gini_chart.run_chart_method(
            "setOption",
            {"series": [{"markLine": {"data": []}}]},
            False,
        )

    def _handle_gini_click(self, e) -> None:
        clicked_data = e.data
        if clicked_data is None:
            return

        raw_ts = clicked_data.get("raw_ts")
        if raw_ts is None:
            return

        try:
            timewindow = datetime.strptime(raw_ts, PRIMARY_OUTPUT_DATETIME_FORMAT)
        except ValueError:
            return

        data_index = self._get_raw_data_index(raw_ts)
        if data_index is None:
            return

        if self._selected_timewindow == timewindow:
            if self._selected_gini_data_index is not None:
                self._clear_vertical_marker()
            self._selected_timewindow = None
            self._selected_gini_data_index = None
            self._selected_hashtag = None
            self._selected_user = None
            self._update_hashtag_info()
            self._clear_user_grid()
            self._clear_tweet_grid()
            return

        if self._selected_gini_data_index is not None:
            self._clear_vertical_marker()

        self._selected_timewindow = timewindow
        self._selected_gini_data_index = data_index
        self._draw_vertical_marker(raw_ts)
        self._selected_hashtag = None
        self._selected_user = None

        self._update_hashtag_info()
        self._clear_user_grid()
        self._clear_tweet_grid()

        ui.timer(0, self._run_secondary_analysis, once=True)

    async def _run_secondary_analysis(self) -> None:
        if self._df_primary is None or self._selected_timewindow is None:
            return

        if self._hashtag_loading is None or self._hashtag_content is None:
            return

        try:
            df_secondary = await run.cpu_bound(
                secondary_analyzer,
                self._df_primary,
                self._selected_timewindow,
            )
        except Exception as exc:
            self._show_error(
                self._hashtag_loading, f"Could not analyze time window: {exc}"
            )
            return

        if df_secondary.is_empty():
            self._show_error(
                self._hashtag_loading, "No hashtag data for this time window."
            )
            return

        self._df_secondary = df_secondary
        self._update_hashtag_grid()
        self._show_content(self._hashtag_loading, self._hashtag_content)
        self._update_hashtag_info()

    def _update_hashtag_grid(self) -> None:
        if self._hashtag_grid is None or self._df_secondary is None:
            return

        df_display = (
            self._df_secondary.select(
                [
                    OUTPUT_COL_HASHTAGS,
                    SECONDARY_COL_HASHTAG_PERC,
                    SECONDARY_COL_USERS_ALL,
                ]
            )
            .with_columns(
                n_users=pl.col(SECONDARY_COL_USERS_ALL).list.len(),
            )
            .sort(SECONDARY_COL_HASHTAG_PERC, descending=True)
            .rename(
                {
                    OUTPUT_COL_HASHTAGS: "Hashtag",
                    SECONDARY_COL_HASHTAG_PERC: "% of hashtags",
                    "n_users": "Unique users",
                }
            )
        )

        self._hashtag_grid.options["rowData"] = df_display.to_dicts()
        self._hashtag_grid.options["columnDefs"] = [
            {"field": "Hashtag", "sortable": True, "filter": True, "resizable": True},
            {
                "field": "% of hashtags",
                "sortable": True,
                "filter": True,
                "resizable": True,
                ":valueFormatter": "(params) => params.value.toFixed(1) + '%'",
            },
            {
                "field": "Unique users",
                "sortable": True,
                "filter": True,
                "resizable": True,
            },
        ]
        self._hashtag_grid.update()

    def _handle_hashtag_click(self, e) -> None:
        data = e.args
        if not data or "data" not in data:
            return

        row_data = data["data"]
        hashtag = row_data.get("Hashtag")
        if not hashtag:
            return

        if self._selected_hashtag == hashtag:
            return

        self._selected_hashtag = hashtag
        self._selected_user = None

        self._clear_tweet_grid()
        self._update_user_grid()
        self._update_user_info()

    def _update_hashtag_info(self) -> None:
        if self._hashtag_info is None:
            return

        if self._selected_timewindow is None:
            self._hashtag_info.text = (
                "Click a point on the chart above to explore hashtags."
            )
        elif self._df_secondary is None:
            self._hashtag_info.text = "Loading hashtag data..."
        else:
            n_hashtags = len(self._df_secondary)
            date_str = self._selected_timewindow.strftime(DISPLAY_DATE_FORMAT)
            self._hashtag_info.text = (
                f"{n_hashtags} hashtags found in window starting {date_str}"
            )

    def _update_user_grid(self) -> None:
        if (
            self._user_grid is None
            or self._df_secondary is None
            or self._selected_hashtag is None
        ):
            return

        df_users = extract_users_for_hashtag(self._df_secondary, self._selected_hashtag)

        if df_users.is_empty():
            self._clear_user_grid()
            return

        self._df_users = df_users

        self._user_grid.options["rowData"] = df_users.to_dicts()
        self._user_grid.options["columnDefs"] = [
            {"field": "User", "sortable": True, "filter": True, "resizable": True},
            {"field": "Posts", "sortable": True, "filter": True, "resizable": True},
        ]
        self._user_grid.update()

        if self._user_loading is not None and self._user_content is not None:
            self._show_content(self._user_loading, self._user_content)

    def _handle_user_click(self, e) -> None:
        data = e.args
        if not data or "data" not in data:
            return

        row_data = data["data"]
        user = row_data.get("User")
        if not user:
            return

        if self._selected_user == user:
            return

        self._selected_user = user
        ui.timer(0, self._load_tweets, once=True)

    def _update_user_info(self) -> None:
        if self._user_info is None:
            return

        if self._selected_hashtag is None:
            self._user_info.text = "Click a hashtag above to see which users posted it."
        elif self._df_users is not None:
            n_users = len(self._df_users)
            self._user_info.text = f"{n_users} users posted '{self._selected_hashtag}'"
        else:
            self._user_info.text = "Loading user data..."

    def _clear_user_grid(self) -> None:
        self._selected_user = None
        self._df_users = None
        if self._user_grid is not None:
            self._user_grid.options["rowData"] = []
            self._user_grid.options["columnDefs"] = []
            self._user_grid.update()
        self._update_user_info()
        if self._user_loading is not None:
            self._show_error(self._user_loading, "Select a hashtag to see users.")

    async def _load_tweets(self) -> None:
        if (
            self._selected_user is None
            or self._selected_hashtag is None
            or self._selected_timewindow is None
        ):
            return

        if self._tweet_loading is None or self._tweet_content is None:
            return

        await self._load_raw_input()

        if self._df_raw is None:
            self._show_error(self._tweet_loading, "Could not load tweet data.")
            return

        time_step = self._get_time_step()
        if time_step is None:
            self._show_error(
                self._tweet_loading, "Could not determine time window duration."
            )
            return

        timewindow_end = self._selected_timewindow + time_step

        try:
            df_tweets = await run.cpu_bound(
                self._filter_tweets,
                self._df_raw,
                self._selected_user,
                self._selected_hashtag,
                self._selected_timewindow,
                timewindow_end,
            )
        except Exception as exc:
            self._show_error(self._tweet_loading, f"Could not filter tweets: {exc}")
            return

        if df_tweets.is_empty():
            self._show_error(self._tweet_loading, "No tweets found for this selection.")
            return

        self._update_tweet_grid(df_tweets)
        self._show_content(self._tweet_loading, self._tweet_content)
        self._update_tweet_info(count=len(df_tweets))

    @staticmethod
    def _filter_tweets(
        df_raw: pl.DataFrame,
        user: str,
        hashtag: str,
        time_start: datetime,
        time_end: datetime,
    ) -> pl.DataFrame:
        return (
            df_raw.filter(
                pl.col(COL_AUTHOR_ID) == user,
                pl.col(COL_TIME).is_between(time_start, time_end),
                pl.col(COL_POST).str.contains(hashtag, literal=True),
            )
            .with_columns(pl.col(COL_TIME).dt.strftime("%B %d, %Y %I:%M %p"))
            .select([COL_TIME, COL_POST])
            .rename({COL_TIME: "Timestamp", COL_POST: "Post"})
        )

    def _update_tweet_grid(self, df_tweets: pl.DataFrame) -> None:
        if self._tweet_grid is None:
            return

        self._tweet_grid.options["rowData"] = df_tweets.to_dicts()
        self._tweet_grid.options["columnDefs"] = [
            {
                "field": "Timestamp",
                "sortable": True,
                "filter": True,
                "resizable": True,
            },
            {
                "field": "Post",
                "sortable": False,
                "filter": True,
                "resizable": True,
                "wrapText": True,
                "autoHeight": True,
                ":tooltipValueGetter": "(params) => params.value",
            },
        ]
        self._tweet_grid.update()

    def _update_tweet_info(self, count: int) -> None:
        timewindow_end = self._selected_timewindow + self._get_time_step()
        date_end = timewindow_end.strftime(DISPLAY_DATE_FORMAT)
        date_start = self._selected_timewindow.strftime(DISPLAY_DATE_FORMAT)
        if self._tweet_info is None:
            return
        self._tweet_info.text = f"{count} posts found for account {self._selected_user} between {date_start} and {date_end}"

    def _clear_tweet_grid(self) -> None:
        self._selected_user = None
        if self._tweet_grid is not None:
            self._tweet_grid.options["rowData"] = []
            self._tweet_grid.options["columnDefs"] = []
            self._tweet_grid.update()
        if self._tweet_info is not None:
            self._tweet_info.text = "Click a user above to see their posts."
        if self._tweet_loading is not None:
            self._show_error(self._tweet_loading, "Select a user to see their posts.")

    def _get_time_step(self):
        if self._df_primary is None or len(self._df_primary) < 2:
            return None
        return (
            self._df_primary[OUTPUT_COL_TIMESPAN][1]
            - self._df_primary[OUTPUT_COL_TIMESPAN][0]
        )

    def render_content(self) -> None:
        ui.add_css("""
            .ag-row {
                cursor: pointer !important;
            }
            .ag-row-hover {
                background-color: #e3f2fd !important;
            }
            """)
        with ui.row().classes("w-full justify-center"):
            with ui.column().classes("w-3/4 q-pa-md gap-4"):
                with ui.card().classes("w-full"):
                    with ui.row().classes("w-full items-center"):
                        self._smooth_checkbox = ui.checkbox(
                            "Show smoothed line",
                            value=False,
                            on_change=self._handle_smooth_change,
                        )
                    self._gini_loading, self._gini_content = (
                        self._create_loading_container("350px")
                    )
                    with self._gini_content:
                        self._gini_chart = (
                            ui.echart(
                                {},
                                on_point_click=self._handle_gini_click,
                            )
                            .classes("w-full")
                            .style("height: 350px")
                        )

                with ui.row().classes("w-full gap-4"):
                    with ui.card().classes("flex-1"):
                        with ui.card_section():
                            ui.label("Hashtags").classes("text-h6")
                            self._hashtag_info = ui.label(
                                "Click a point on the chart above to explore hashtags."
                            ).classes("text-body2 text-grey-7 q-mb-sm")
                        self._hashtag_loading, self._hashtag_content = (
                            self._create_loading_container("300px")
                        )
                        self._show_error(
                            self._hashtag_loading,
                            "Select a time window to see hashtags.",
                        )
                        with self._hashtag_content:
                            self._hashtag_grid = (
                                ui.aggrid(
                                    {
                                        "columnDefs": [],
                                        "rowData": [],
                                        "defaultColDef": {
                                            "sortable": True,
                                            "filter": True,
                                            "resizable": True,
                                        },
                                    },
                                    theme="quartz",
                                )
                                .classes("w-full")
                                .style("height: 300px")
                            )
                            self._hashtag_grid.on(
                                "cellClicked", self._handle_hashtag_click
                            )

                    with ui.card().classes("flex-1"):
                        with ui.card_section():
                            ui.label("Users").classes("text-h6")
                            self._user_info = ui.label(
                                "Click a hashtag above to see which users posted it."
                            ).classes("text-body2 text-grey-7 q-mb-sm")
                        self._user_loading, self._user_content = (
                            self._create_loading_container("300px")
                        )
                        self._show_error(
                            self._user_loading,
                            "Select a hashtag to see users.",
                        )
                        with self._user_content:
                            self._user_grid = (
                                ui.aggrid(
                                    {
                                        "columnDefs": [],
                                        "rowData": [],
                                        "defaultColDef": {
                                            "sortable": True,
                                            "filter": True,
                                            "resizable": True,
                                        },
                                    },
                                    theme="quartz",
                                )
                                .classes("w-full")
                                .style("height: 300px")
                            )
                            self._user_grid.on("cellClicked", self._handle_user_click)

                with ui.card().classes("w-full"):
                    with ui.card_section():
                        ui.label("Tweet Explorer").classes("text-h6")
                        self._tweet_info = ui.label(
                            "Click a user above to see their posts."
                        ).classes("text-body2 text-grey-7 q-mb-sm")
                    self._tweet_loading, self._tweet_content = (
                        self._create_loading_container("300px")
                    )
                    self._show_error(
                        self._tweet_loading,
                        "Select a user to see their posts.",
                    )
                    with self._tweet_content:
                        self._tweet_grid = (
                            ui.aggrid(
                                {
                                    "columnDefs": [],
                                    "rowData": [],
                                    "defaultColDef": {
                                        "sortable": True,
                                        "filter": True,
                                        "resizable": True,
                                    },
                                    "tooltipShowDelay": 200,
                                    "tooltipSwitchShowDelay": 70,
                                },
                                theme="quartz",
                            )
                            .classes("w-full")
                            .style("height: 300px")
                        )

        ui.timer(0, self._load_and_render_async, once=True)

dashboard

Hashtags analyzer dashboard page.

Layout: - Top: Gini line plot (full width) — click to select time window - Middle-left: Hashtag ranked list (AG-Grid) — click row to select hashtag - Middle-right: User ranked list (AG-Grid) — click row to select user - Bottom: Tweet Explorer (AG-Grid) — shows tweets for selected user/hashtag/window

Classes:

Name Description
HashtagsDashboardPage

Hashtags dashboard with ranked lists instead of bar charts.

HashtagsDashboardPage

Bases: BaseDashboardPage

Hashtags dashboard with ranked lists instead of bar charts.

Dependency chain: Gini plot click -> sets timewindow -> populates hashtag list Hashtag row click -> populates user list User row click -> populates tweet explorer

Source code in src/cibmangotree/gui/dashboards/hashtags/dashboard.py
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
class HashtagsDashboardPage(BaseDashboardPage):
    """
    Hashtags dashboard with ranked lists instead of bar charts.

    Dependency chain:
    Gini plot click -> sets timewindow -> populates hashtag list
    Hashtag row click -> populates user list
    User row click -> populates tweet explorer
    """

    def __init__(self, session: GuiSession):
        super().__init__(session=session)

        self._df_primary: pl.DataFrame | None = None
        self._df_raw: pl.DataFrame | None = None
        self._df_secondary: pl.DataFrame | None = None
        self._df_users: pl.DataFrame | None = None
        self._smooth: bool = False

        self._selected_timewindow: datetime | None = None
        self._selected_hashtag: str | None = None
        self._selected_user: str | None = None
        self._selected_gini_data_index: int | None = None

        self._gini_chart: ui.echart | None = None
        self._gini_loading: ui.column | None = None
        self._gini_content: ui.column | None = None
        self._smooth_checkbox: ui.checkbox | None = None

        self._hashtag_grid: ui.aggrid | None = None
        self._hashtag_loading: ui.column | None = None
        self._hashtag_content: ui.column | None = None
        self._hashtag_info: ui.label | None = None

        self._user_grid: ui.aggrid | None = None
        self._user_loading: ui.column | None = None
        self._user_content: ui.column | None = None
        self._user_info: ui.label | None = None

        self._tweet_grid: ui.aggrid | None = None
        self._tweet_loading: ui.column | None = None
        self._tweet_content: ui.column | None = None
        self._tweet_info: ui.label | None = None

    async def _load_and_render_async(self) -> None:
        try:
            self._df_primary = await run.io_bound(load_primary_output, self.session)
        except Exception as exc:
            if self._gini_loading is not None:
                self._show_error(
                    self._gini_loading, f"Could not load hashtag analysis: {exc}"
                )
            return

        if self._df_primary.is_empty():
            if self._gini_loading is not None:
                self._show_error(self._gini_loading, "No hashtag data available.")
            return

        try:
            option = await run.cpu_bound(
                plot_gini_echart,
                self._df_primary,
                self._smooth,
            )
        except Exception as exc:
            if self._gini_loading is not None:
                self._show_error(self._gini_loading, f"Could not build chart: {exc}")
            return

        if (
            self._gini_chart is None
            or self._gini_content is None
            or self._gini_loading is None
        ):
            return

        self._gini_chart.options.update(option)
        self._gini_chart.update()
        self._show_content(self._gini_loading, self._gini_content)

    async def _load_raw_input(self) -> None:
        if self._df_raw is not None:
            return
        try:
            self._df_raw = await run.io_bound(load_transformed_raw_input, self.session)
        except Exception as exc:
            self.notify_error(f"Could not load raw input data: {exc}")

    def _handle_smooth_change(self, e) -> None:
        self._smooth = e.value
        self._selected_gini_data_index = None
        if self._gini_chart is not None and self._df_primary is not None:
            option = plot_gini_echart(self._df_primary, smooth=self._smooth)
            self._gini_chart.options.update(option)
            self._gini_chart.update()

    def _get_raw_data_index(self, raw_ts: str) -> int | None:
        if self._df_primary is None:
            return None
        ts_list = self._df_primary[OUTPUT_COL_TIMESPAN].to_list()
        for i, ts in enumerate(ts_list):
            if ts.strftime(PRIMARY_OUTPUT_DATETIME_FORMAT) == raw_ts:
                return i
        return None

    def _draw_vertical_marker(self, raw_ts: str) -> None:
        if self._gini_chart is None:
            return
        self._gini_chart.run_chart_method(
            "setOption",
            {
                "series": [
                    {
                        "markLine": {
                            "silent": True,
                            "symbol": "none",
                            "animation": False,
                            "lineStyle": {"color": "#d62728", "width": 2},
                            "label": {
                                "position": "end",
                                "distance": 10,
                            },
                            "data": [{"xAxis": raw_ts}],
                        }
                    }
                ]
            },
            False,
        )

    def _clear_vertical_marker(self) -> None:
        if self._gini_chart is None:
            return
        self._gini_chart.run_chart_method(
            "setOption",
            {"series": [{"markLine": {"data": []}}]},
            False,
        )

    def _handle_gini_click(self, e) -> None:
        clicked_data = e.data
        if clicked_data is None:
            return

        raw_ts = clicked_data.get("raw_ts")
        if raw_ts is None:
            return

        try:
            timewindow = datetime.strptime(raw_ts, PRIMARY_OUTPUT_DATETIME_FORMAT)
        except ValueError:
            return

        data_index = self._get_raw_data_index(raw_ts)
        if data_index is None:
            return

        if self._selected_timewindow == timewindow:
            if self._selected_gini_data_index is not None:
                self._clear_vertical_marker()
            self._selected_timewindow = None
            self._selected_gini_data_index = None
            self._selected_hashtag = None
            self._selected_user = None
            self._update_hashtag_info()
            self._clear_user_grid()
            self._clear_tweet_grid()
            return

        if self._selected_gini_data_index is not None:
            self._clear_vertical_marker()

        self._selected_timewindow = timewindow
        self._selected_gini_data_index = data_index
        self._draw_vertical_marker(raw_ts)
        self._selected_hashtag = None
        self._selected_user = None

        self._update_hashtag_info()
        self._clear_user_grid()
        self._clear_tweet_grid()

        ui.timer(0, self._run_secondary_analysis, once=True)

    async def _run_secondary_analysis(self) -> None:
        if self._df_primary is None or self._selected_timewindow is None:
            return

        if self._hashtag_loading is None or self._hashtag_content is None:
            return

        try:
            df_secondary = await run.cpu_bound(
                secondary_analyzer,
                self._df_primary,
                self._selected_timewindow,
            )
        except Exception as exc:
            self._show_error(
                self._hashtag_loading, f"Could not analyze time window: {exc}"
            )
            return

        if df_secondary.is_empty():
            self._show_error(
                self._hashtag_loading, "No hashtag data for this time window."
            )
            return

        self._df_secondary = df_secondary
        self._update_hashtag_grid()
        self._show_content(self._hashtag_loading, self._hashtag_content)
        self._update_hashtag_info()

    def _update_hashtag_grid(self) -> None:
        if self._hashtag_grid is None or self._df_secondary is None:
            return

        df_display = (
            self._df_secondary.select(
                [
                    OUTPUT_COL_HASHTAGS,
                    SECONDARY_COL_HASHTAG_PERC,
                    SECONDARY_COL_USERS_ALL,
                ]
            )
            .with_columns(
                n_users=pl.col(SECONDARY_COL_USERS_ALL).list.len(),
            )
            .sort(SECONDARY_COL_HASHTAG_PERC, descending=True)
            .rename(
                {
                    OUTPUT_COL_HASHTAGS: "Hashtag",
                    SECONDARY_COL_HASHTAG_PERC: "% of hashtags",
                    "n_users": "Unique users",
                }
            )
        )

        self._hashtag_grid.options["rowData"] = df_display.to_dicts()
        self._hashtag_grid.options["columnDefs"] = [
            {"field": "Hashtag", "sortable": True, "filter": True, "resizable": True},
            {
                "field": "% of hashtags",
                "sortable": True,
                "filter": True,
                "resizable": True,
                ":valueFormatter": "(params) => params.value.toFixed(1) + '%'",
            },
            {
                "field": "Unique users",
                "sortable": True,
                "filter": True,
                "resizable": True,
            },
        ]
        self._hashtag_grid.update()

    def _handle_hashtag_click(self, e) -> None:
        data = e.args
        if not data or "data" not in data:
            return

        row_data = data["data"]
        hashtag = row_data.get("Hashtag")
        if not hashtag:
            return

        if self._selected_hashtag == hashtag:
            return

        self._selected_hashtag = hashtag
        self._selected_user = None

        self._clear_tweet_grid()
        self._update_user_grid()
        self._update_user_info()

    def _update_hashtag_info(self) -> None:
        if self._hashtag_info is None:
            return

        if self._selected_timewindow is None:
            self._hashtag_info.text = (
                "Click a point on the chart above to explore hashtags."
            )
        elif self._df_secondary is None:
            self._hashtag_info.text = "Loading hashtag data..."
        else:
            n_hashtags = len(self._df_secondary)
            date_str = self._selected_timewindow.strftime(DISPLAY_DATE_FORMAT)
            self._hashtag_info.text = (
                f"{n_hashtags} hashtags found in window starting {date_str}"
            )

    def _update_user_grid(self) -> None:
        if (
            self._user_grid is None
            or self._df_secondary is None
            or self._selected_hashtag is None
        ):
            return

        df_users = extract_users_for_hashtag(self._df_secondary, self._selected_hashtag)

        if df_users.is_empty():
            self._clear_user_grid()
            return

        self._df_users = df_users

        self._user_grid.options["rowData"] = df_users.to_dicts()
        self._user_grid.options["columnDefs"] = [
            {"field": "User", "sortable": True, "filter": True, "resizable": True},
            {"field": "Posts", "sortable": True, "filter": True, "resizable": True},
        ]
        self._user_grid.update()

        if self._user_loading is not None and self._user_content is not None:
            self._show_content(self._user_loading, self._user_content)

    def _handle_user_click(self, e) -> None:
        data = e.args
        if not data or "data" not in data:
            return

        row_data = data["data"]
        user = row_data.get("User")
        if not user:
            return

        if self._selected_user == user:
            return

        self._selected_user = user
        ui.timer(0, self._load_tweets, once=True)

    def _update_user_info(self) -> None:
        if self._user_info is None:
            return

        if self._selected_hashtag is None:
            self._user_info.text = "Click a hashtag above to see which users posted it."
        elif self._df_users is not None:
            n_users = len(self._df_users)
            self._user_info.text = f"{n_users} users posted '{self._selected_hashtag}'"
        else:
            self._user_info.text = "Loading user data..."

    def _clear_user_grid(self) -> None:
        self._selected_user = None
        self._df_users = None
        if self._user_grid is not None:
            self._user_grid.options["rowData"] = []
            self._user_grid.options["columnDefs"] = []
            self._user_grid.update()
        self._update_user_info()
        if self._user_loading is not None:
            self._show_error(self._user_loading, "Select a hashtag to see users.")

    async def _load_tweets(self) -> None:
        if (
            self._selected_user is None
            or self._selected_hashtag is None
            or self._selected_timewindow is None
        ):
            return

        if self._tweet_loading is None or self._tweet_content is None:
            return

        await self._load_raw_input()

        if self._df_raw is None:
            self._show_error(self._tweet_loading, "Could not load tweet data.")
            return

        time_step = self._get_time_step()
        if time_step is None:
            self._show_error(
                self._tweet_loading, "Could not determine time window duration."
            )
            return

        timewindow_end = self._selected_timewindow + time_step

        try:
            df_tweets = await run.cpu_bound(
                self._filter_tweets,
                self._df_raw,
                self._selected_user,
                self._selected_hashtag,
                self._selected_timewindow,
                timewindow_end,
            )
        except Exception as exc:
            self._show_error(self._tweet_loading, f"Could not filter tweets: {exc}")
            return

        if df_tweets.is_empty():
            self._show_error(self._tweet_loading, "No tweets found for this selection.")
            return

        self._update_tweet_grid(df_tweets)
        self._show_content(self._tweet_loading, self._tweet_content)
        self._update_tweet_info(count=len(df_tweets))

    @staticmethod
    def _filter_tweets(
        df_raw: pl.DataFrame,
        user: str,
        hashtag: str,
        time_start: datetime,
        time_end: datetime,
    ) -> pl.DataFrame:
        return (
            df_raw.filter(
                pl.col(COL_AUTHOR_ID) == user,
                pl.col(COL_TIME).is_between(time_start, time_end),
                pl.col(COL_POST).str.contains(hashtag, literal=True),
            )
            .with_columns(pl.col(COL_TIME).dt.strftime("%B %d, %Y %I:%M %p"))
            .select([COL_TIME, COL_POST])
            .rename({COL_TIME: "Timestamp", COL_POST: "Post"})
        )

    def _update_tweet_grid(self, df_tweets: pl.DataFrame) -> None:
        if self._tweet_grid is None:
            return

        self._tweet_grid.options["rowData"] = df_tweets.to_dicts()
        self._tweet_grid.options["columnDefs"] = [
            {
                "field": "Timestamp",
                "sortable": True,
                "filter": True,
                "resizable": True,
            },
            {
                "field": "Post",
                "sortable": False,
                "filter": True,
                "resizable": True,
                "wrapText": True,
                "autoHeight": True,
                ":tooltipValueGetter": "(params) => params.value",
            },
        ]
        self._tweet_grid.update()

    def _update_tweet_info(self, count: int) -> None:
        timewindow_end = self._selected_timewindow + self._get_time_step()
        date_end = timewindow_end.strftime(DISPLAY_DATE_FORMAT)
        date_start = self._selected_timewindow.strftime(DISPLAY_DATE_FORMAT)
        if self._tweet_info is None:
            return
        self._tweet_info.text = f"{count} posts found for account {self._selected_user} between {date_start} and {date_end}"

    def _clear_tweet_grid(self) -> None:
        self._selected_user = None
        if self._tweet_grid is not None:
            self._tweet_grid.options["rowData"] = []
            self._tweet_grid.options["columnDefs"] = []
            self._tweet_grid.update()
        if self._tweet_info is not None:
            self._tweet_info.text = "Click a user above to see their posts."
        if self._tweet_loading is not None:
            self._show_error(self._tweet_loading, "Select a user to see their posts.")

    def _get_time_step(self):
        if self._df_primary is None or len(self._df_primary) < 2:
            return None
        return (
            self._df_primary[OUTPUT_COL_TIMESPAN][1]
            - self._df_primary[OUTPUT_COL_TIMESPAN][0]
        )

    def render_content(self) -> None:
        ui.add_css("""
            .ag-row {
                cursor: pointer !important;
            }
            .ag-row-hover {
                background-color: #e3f2fd !important;
            }
            """)
        with ui.row().classes("w-full justify-center"):
            with ui.column().classes("w-3/4 q-pa-md gap-4"):
                with ui.card().classes("w-full"):
                    with ui.row().classes("w-full items-center"):
                        self._smooth_checkbox = ui.checkbox(
                            "Show smoothed line",
                            value=False,
                            on_change=self._handle_smooth_change,
                        )
                    self._gini_loading, self._gini_content = (
                        self._create_loading_container("350px")
                    )
                    with self._gini_content:
                        self._gini_chart = (
                            ui.echart(
                                {},
                                on_point_click=self._handle_gini_click,
                            )
                            .classes("w-full")
                            .style("height: 350px")
                        )

                with ui.row().classes("w-full gap-4"):
                    with ui.card().classes("flex-1"):
                        with ui.card_section():
                            ui.label("Hashtags").classes("text-h6")
                            self._hashtag_info = ui.label(
                                "Click a point on the chart above to explore hashtags."
                            ).classes("text-body2 text-grey-7 q-mb-sm")
                        self._hashtag_loading, self._hashtag_content = (
                            self._create_loading_container("300px")
                        )
                        self._show_error(
                            self._hashtag_loading,
                            "Select a time window to see hashtags.",
                        )
                        with self._hashtag_content:
                            self._hashtag_grid = (
                                ui.aggrid(
                                    {
                                        "columnDefs": [],
                                        "rowData": [],
                                        "defaultColDef": {
                                            "sortable": True,
                                            "filter": True,
                                            "resizable": True,
                                        },
                                    },
                                    theme="quartz",
                                )
                                .classes("w-full")
                                .style("height: 300px")
                            )
                            self._hashtag_grid.on(
                                "cellClicked", self._handle_hashtag_click
                            )

                    with ui.card().classes("flex-1"):
                        with ui.card_section():
                            ui.label("Users").classes("text-h6")
                            self._user_info = ui.label(
                                "Click a hashtag above to see which users posted it."
                            ).classes("text-body2 text-grey-7 q-mb-sm")
                        self._user_loading, self._user_content = (
                            self._create_loading_container("300px")
                        )
                        self._show_error(
                            self._user_loading,
                            "Select a hashtag to see users.",
                        )
                        with self._user_content:
                            self._user_grid = (
                                ui.aggrid(
                                    {
                                        "columnDefs": [],
                                        "rowData": [],
                                        "defaultColDef": {
                                            "sortable": True,
                                            "filter": True,
                                            "resizable": True,
                                        },
                                    },
                                    theme="quartz",
                                )
                                .classes("w-full")
                                .style("height: 300px")
                            )
                            self._user_grid.on("cellClicked", self._handle_user_click)

                with ui.card().classes("w-full"):
                    with ui.card_section():
                        ui.label("Tweet Explorer").classes("text-h6")
                        self._tweet_info = ui.label(
                            "Click a user above to see their posts."
                        ).classes("text-body2 text-grey-7 q-mb-sm")
                    self._tweet_loading, self._tweet_content = (
                        self._create_loading_container("300px")
                    )
                    self._show_error(
                        self._tweet_loading,
                        "Select a user to see their posts.",
                    )
                    with self._tweet_content:
                        self._tweet_grid = (
                            ui.aggrid(
                                {
                                    "columnDefs": [],
                                    "rowData": [],
                                    "defaultColDef": {
                                        "sortable": True,
                                        "filter": True,
                                        "resizable": True,
                                    },
                                    "tooltipShowDelay": 200,
                                    "tooltipSwitchShowDelay": 70,
                                },
                                theme="quartz",
                            )
                            .classes("w-full")
                            .style("height: 300px")
                        )

        ui.timer(0, self._load_and_render_async, once=True)

data

Thin data loading wrappers for the hashtags analyzer.

Provides functions to: - Load and transform raw input data (replicates factory.py logic) - Run secondary analysis on-demand from primary output

Functions:

Name Description
extract_users_for_hashtag

Extract users who posted a specific hashtag from secondary analysis output.

load_primary_output

Load primary output parquet and convert timewindow_start to datetime.

load_transformed_raw_input

Load raw input dataset and apply analysis preprocessing.

run_secondary_analysis

Run secondary analysis for a single timewindow.

extract_users_for_hashtag(df_secondary, hashtag)

Extract users who posted a specific hashtag from secondary analysis output.

Parameters:

Name Type Description Default
df_secondary
DataFrame

Output of secondary_analyzer() with columns: hashtags, users_all, users_unique, hashtag_perc

required
hashtag
str

The hashtag to filter by.

required

Returns:

Type Description
DataFrame

DataFrame with columns: User, Posts (sorted by Posts descending).

DataFrame

Empty DataFrame if hashtag not found.

Source code in src/cibmangotree/gui/dashboards/hashtags/data.py
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def extract_users_for_hashtag(
    df_secondary: pl.DataFrame,
    hashtag: str,
) -> pl.DataFrame:
    """
    Extract users who posted a specific hashtag from secondary analysis output.

    Args:
        df_secondary: Output of secondary_analyzer() with columns:
                      hashtags, users_all, users_unique, hashtag_perc
        hashtag: The hashtag to filter by.

    Returns:
        DataFrame with columns: User, Posts (sorted by Posts descending).
        Empty DataFrame if hashtag not found.
    """
    hashtag_rows = df_secondary.filter(pl.col(OUTPUT_COL_HASHTAGS) == hashtag)

    if hashtag_rows.is_empty():
        return pl.DataFrame()

    users_col = hashtag_rows[SECONDARY_COL_USERS_ALL].to_list()[0]
    return (
        pl.DataFrame({SECONDARY_COL_USERS_ALL: users_col})
        .group_by(SECONDARY_COL_USERS_ALL)
        .agg(pl.count().alias("count"))
        .sort("count", descending=True)
        .rename({SECONDARY_COL_USERS_ALL: "User", "count": "Posts"})
    )
load_primary_output(session)

Load primary output parquet and convert timewindow_start to datetime.

The analyzer writes timewindow_start as a string ("%Y-%m-%d %H:%M:%S"). This function reads the parquet and converts it back to datetime for downstream use (charting, secondary analysis).

Parameters:

Name Type Description Default
session
GuiSession

GuiSession with current_analysis and app context.

required

Returns:

Type Description
DataFrame

DataFrame with timewindow_start as datetime column.

Source code in src/cibmangotree/gui/dashboards/hashtags/data.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
def load_primary_output(session: GuiSession) -> pl.DataFrame:
    """
    Load primary output parquet and convert timewindow_start to datetime.

    The analyzer writes timewindow_start as a string ("%Y-%m-%d %H:%M:%S").
    This function reads the parquet and converts it back to datetime for
    downstream use (charting, secondary analysis).

    Args:
        session: GuiSession with current_analysis and app context.

    Returns:
        DataFrame with timewindow_start as datetime column.
    """
    from cibmangotree.analyzers.hashtags.hashtags_base.interface import (
        OUTPUT_COL_TIMESPAN,
        OUTPUT_GINI,
    )

    analysis = session.current_analysis
    if analysis is None:
        raise ValueError("No analysis selected in session")

    storage = session.app.context.storage
    parquet_path = storage.get_primary_output_parquet_path(
        analysis,
        OUTPUT_GINI,
    )

    df = pl.read_parquet(parquet_path)

    if OUTPUT_COL_TIMESPAN in df.columns and df[OUTPUT_COL_TIMESPAN].dtype == pl.String:
        df = df.with_columns(
            pl.col(OUTPUT_COL_TIMESPAN).str.to_datetime(PRIMARY_OUTPUT_DATETIME_FORMAT)
        )

    return df
load_transformed_raw_input(session)

Load raw input dataset and apply analysis preprocessing.

Replicates the logic from hashtags_web/factory.py: 1. Load parquet from storage 2. Apply semantic transforms to time/identifier columns 3. Rename columns via column_mapping 4. Sort by timestamp

Parameters:

Name Type Description Default
session
GuiSession

GuiSession with current_analysis and app context.

required

Returns:

Type Description
DataFrame

Transformed DataFrame with schema columns (e.g., COL_TIME, COL_AUTHOR_ID, COL_POST).

Source code in src/cibmangotree/gui/dashboards/hashtags/data.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def load_transformed_raw_input(session: GuiSession) -> pl.DataFrame:
    """
    Load raw input dataset and apply analysis preprocessing.

    Replicates the logic from hashtags_web/factory.py:
    1. Load parquet from storage
    2. Apply semantic transforms to time/identifier columns
    3. Rename columns via column_mapping
    4. Sort by timestamp

    Args:
        session: GuiSession with current_analysis and app context.

    Returns:
        Transformed DataFrame with schema columns (e.g., COL_TIME, COL_AUTHOR_ID, COL_POST).
    """
    analysis = session.current_analysis
    if analysis is None:
        raise ValueError("No analysis selected in session")

    storage = session.app.context.storage
    project_id = analysis.project_id

    df_raw = storage.load_project_input(project_id)

    columns_with_semantic = _get_columns_with_semantic(df_raw)
    semantic_dict = {col.name: col for col in columns_with_semantic}

    column_mapping = analysis.column_mapping
    if column_mapping is None:
        raise ValueError("No column mapping found in analysis")

    transformed_columns = {}
    for schema_col, user_col in column_mapping.items():
        if user_col in semantic_dict:
            transformed_columns[schema_col] = semantic_dict[
                user_col
            ].apply_semantic_transform()
        else:
            transformed_columns[schema_col] = df_raw[user_col]

    df_transformed = df_raw.with_columns(
        [
            transformed_columns[schema_col].alias(schema_col)
            for schema_col in column_mapping.keys()
        ]
    )

    df_transformed = df_transformed.select(
        [pl.col(schema_col) for schema_col in column_mapping.keys()]
    ).sort(pl.col(COL_TIME))

    return df_transformed
run_secondary_analysis(df_primary, timewindow)

Run secondary analysis for a single timewindow.

Thin wrapper around secondary_analyzer() from the Shiny app.

Parameters:

Name Type Description Default
df_primary
DataFrame

Primary output DataFrame with timewindow_start column.

required
timewindow
datetime

The selected timewindow start datetime.

required

Returns:

Type Description
DataFrame

DataFrame with per-hashtag stats (hashtags, users_all, users_unique, hashtag_perc).

Source code in src/cibmangotree/gui/dashboards/hashtags/data.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def run_secondary_analysis(
    df_primary: pl.DataFrame,
    timewindow: datetime,
) -> pl.DataFrame:
    """
    Run secondary analysis for a single timewindow.

    Thin wrapper around secondary_analyzer() from the Shiny app.

    Args:
        df_primary: Primary output DataFrame with timewindow_start column.
        timewindow: The selected timewindow start datetime.

    Returns:
        DataFrame with per-hashtag stats (hashtags, users_all, users_unique, hashtag_perc).
    """
    return secondary_analyzer(df_primary, timewindow)

plots

Framework-agnostic ECharts figure builders for the hashtags analyzer.

These functions accept Polars DataFrames and return ECharts option dicts. They have no dependency on Shiny, Dash, or NiceGUI.

Functions:

Name Description
plot_gini_echart

Build a line chart of Gini coefficient over time.

plot_gini_echart(df, smooth=False)

Build a line chart of Gini coefficient over time.

Parameters:

Name Type Description Default
df
DataFrame

Primary output DataFrame with 'timewindow_start' and 'gini' columns.

required
smooth
bool

Whether to include a smoothed line (requires 'gini_smooth' column).

False

Returns:

Type Description
dict

ECharts option dict ready for ui.echart().

Source code in src/cibmangotree/gui/dashboards/hashtags/plots.py
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def plot_gini_echart(
    df: pl.DataFrame,
    smooth: bool = False,
) -> dict:
    """
    Build a line chart of Gini coefficient over time.

    Args:
        df: Primary output DataFrame with 'timewindow_start' and 'gini' columns.
        smooth: Whether to include a smoothed line (requires 'gini_smooth' column).

    Returns:
        ECharts option dict ready for ui.echart().
    """
    is_hourly = _detect_is_hourly(df)

    series_data = [
        {
            "value": [ts, gini],
            "raw_ts": ts.strftime(PRIMARY_OUTPUT_DATETIME_FORMAT),
            "display_ts": _format_date_for_axis(ts, is_hourly),
        }
        for ts, gini in zip(
            df[OUTPUT_COL_TIMESPAN].to_list(),
            df[OUTPUT_COL_GINI].to_list(),
        )
    ]

    series = [
        {
            "name": "Gini coefficient",
            "type": "line",
            "data": series_data,
            "lineStyle": {"color": "black", "width": 1.5},
            "showSymbol": False,
            "symbol": "circle",
            "symbolSize": 4,
            "itemStyle": {"color": "black"},
            "emphasis": {
                "showSymbol": True,
                "itemStyle": {
                    "color": "#d62728",
                    "symbolSize": 12,
                    "shadowBlur": 10,
                    "shadowColor": "rgba(0, 0, 0, 0.3)",
                },
            },
        }
    ]

    if smooth and "gini_smooth" in df.columns:
        smooth_series_data = [
            {
                "value": [ts, gini_s],
                "raw_ts": ts.strftime(PRIMARY_OUTPUT_DATETIME_FORMAT),
                "display_ts": _format_date_for_axis(ts, is_hourly),
            }
            for ts, gini_s in zip(
                df[OUTPUT_COL_TIMESPAN].to_list(),
                df["gini_smooth"].to_list(),
            )
            if gini_s is not None
        ]
        series.append(
            {
                "name": "Smoothed",
                "type": "line",
                "data": smooth_series_data,
                "lineStyle": {"color": MANGO_DARK_ORANGE, "width": 2},
                "itemStyle": {"color": MANGO_DARK_ORANGE},
                "showSymbol": False,
                "emphasis": {
                    "showSymbol": True,
                    "itemStyle": {
                        "color": "#d62728",
                        "symbolSize": 12,
                        "shadowBlur": 10,
                        "shadowColor": "rgba(0, 0, 0, 0.3)",
                    },
                },
            }
        )

    return {
        "title": {"text": "Concentration of hashtags over time"},
        "tooltip": {
            "trigger": "axis",
            "axisPointer": {"type": "cross"},
            ":formatter": """function(params) {
                if (!params || params.length === 0) return '';
                var p = params[0];
                var displayTs = p.data ? p.data.display_ts : p.axisValue;
                var html = '<b>' + displayTs + '</b><br/>';
                for (var i = 0; i < params.length; i++) {
                    html += params[i].marker + params[i].seriesName + ': '
                          + params[i].value[1].toFixed(3) + '<br/>';
                }
                return html;
            }""",
        },
        "grid": {"left": 60, "right": 30, "top": 70, "bottom": 50},
        "xAxis": {
            "type": "time",
            "name": "Time window (start date)",
            "nameLocation": "middle",
            "nameGap": 30,
            "nameTextStyle": {"fontSize": 13},
            "axisLabel": {
                "fontSize": 11,
                ":formatter": """function(value) {
                    var date = new Date(value);
                    var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
                                  'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
                    return months[date.getMonth()] + ' ' + date.getDate() + ', ' + date.getFullYear();
                }""",
            },
        },
        "yAxis": {
            "type": "value",
            "name": "Hashtag Concentration\n(Gini coefficient)",
            "nameLocation": "middle",
            "nameGap": 50,
            "nameTextStyle": {"fontSize": 13},
            "min": 0,
            "max": 1,
            "axisLabel": {
                ":formatter": "function(value) { return value.toFixed(2); }",
                "fontSize": 11,
            },
        },
        "series": series,
    }

ngrams

Modules:

Name Description
dashboard

N-grams results dashboard page.

data

Data loading and transformation helpers for the n-grams dashboard.

plots

Framework-agnostic ECharts figure builders for the n-grams analyzer.

Classes:

Name Description
NgramsDashboardPage

Results dashboard for the N-grams (Copy-Pasta Detector) analyzer.

NgramsDashboardPage

Bases: BaseDashboardPage

Results dashboard for the N-grams (Copy-Pasta Detector) analyzer.

Renders a log-log scatter plot of n-gram frequency versus unique poster count. Each point represents one n-gram; points are coloured by n-gram length.

Interactive features: - Click a point to highlight it and filter the data grid to show all occurrences of that n-gram - Click the same point again to deselect and return to summary view - Click a different point to switch selection

Source code in src/cibmangotree/gui/dashboards/ngrams/dashboard.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
class NgramsDashboardPage(BaseDashboardPage):
    """
    Results dashboard for the N-grams (Copy-Pasta Detector) analyzer.

    Renders a log-log scatter plot of n-gram frequency versus unique poster
    count.  Each point represents one n-gram; points are coloured by n-gram
    length.

    Interactive features:
    - Click a point to highlight it and filter the data grid to show all
      occurrences of that n-gram
    - Click the same point again to deselect and return to summary view
    - Click a different point to switch selection
    """

    _secondary_analyzer_id = ngram_stats_interface.id

    def __init__(self, session: GuiSession):
        super().__init__(session=session)
        self._selected_words: str | None = None
        self._selected_series_index: int | None = None
        self._selected_data_index: int | None = None

        self._filter_text: str | None = None
        self._filter_applied: bool = False
        self._all_ngram_options: list[str] = []

        self._df_stats: pl.DataFrame | None = None
        self._df_full: pl.DataFrame | None = None
        self._df_stats_sampled: pl.DataFrame | None = None
        self._sampling_metadata: SamplingMetadata | None = None

        self._chart: ui.echart | None = None
        self._grid: ui.aggrid | None = None
        self._info_label: ui.label | None = None
        self._ngram_select: ui.input | None = None
        self._chart_loading: ui.column | None = None
        self._chart_content: ui.column | None = None
        self._grid_loading: ui.column | None = None
        self._grid_content: ui.column | None = None
        self._sampling_label: ui.label | None = None
        self._show_all_btn: ui.button | None = None

    def _get_top_n_summary(self, n: int = 100) -> pl.DataFrame:
        df = self._get_filtered_stats()
        if df.is_empty():
            return pl.DataFrame()

        return (
            make_summary_columns(df).sort("Total repetitions", descending=True).head(n)
        )

    def _get_filtered_full_data(self, words: str) -> pl.DataFrame:
        if self._df_full is None or self._df_full.is_empty():
            return pl.DataFrame()

        return self._df_full.filter(pl.col(COL_NGRAM_WORDS) == words).pipe(
            make_detail_columns
        )

    def _update_info_label(self) -> None:
        if self._info_label is None:
            return

        if self._selected_words is not None:
            if self._df_full is not None:
                count = self._df_full.filter(
                    pl.col(COL_NGRAM_WORDS) == self._selected_words
                ).height
            else:
                count = 0
            self._info_label.text = (
                f"N-gram: '{self._selected_words}' — {count:,} total repetitions"
            )
        elif self._filter_text:
            df_filtered = self._get_filtered_stats()
            count = df_filtered.height
            if count == 0:
                self._info_label.text = (
                    f"No n-grams found matching '{self._filter_text}'. "
                    "Try a different search term."
                )
            elif not self._filter_applied:
                self._info_label.text = (
                    f"Filter: '{self._filter_text}' — {count:,} matches found. "
                    "Press Enter to apply filter to chart and grid."
                )
            else:
                self._info_label.text = (
                    f"Showing {min(count, 100):,} of {count:,} n-grams "
                    f"matching '{self._filter_text}'. "
                    "Click a point to view all occurrences."
                )
        else:
            self._info_label.text = (
                "Showing top 100 n-grams by frequency. "
                "Type to search, then press Enter to filter. "
                "Click a point to view all occurrences."
            )

    def _update_grid(self) -> None:
        if self._grid is None:
            return

        if self._selected_words is None:
            df_display = self._get_top_n_summary()
        else:
            df_display = self._get_filtered_full_data(self._selected_words)

        if df_display.is_empty():
            self._grid.options["rowData"] = []
            self._grid.options["columnDefs"] = []
        else:
            self._grid.options["rowData"] = df_display.to_dicts()
            column_defs = []
            for col in df_display.columns:
                col_def = {
                    "field": col,
                    "sortable": True,
                    "filter": True,
                    "resizable": True,
                }
                if col == "Post content":
                    col_def[":tooltipValueGetter"] = "(params) => params.value"
                column_defs.append(col_def)
            self._grid.options["columnDefs"] = column_defs
        self._grid.update()

    def _highlight_point(self, series_index: int, data_index: int) -> None:
        if self._chart is None:
            return
        self._chart.run_chart_method(
            "dispatchAction",
            {"type": "highlight", "seriesIndex": series_index, "dataIndex": data_index},
        )

    def _downplay_point(self, series_index: int, data_index: int) -> None:
        self._chart.run_chart_method(
            "dispatchAction",
            {"type": "downplay", "seriesIndex": series_index, "dataIndex": data_index},
        )

    def _clear_all_highlights(self) -> None:
        self._chart.run_chart_method("dispatchAction", {"type": "downplay"})

    def _handle_point_click(self, e) -> None:
        clicked_words = e.data.get("words")
        if clicked_words is None:
            return

        series_index = e.series_index
        data_index = e.data_index

        if (
            self._selected_series_index is not None
            and self._selected_data_index is not None
        ):
            self._downplay_point(self._selected_series_index, self._selected_data_index)

        if self._selected_words == clicked_words:
            self._selected_words = None
            self._selected_series_index = None
            self._selected_data_index = None
        else:
            self._selected_words = clicked_words
            self._selected_series_index = series_index
            self._selected_data_index = data_index
            self._highlight_point(series_index, data_index)

        self._update_info_label()
        self._update_grid()

    def _handle_filter_change(self, e) -> None:
        self._filter_text = e.value if e.value else None
        self._filter_applied = False
        self._update_info_label()

    def _handle_enter_press(self, e) -> None:
        self._selected_words = None
        self._selected_series_index = None
        self._selected_data_index = None
        self._clear_all_highlights()

        self._filter_applied = True

        self._update_chart_with_filter()
        self._update_grid()
        self._update_info_label()

    def _get_filtered_stats(self) -> pl.DataFrame:
        if self._df_stats is None:
            return pl.DataFrame()

        if not self._filter_text:
            return (
                self._df_stats_sampled
                if self._df_stats_sampled is not None
                else self._df_stats
            )

        return filter_ngrams_by_text(self._df_stats, self._filter_text)

    def _handle_clear(self) -> None:
        self._filter_text = None
        self._filter_applied = False

        self._selected_words = None
        self._selected_series_index = None
        self._selected_data_index = None
        self._clear_all_highlights()

        self._update_chart_with_filter()
        self._update_grid()
        self._update_info_label()

    async def _load_and_render_async(self) -> None:
        stats_path = self.get_output_parquet_path(OUTPUT_NGRAM_STATS)
        full_path = self.get_output_parquet_path(OUTPUT_NGRAM_FULL)

        if stats_path is None:
            if self._chart_loading is not None:
                self._show_error(
                    self._chart_loading, "No analysis found in the current session."
                )
            return

        try:
            self._df_stats = await run.io_bound(pl.read_parquet, stats_path)
            if full_path:
                self._df_full = await run.io_bound(pl.read_parquet, full_path)
        except Exception as exc:
            if self._chart_loading is not None:
                self._show_error(
                    self._chart_loading, f"Could not load n-gram results: {exc}"
                )
            return

        if self._df_stats.is_empty():
            if self._chart_loading is not None:
                self._show_error(self._chart_loading, "No n-gram data available.")
            return

        try:
            self._df_stats_sampled, self._sampling_metadata = await run.cpu_bound(
                sample_ngram_data,
                self._df_stats,
                50000,
            )
            option = await run.cpu_bound(
                plot_scatter_echart,
                self._df_stats_sampled,
                False,
            )
        except Exception as exc:
            if self._chart_loading is not None:
                self._show_error(self._chart_loading, f"Could not build chart: {exc}")
            return

        if self._ngram_select is not None:
            self._all_ngram_options = (
                self._df_stats.select(pl.col(COL_NGRAM_WORDS).unique())
                .sort(COL_NGRAM_WORDS)
                .to_series()
                .to_list()
            )
            self._ngram_select.set_autocomplete(self._all_ngram_options)

        if (
            self._chart is None
            or self._chart_content is None
            or self._chart_loading is None
        ):
            return
        self._chart.options.update(option)
        self._chart.update()
        self._show_content(self._chart_loading, self._chart_content)

        self._update_sampling_info_label()
        self._update_info_label()
        self._update_grid()
        if self._grid_loading is not None and self._grid_content is not None:
            self._show_content(self._grid_loading, self._grid_content)

    def _update_sampling_info_label(self) -> None:
        if self._sampling_label is None or self._sampling_metadata is None:
            return
        self._sampling_label.text = self._sampling_metadata.sampling_message
        if self._show_all_btn is not None:
            self._show_all_btn.set_visibility(self._sampling_metadata.is_sampled)

    async def _handle_show_all_click(self) -> None:
        if self._df_stats is None:
            return

        total = len(self._df_stats)

        if total > 100_000:
            with ui.dialog() as dialog, ui.card():
                ui.label(f"Load all {total:,} data points?").classes("text-h6")
                ui.label(
                    "This may cause the browser to slow down or become unresponsive."
                ).classes("text-body2 text-grey-7")
                with ui.row().classes("gap-4 justify-end"):
                    ui.button("Cancel", on_click=dialog.close).props("flat")

                    async def _confirm():
                        dialog.close()
                        await self._load_full_dataset()

                    ui.button("Load all", on_click=_confirm, color="primary")
            dialog.open()
        else:
            await self._load_full_dataset()

    async def _load_full_dataset(self) -> None:
        if self._show_all_btn is not None:
            self._show_all_btn.set_enabled(False)
        if self._sampling_label is not None:
            self._sampling_label.text = "Loading full dataset..."

        if self._df_stats is None:
            return

        df_stats = self._df_stats

        try:
            option = await run.cpu_bound(
                plot_scatter_echart,
                df_stats,
                False,
            )
        except Exception as exc:
            self.notify_error(f"Could not load full dataset: {exc}")
            if self._show_all_btn is not None:
                self._show_all_btn.set_enabled(True)
            return

        self._df_stats_sampled = df_stats
        self._sampling_metadata = SamplingMetadata(
            total_count=len(df_stats),
            sampled_count=len(df_stats),
            is_sampled=False,
            strategy="none",
        )

        if self._chart is not None:
            self._chart.options.update(option)
            self._chart.update()
        self._update_sampling_info_label()

    def _update_chart_with_filter(self) -> None:
        if self._chart is None:
            return

        df_filtered = self._get_filtered_stats()

        if df_filtered.is_empty():
            self._chart.options.clear()
            self._chart.update()
            return

        option = plot_scatter_echart(df_filtered, enable_large_mode=False)
        self._chart.options.update(option)
        self._chart.update()

    def render_content(self) -> None:
        ui.add_css("""
            .ag-tooltip {
                white-space: normal !important;
                max-width: 450px !important;
                word-wrap: break-word !important;
            }
        """)
        with ui.row().classes("w-full justify-center"):
            with ui.column().classes("w-3/4 q-pa-md gap-4"):
                with ui.card().classes("w-full"):
                    self._ngram_select = (
                        ui.input(
                            autocomplete=[],
                            label="Search N-gram",
                            on_change=self._handle_filter_change,
                        )
                        .props('clearable autocomplete="off"')
                        .classes("w-1/4")
                        .on("keydown.enter", self._handle_enter_press)
                        .on("clear", self._handle_clear)
                    )

                    self._chart_loading, self._chart_content = (
                        self._create_loading_container("500px")
                    )
                    with self._chart_content:
                        self._chart = (
                            ui.echart({}, on_point_click=self._handle_point_click)
                            .classes("w-full")
                            .style("height: 500px")
                        )

                with ui.row().classes("w-full items-center gap-4"):
                    self._sampling_label = ui.label("").classes(
                        "text-body2 text-grey-7"
                    )
                    self._show_all_btn = ui.button(
                        "Show all data",
                        on_click=self._handle_show_all_click,
                        color="secondary",
                    ).props("outline dense")
                    self._show_all_btn.set_visibility(False)

                with ui.card().classes("w-full"):
                    with ui.card_section():
                        ui.label("Data viewer").classes("text-h6")
                    self._info_label = ui.label("Loading data...").classes(
                        "text-body2 text-grey-7 q-mb-sm"
                    )
                    self._grid_loading, self._grid_content = (
                        self._create_loading_container("400px")
                    )
                    with self._grid_content:
                        self._grid = (
                            ui.aggrid(
                                {
                                    "columnDefs": [],
                                    "rowData": [],
                                    "defaultColDef": {
                                        "sortable": True,
                                        "filter": True,
                                        "resizable": True,
                                    },
                                    "tooltipShowDelay": 200,
                                    "tooltipSwitchShowDelay": 70,
                                },
                                theme="quartz",
                            )
                            .classes("w-full")
                            .style("height: 400px")
                        )

        ui.timer(0, self._load_and_render_async, once=True)

dashboard

N-grams results dashboard page.

Displays an interactive scatter plot of n-gram frequency vs. unique poster count, loaded from the ngram_stats secondary analyzer output. Clicking a point filters the data grid to show all occurrences of that n-gram.

Classes:

Name Description
NgramsDashboardPage

Results dashboard for the N-grams (Copy-Pasta Detector) analyzer.

NgramsDashboardPage

Bases: BaseDashboardPage

Results dashboard for the N-grams (Copy-Pasta Detector) analyzer.

Renders a log-log scatter plot of n-gram frequency versus unique poster count. Each point represents one n-gram; points are coloured by n-gram length.

Interactive features: - Click a point to highlight it and filter the data grid to show all occurrences of that n-gram - Click the same point again to deselect and return to summary view - Click a different point to switch selection

Source code in src/cibmangotree/gui/dashboards/ngrams/dashboard.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
class NgramsDashboardPage(BaseDashboardPage):
    """
    Results dashboard for the N-grams (Copy-Pasta Detector) analyzer.

    Renders a log-log scatter plot of n-gram frequency versus unique poster
    count.  Each point represents one n-gram; points are coloured by n-gram
    length.

    Interactive features:
    - Click a point to highlight it and filter the data grid to show all
      occurrences of that n-gram
    - Click the same point again to deselect and return to summary view
    - Click a different point to switch selection
    """

    _secondary_analyzer_id = ngram_stats_interface.id

    def __init__(self, session: GuiSession):
        super().__init__(session=session)
        self._selected_words: str | None = None
        self._selected_series_index: int | None = None
        self._selected_data_index: int | None = None

        self._filter_text: str | None = None
        self._filter_applied: bool = False
        self._all_ngram_options: list[str] = []

        self._df_stats: pl.DataFrame | None = None
        self._df_full: pl.DataFrame | None = None
        self._df_stats_sampled: pl.DataFrame | None = None
        self._sampling_metadata: SamplingMetadata | None = None

        self._chart: ui.echart | None = None
        self._grid: ui.aggrid | None = None
        self._info_label: ui.label | None = None
        self._ngram_select: ui.input | None = None
        self._chart_loading: ui.column | None = None
        self._chart_content: ui.column | None = None
        self._grid_loading: ui.column | None = None
        self._grid_content: ui.column | None = None
        self._sampling_label: ui.label | None = None
        self._show_all_btn: ui.button | None = None

    def _get_top_n_summary(self, n: int = 100) -> pl.DataFrame:
        df = self._get_filtered_stats()
        if df.is_empty():
            return pl.DataFrame()

        return (
            make_summary_columns(df).sort("Total repetitions", descending=True).head(n)
        )

    def _get_filtered_full_data(self, words: str) -> pl.DataFrame:
        if self._df_full is None or self._df_full.is_empty():
            return pl.DataFrame()

        return self._df_full.filter(pl.col(COL_NGRAM_WORDS) == words).pipe(
            make_detail_columns
        )

    def _update_info_label(self) -> None:
        if self._info_label is None:
            return

        if self._selected_words is not None:
            if self._df_full is not None:
                count = self._df_full.filter(
                    pl.col(COL_NGRAM_WORDS) == self._selected_words
                ).height
            else:
                count = 0
            self._info_label.text = (
                f"N-gram: '{self._selected_words}' — {count:,} total repetitions"
            )
        elif self._filter_text:
            df_filtered = self._get_filtered_stats()
            count = df_filtered.height
            if count == 0:
                self._info_label.text = (
                    f"No n-grams found matching '{self._filter_text}'. "
                    "Try a different search term."
                )
            elif not self._filter_applied:
                self._info_label.text = (
                    f"Filter: '{self._filter_text}' — {count:,} matches found. "
                    "Press Enter to apply filter to chart and grid."
                )
            else:
                self._info_label.text = (
                    f"Showing {min(count, 100):,} of {count:,} n-grams "
                    f"matching '{self._filter_text}'. "
                    "Click a point to view all occurrences."
                )
        else:
            self._info_label.text = (
                "Showing top 100 n-grams by frequency. "
                "Type to search, then press Enter to filter. "
                "Click a point to view all occurrences."
            )

    def _update_grid(self) -> None:
        if self._grid is None:
            return

        if self._selected_words is None:
            df_display = self._get_top_n_summary()
        else:
            df_display = self._get_filtered_full_data(self._selected_words)

        if df_display.is_empty():
            self._grid.options["rowData"] = []
            self._grid.options["columnDefs"] = []
        else:
            self._grid.options["rowData"] = df_display.to_dicts()
            column_defs = []
            for col in df_display.columns:
                col_def = {
                    "field": col,
                    "sortable": True,
                    "filter": True,
                    "resizable": True,
                }
                if col == "Post content":
                    col_def[":tooltipValueGetter"] = "(params) => params.value"
                column_defs.append(col_def)
            self._grid.options["columnDefs"] = column_defs
        self._grid.update()

    def _highlight_point(self, series_index: int, data_index: int) -> None:
        if self._chart is None:
            return
        self._chart.run_chart_method(
            "dispatchAction",
            {"type": "highlight", "seriesIndex": series_index, "dataIndex": data_index},
        )

    def _downplay_point(self, series_index: int, data_index: int) -> None:
        self._chart.run_chart_method(
            "dispatchAction",
            {"type": "downplay", "seriesIndex": series_index, "dataIndex": data_index},
        )

    def _clear_all_highlights(self) -> None:
        self._chart.run_chart_method("dispatchAction", {"type": "downplay"})

    def _handle_point_click(self, e) -> None:
        clicked_words = e.data.get("words")
        if clicked_words is None:
            return

        series_index = e.series_index
        data_index = e.data_index

        if (
            self._selected_series_index is not None
            and self._selected_data_index is not None
        ):
            self._downplay_point(self._selected_series_index, self._selected_data_index)

        if self._selected_words == clicked_words:
            self._selected_words = None
            self._selected_series_index = None
            self._selected_data_index = None
        else:
            self._selected_words = clicked_words
            self._selected_series_index = series_index
            self._selected_data_index = data_index
            self._highlight_point(series_index, data_index)

        self._update_info_label()
        self._update_grid()

    def _handle_filter_change(self, e) -> None:
        self._filter_text = e.value if e.value else None
        self._filter_applied = False
        self._update_info_label()

    def _handle_enter_press(self, e) -> None:
        self._selected_words = None
        self._selected_series_index = None
        self._selected_data_index = None
        self._clear_all_highlights()

        self._filter_applied = True

        self._update_chart_with_filter()
        self._update_grid()
        self._update_info_label()

    def _get_filtered_stats(self) -> pl.DataFrame:
        if self._df_stats is None:
            return pl.DataFrame()

        if not self._filter_text:
            return (
                self._df_stats_sampled
                if self._df_stats_sampled is not None
                else self._df_stats
            )

        return filter_ngrams_by_text(self._df_stats, self._filter_text)

    def _handle_clear(self) -> None:
        self._filter_text = None
        self._filter_applied = False

        self._selected_words = None
        self._selected_series_index = None
        self._selected_data_index = None
        self._clear_all_highlights()

        self._update_chart_with_filter()
        self._update_grid()
        self._update_info_label()

    async def _load_and_render_async(self) -> None:
        stats_path = self.get_output_parquet_path(OUTPUT_NGRAM_STATS)
        full_path = self.get_output_parquet_path(OUTPUT_NGRAM_FULL)

        if stats_path is None:
            if self._chart_loading is not None:
                self._show_error(
                    self._chart_loading, "No analysis found in the current session."
                )
            return

        try:
            self._df_stats = await run.io_bound(pl.read_parquet, stats_path)
            if full_path:
                self._df_full = await run.io_bound(pl.read_parquet, full_path)
        except Exception as exc:
            if self._chart_loading is not None:
                self._show_error(
                    self._chart_loading, f"Could not load n-gram results: {exc}"
                )
            return

        if self._df_stats.is_empty():
            if self._chart_loading is not None:
                self._show_error(self._chart_loading, "No n-gram data available.")
            return

        try:
            self._df_stats_sampled, self._sampling_metadata = await run.cpu_bound(
                sample_ngram_data,
                self._df_stats,
                50000,
            )
            option = await run.cpu_bound(
                plot_scatter_echart,
                self._df_stats_sampled,
                False,
            )
        except Exception as exc:
            if self._chart_loading is not None:
                self._show_error(self._chart_loading, f"Could not build chart: {exc}")
            return

        if self._ngram_select is not None:
            self._all_ngram_options = (
                self._df_stats.select(pl.col(COL_NGRAM_WORDS).unique())
                .sort(COL_NGRAM_WORDS)
                .to_series()
                .to_list()
            )
            self._ngram_select.set_autocomplete(self._all_ngram_options)

        if (
            self._chart is None
            or self._chart_content is None
            or self._chart_loading is None
        ):
            return
        self._chart.options.update(option)
        self._chart.update()
        self._show_content(self._chart_loading, self._chart_content)

        self._update_sampling_info_label()
        self._update_info_label()
        self._update_grid()
        if self._grid_loading is not None and self._grid_content is not None:
            self._show_content(self._grid_loading, self._grid_content)

    def _update_sampling_info_label(self) -> None:
        if self._sampling_label is None or self._sampling_metadata is None:
            return
        self._sampling_label.text = self._sampling_metadata.sampling_message
        if self._show_all_btn is not None:
            self._show_all_btn.set_visibility(self._sampling_metadata.is_sampled)

    async def _handle_show_all_click(self) -> None:
        if self._df_stats is None:
            return

        total = len(self._df_stats)

        if total > 100_000:
            with ui.dialog() as dialog, ui.card():
                ui.label(f"Load all {total:,} data points?").classes("text-h6")
                ui.label(
                    "This may cause the browser to slow down or become unresponsive."
                ).classes("text-body2 text-grey-7")
                with ui.row().classes("gap-4 justify-end"):
                    ui.button("Cancel", on_click=dialog.close).props("flat")

                    async def _confirm():
                        dialog.close()
                        await self._load_full_dataset()

                    ui.button("Load all", on_click=_confirm, color="primary")
            dialog.open()
        else:
            await self._load_full_dataset()

    async def _load_full_dataset(self) -> None:
        if self._show_all_btn is not None:
            self._show_all_btn.set_enabled(False)
        if self._sampling_label is not None:
            self._sampling_label.text = "Loading full dataset..."

        if self._df_stats is None:
            return

        df_stats = self._df_stats

        try:
            option = await run.cpu_bound(
                plot_scatter_echart,
                df_stats,
                False,
            )
        except Exception as exc:
            self.notify_error(f"Could not load full dataset: {exc}")
            if self._show_all_btn is not None:
                self._show_all_btn.set_enabled(True)
            return

        self._df_stats_sampled = df_stats
        self._sampling_metadata = SamplingMetadata(
            total_count=len(df_stats),
            sampled_count=len(df_stats),
            is_sampled=False,
            strategy="none",
        )

        if self._chart is not None:
            self._chart.options.update(option)
            self._chart.update()
        self._update_sampling_info_label()

    def _update_chart_with_filter(self) -> None:
        if self._chart is None:
            return

        df_filtered = self._get_filtered_stats()

        if df_filtered.is_empty():
            self._chart.options.clear()
            self._chart.update()
            return

        option = plot_scatter_echart(df_filtered, enable_large_mode=False)
        self._chart.options.update(option)
        self._chart.update()

    def render_content(self) -> None:
        ui.add_css("""
            .ag-tooltip {
                white-space: normal !important;
                max-width: 450px !important;
                word-wrap: break-word !important;
            }
        """)
        with ui.row().classes("w-full justify-center"):
            with ui.column().classes("w-3/4 q-pa-md gap-4"):
                with ui.card().classes("w-full"):
                    self._ngram_select = (
                        ui.input(
                            autocomplete=[],
                            label="Search N-gram",
                            on_change=self._handle_filter_change,
                        )
                        .props('clearable autocomplete="off"')
                        .classes("w-1/4")
                        .on("keydown.enter", self._handle_enter_press)
                        .on("clear", self._handle_clear)
                    )

                    self._chart_loading, self._chart_content = (
                        self._create_loading_container("500px")
                    )
                    with self._chart_content:
                        self._chart = (
                            ui.echart({}, on_point_click=self._handle_point_click)
                            .classes("w-full")
                            .style("height: 500px")
                        )

                with ui.row().classes("w-full items-center gap-4"):
                    self._sampling_label = ui.label("").classes(
                        "text-body2 text-grey-7"
                    )
                    self._show_all_btn = ui.button(
                        "Show all data",
                        on_click=self._handle_show_all_click,
                        color="secondary",
                    ).props("outline dense")
                    self._show_all_btn.set_visibility(False)

                with ui.card().classes("w-full"):
                    with ui.card_section():
                        ui.label("Data viewer").classes("text-h6")
                    self._info_label = ui.label("Loading data...").classes(
                        "text-body2 text-grey-7 q-mb-sm"
                    )
                    self._grid_loading, self._grid_content = (
                        self._create_loading_container("400px")
                    )
                    with self._grid_content:
                        self._grid = (
                            ui.aggrid(
                                {
                                    "columnDefs": [],
                                    "rowData": [],
                                    "defaultColDef": {
                                        "sortable": True,
                                        "filter": True,
                                        "resizable": True,
                                    },
                                    "tooltipShowDelay": 200,
                                    "tooltipSwitchShowDelay": 70,
                                },
                                theme="quartz",
                            )
                            .classes("w-full")
                            .style("height: 400px")
                        )

        ui.timer(0, self._load_and_render_async, once=True)

data

Data loading and transformation helpers for the n-grams dashboard.

Provides functions to: - Build summary and detail column definitions for AG-Grid - Filter n-gram stats by text search

plots

Framework-agnostic ECharts figure builders for the n-grams analyzer.

These functions accept a Polars DataFrame and return an ECharts option dict. They have no dependency on Shiny, Dash, or NiceGUI.

Classes:

Name Description
SamplingMetadata

Metadata about data sampling applied before visualization.

SamplingMetadata

Bases: BaseModel

Metadata about data sampling applied before visualization.

Source code in src/cibmangotree/gui/dashboards/ngrams/plots.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class SamplingMetadata(BaseModel):
    """Metadata about data sampling applied before visualization."""

    total_count: int = Field(...)
    sampled_count: int = Field(...)
    is_sampled: bool = Field(...)
    strategy: str = Field(...)

    @property
    def sampling_message(self) -> str:
        if not self.is_sampled:
            return f"Showing all {self.total_count:,} n-grams."
        return (
            f"Showing top {self.sampled_count:,} of {self.total_count:,} n-grams "
            f"(by frequency). Click 'Show all' to load the complete dataset."
        )

placeholder

Modules:

Name Description
dashboard

Placeholder dashboard shown when an analyzer has no dashboard yet.

Classes:

Name Description
PlaceholderDashboard

Fallback page shown when the selected analyzer has no dashboard yet.

PlaceholderDashboard

Bases: BaseDashboardPage

Fallback page shown when the selected analyzer has no dashboard yet.

Source code in src/cibmangotree/gui/dashboards/placeholder/dashboard.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class PlaceholderDashboard(BaseDashboardPage):
    """Fallback page shown when the selected analyzer has no dashboard yet."""

    def __init__(self, session: GuiSession):
        super().__init__(session=session)

    def render_content(self) -> None:
        with (
            ui.column()
            .classes("items-center justify-center")
            .style("height: 80vh; width: 100%")
        ):
            ui.icon("bar_chart", size="4rem").classes("text-grey-5")
            ui.label("Dashboard coming soon").classes("text-h6 text-grey-6 q-mt-md")
            ui.label(
                "A results dashboard for this analyzer is not yet available."
            ).classes("text-grey-5")

dashboard

Placeholder dashboard shown when an analyzer has no dashboard yet.

Classes:

Name Description
PlaceholderDashboard

Fallback page shown when the selected analyzer has no dashboard yet.

PlaceholderDashboard

Bases: BaseDashboardPage

Fallback page shown when the selected analyzer has no dashboard yet.

Source code in src/cibmangotree/gui/dashboards/placeholder/dashboard.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class PlaceholderDashboard(BaseDashboardPage):
    """Fallback page shown when the selected analyzer has no dashboard yet."""

    def __init__(self, session: GuiSession):
        super().__init__(session=session)

    def render_content(self) -> None:
        with (
            ui.column()
            .classes("items-center justify-center")
            .style("height: 80vh; width: 100%")
        ):
            ui.icon("bar_chart", size="4rem").classes("text-grey-5")
            ui.label("Dashboard coming soon").classes("text-h6 text-grey-6 q-mt-md")
            ui.label(
                "A results dashboard for this analyzer is not yet available."
            ).classes("text-grey-5")

cibmangotree.gui.utils

Functions:

Name Description
is_wsl

Check if the environment is WSL2.

is_wsl()

Check if the environment is WSL2.

Source code in src/cibmangotree/gui/utils.py
 6
 7
 8
 9
10
11
12
def is_wsl() -> bool:
    """Check if the environment is WSL2."""
    try:
        with open("/proc/version", "r") as f:
            return "microsoft" in f.read().lower()
    except FileNotFoundError:
        return False