diff --git a/.env.example b/.env.example index 7a14a09..b4512ea 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ DATA_PATH=src/spanselector/data/sample.csv HOST=127.0.0.1 PORT=8080 # RELOAD=false +# SHOW=true # Optional: Logging level (DEBUG, INFO, WARNING, ERROR) # LOG_LEVEL=INFO diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7574272..b8eab71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,20 +9,16 @@ permissions: contents: read jobs: - quality: - name: Python ${{ matrix.python-version }} + static-analysis: + name: Static analysis (Ruff + mypy) runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v7 - name: Install uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: - python-version: ${{ matrix.python-version }} + python-version: "3.13" enable-cache: true - name: Install dependencies run: uv sync --locked --extra dev @@ -32,14 +28,43 @@ jobs: run: uv run ruff format --check . - name: Type check run: uv run mypy src/spanselector + + tests: + name: Tests / Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v7 + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + - name: Install dependencies + run: uv sync --locked --extra dev - name: Test run: uv run pytest + + package: + name: Package / Build distributions + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: "3.13" + enable-cache: true - name: Build package - if: matrix.python-version == '3.13' run: uv build browser-tests: - name: Selection in ${{ matrix.browser }} + name: E2E / Selection / ${{ matrix.browser }} runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8fe6318 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,62 @@ +minimum_pre_commit_version: "4.0.0" +default_install_hook_types: [pre-commit] + +# These local hooks intentionally mirror .github/workflows/ci.yml. They run +# against the whole repository so documentation-only commits cannot bypass CI. +repos: + - repo: local + hooks: + - id: ruff-lint + name: CI / Ruff lint + entry: uv run --locked --extra dev ruff check . + language: system + pass_filenames: false + always_run: true + + - id: ruff-format + name: CI / Ruff format + entry: uv run --locked --extra dev ruff format --check . + language: system + pass_filenames: false + always_run: true + + - id: mypy + name: CI / mypy + entry: uv run --locked --extra dev mypy --no-incremental src/spanselector + language: system + pass_filenames: false + always_run: true + + - id: pytest + name: CI / pytest + entry: uv run --locked --extra dev pytest + language: system + pass_filenames: false + always_run: true + + - id: build + name: CI / package build + entry: uv build + language: system + pass_filenames: false + always_run: true + + - id: e2e-chromium + name: CI / Playwright Chromium + entry: >- + uv run --locked --extra dev --extra e2e pytest tests/e2e --run-e2e + --browser chromium --no-cov --tracing retain-on-failure + --screenshot only-on-failure + language: system + pass_filenames: false + always_run: true + + - id: e2e-firefox + name: CI / Playwright Firefox + entry: >- + uv run --locked --extra dev --extra e2e pytest tests/e2e --run-e2e + --browser firefox --no-cov --tracing retain-on-failure + --screenshot only-on-failure + language: system + pass_filenames: false + always_run: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c83b05..b4ab194 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,12 +6,23 @@ strengthen the local span-annotation loop before expanding its product surface. ## Set up ```bash -uv sync --extra dev +uv sync --extra dev --extra e2e +uv run playwright install chromium firefox +uv run pre-commit install uv run spanselector ``` ## Before opening a pull request +Every commit runs the same lint, format, type, test, build, and browser checks as CI. To run the +complete gate without creating a commit: + +```bash +uv run pre-commit run --all-files +``` + +The individual commands are: + ```bash uv run ruff format --check . uv run ruff check . diff --git a/pyproject.toml b/pyproject.toml index 7b67e5c..9d2ddab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ Issues = "https://github.com/wiperpaul/span-annotator/issues" [project.optional-dependencies] dev = [ + "pre-commit>=4.0", "pytest>=7.4", "pytest-cov>=4.1", "ruff>=0.2.0", @@ -78,4 +79,4 @@ ignore_missing_imports = true where = ["src"] [tool.setuptools.package-data] -spanselector = ["data/*.csv", "schemas/*.yaml"] +spanselector = ["assets/*.css", "data/*.csv", "schemas/*.yaml"] diff --git a/src/spanselector/assets/app.css b/src/spanselector/assets/app.css new file mode 100644 index 0000000..87030f7 --- /dev/null +++ b/src/spanselector/assets/app.css @@ -0,0 +1,466 @@ +:root { + --canvas: #0c0c0f; + --surface: #151519; + --surface-raised: #1b1b21; + --border: #2b2b33; + --border-strong: #3b3b46; + --text: #f5f5f7; + --text-muted: #9c9ca8; + --primary: #7c5cfc; + --primary-soft: rgba(124, 92, 252, 0.14); +} +body { + background: var(--canvas) !important; + color: var(--text); +} +.nicegui-content { + padding: 0; +} +.app-shell { + width: min(100%, 1440px); + margin: 0 auto; + padding: 40px 32px 48px; + gap: 28px; +} +.hero { + padding: 0 4px 24px; + border-bottom: 1px solid var(--border); + animation: enter 260ms ease-out both; +} +.brand { + color: var(--text); + font-size: clamp(2rem, 5vw, 3.4rem); + font-weight: 600; + letter-spacing: -0.055em; + line-height: 1; +} +.hero-copy { + color: var(--text-muted); + font-size: 1rem; + letter-spacing: 0.01em; +} +.release-badge, .scope-badge, .count-pill { + border: 1px solid var(--border-strong); + border-radius: 999px; + background: transparent !important; + color: var(--text-muted) !important; + font-size: 10px; + letter-spacing: 0.08em; + padding: 5px 9px; +} +.workspace-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(300px, 360px); + gap: 24px; + align-items: start; + animation: enter 320ms 40ms ease-out both; +} +.content-panel, .setup-panel, .navigation-panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 20px; +} +.content-panel { + padding: 24px; + gap: 18px; +} +.setup-panel { + display: flex; + flex-direction: column; + gap: 14px; + padding: 22px; + position: sticky; + top: 20px; +} +.navigation-panel { + padding: 18px 20px 14px; + animation: enter 360ms 80ms ease-out both; +} +.panel-title { + color: var(--text); + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; +} +.section-label, .metadata-label { + color: #d6d6dc; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.supporting-text, .status-text { + color: var(--text-muted); + font-size: 12px; +} +.status-text::before { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + margin-right: 7px; + border-radius: 999px; + background: var(--primary); +} +.mono { + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, + 'Liberation Mono', monospace; + line-height: 1.6; + font-size: 14px; + margin: 0; + color: #dedee3; +} +.log-box .q-field__control { + background: #101014 !important; + border: 1px solid var(--border) !important; + border-radius: 14px; + color: var(--text); +} +.log-box textarea { + color: #e5e5e9 !important; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, + 'Liberation Mono', monospace; + font-size: 14px; + line-height: 1.6; + padding: 18px !important; +} +.selection-tray { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + min-height: 54px; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: 14px; + background: #101014; + transition: border-color 160ms ease, background-color 160ms ease; +} +.selection-tray.is-pending { + border-color: rgba(124, 92, 252, 0.55); + background: var(--primary-soft); +} +.selection-icon { + color: #8b8b96; +} +.selection-hint { + color: #6f6f7a; + font-size: 11px; +} +.selection-summary { + min-width: 0; +} +.metadata-label { + width: 52px; + color: var(--text-muted); +} +.selection-value { + min-width: 72px; + padding: 5px 9px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: rgba(12, 12, 15, 0.55); + color: #c4b5fd; + font-size: 12px; +} +.selection-text { + max-width: 100%; + overflow: hidden; + color: #e9e5ff; + text-overflow: ellipsis; + white-space: nowrap; +} +.selection-range { + flex: 0 0 auto; +} +.label-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + width: 100%; +} +.label-option { + min-width: 0; + justify-content: flex-start; + border-color: color-mix(in srgb, var(--label-color) 58%, #2b2b33) !important; + color: #e7e7ec !important; + font-size: 12px; +} +.label-option::before { + background: var(--label-color) !important; + opacity: 0.08 !important; +} +.label-option .q-btn__content { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.preview-panel { + min-height: 112px; + padding: 18px; + overflow: auto; + border: 1px solid var(--border); + border-radius: 14px; + background: #101014; +} +.annotation { + padding: 2px 4px; + border-radius: 5px; + background: var(--annotation-color); + color: #111114; + font-weight: 600; +} +.annotation-label { + margin-left: 6px; + color: #3f3f46; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; +} +.assignment-row { + min-width: 0; + padding: 8px 4px 8px 0; + border-bottom: 1px solid var(--border); +} +.assignment-text { + min-width: 0; + max-width: 120px; + overflow: hidden; + color: #d6d6dc; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} +.empty-state { + width: 100%; + padding: 18px 10px; + border: 1px dashed var(--border-strong); + border-radius: 12px; + color: var(--text-muted); + font-size: 12px; + text-align: center; +} +.q-btn { + border-radius: 999px; + transition: transform 160ms ease, background-color 160ms ease, + border-color 160ms ease; +} +.q-btn:not(.disabled):hover { + transform: translateY(-1px); +} +.primary-action { + min-height: 42px; + padding-inline: 18px; +} +.q-field--outlined .q-field__control { + border-radius: 12px; +} +.q-card { + border: 1px solid var(--border); + border-radius: 18px; + background: var(--surface-raised); + box-shadow: none; +} +.event-progress { + margin-top: 14px; + border-radius: 999px; + opacity: 0.75; +} +.shortcut-separator { + margin-top: 14px; +} +.shortcut-bar { + min-height: 38px; + padding-top: 10px; +} +.shortcut-title { + color: #d6d6dc; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.shortcut-item { + white-space: nowrap; +} +.keycap { + padding: 3px 7px; + border: 1px solid var(--border-strong); + border-bottom-color: #50505c; + border-radius: 6px; + background: #101014; + color: #d8d1ff; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; + line-height: 1.2; +} +.shortcut-description, .shortcut-reference-description, .shortcut-note { + color: var(--text-muted); + font-size: 11px; +} +.shortcut-status { + border: 1px solid var(--border-strong); + background: transparent !important; + color: var(--text-muted) !important; + font-size: 9px; +} +.shortcut-dialog-native { + width: min(92vw, 560px); + max-height: 86vh; + border: 1px solid var(--border-strong); + border-radius: 16px; + background: #17171c; + color: var(--text); + overflow-y: auto; + padding: 24px; +} +.shortcut-dialog-native::backdrop { + background: rgb(0 0 0 / 62%); + backdrop-filter: blur(2px); +} +.shortcut-dialog-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} +.shortcut-dialog-header p, .shortcut-dialog-header h2 { + margin: 0; +} +.shortcut-open-button, .shortcut-close-button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + background: transparent; + color: #b9adff; + font: inherit; + cursor: pointer; +} +.shortcut-open-button { + gap: 7px; + min-height: 32px; + padding: 4px 8px; + border-radius: 6px; +} +.shortcut-open-button:hover, .shortcut-close-button:hover { + background: rgb(124 92 252 / 12%); +} +.shortcut-open-button .material-icons { + font-size: 18px; +} +.shortcut-close-button { + width: 32px; + height: 32px; + border-radius: 50%; +} +.shortcut-toggle { + display: flex; + align-items: center; + gap: 10px; + width: fit-content; + color: var(--text); + font-size: 14px; + cursor: pointer; + user-select: none; +} +.shortcut-toggle-input { + position: relative; + width: 34px; + height: 20px; + margin: 0; + border: 1px solid var(--border-strong); + border-radius: 999px; + appearance: none; + background: #24242c; + cursor: pointer; + transition: background 140ms ease, border-color 140ms ease; +} +.shortcut-toggle-input::after { + position: absolute; + top: 3px; + left: 3px; + width: 12px; + height: 12px; + border-radius: 50%; + background: #d6d3e2; + content: ""; + transition: transform 140ms ease; +} +.shortcut-toggle-input:checked { + border-color: var(--primary); + background: var(--primary); +} +.shortcut-toggle-input:checked::after { + transform: translateX(14px); + background: white; +} +.dialog-title { + color: var(--text); + font-size: 20px; + font-weight: 600; +} +.shortcut-note { + display: block; + margin: 16px 0; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: #101014; + line-height: 1.5; +} +.shortcut-group-title { + margin: 16px 0 6px; +} +.shortcut-list { + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} +.shortcut-reference-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 42px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} +.shortcut-reference-row:last-child { + border-bottom: 0; +} +:focus-visible { + outline: 2px solid #a78bfa !important; + outline-offset: 3px; +} +@keyframes enter { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +@media (max-width: 900px) { + .app-shell { padding: 24px 16px 32px; } + .workspace-grid { grid-template-columns: 1fr; } + .setup-panel { position: static; } + .navigation-actions { width: 100%; } + .shortcut-items { gap: 8px; } + .shortcut-description { display: none; } +} +@media (max-width: 600px) { + .hero { gap: 16px; } + .scope-badge { display: none; } + .content-panel, .setup-panel { padding: 18px; } + .navigation-actions .q-btn { flex: 1 1 auto; } + .label-grid { grid-template-columns: 1fr 1fr; } + .shortcut-item:nth-of-type(n+4) { display: none; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/spanselector/browser.py b/src/spanselector/browser.py new file mode 100644 index 0000000..c8404aa --- /dev/null +++ b/src/spanselector/browser.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +WORKSPACE_SHORTCUT_EVENT_JS = """ +(event) => { + if (window.spanselectorShortcutsEnabled === false) return; + + const target = event.target; + const sourceTextarea = target instanceof HTMLTextAreaElement + && target.readOnly + && target.closest('.log-box'); + const editable = target instanceof HTMLInputElement + || target instanceof HTMLSelectElement + || (target instanceof HTMLTextAreaElement && !sourceTextarea) + || target?.isContentEditable; + if (editable) return; + + const primary = event.ctrlKey || event.metaKey; + let command = null; + if (sourceTextarea + && !primary + && !event.altKey + && !event.shiftKey + && /^Digit[1-9]$/.test(event.code)) { + command = `label:${event.code.slice(-1)}`; + } else if (sourceTextarea && event.key === 'Escape') { + command = 'clear-selection'; + } else if (primary && !event.altKey && event.code === 'KeyZ') { + command = 'undo'; + } else if (primary && !event.altKey && event.code === 'KeyS') { + command = 'save'; + } else if (primary && !event.altKey && event.code === 'Enter') { + command = 'save-next'; + } else if (primary && !event.altKey && event.code === 'Slash') { + command = 'show-shortcuts'; + } + + if (!command) return; + event.preventDefault(); + event.stopPropagation(); + emit({command}); +} +""" + +CLEAR_BROWSER_SELECTION_JS = """ +(() => { + const textarea = document.querySelector('.log-box textarea'); + if (!textarea) return; + const caret = textarea.selectionEnd ?? 0; + textarea.setSelectionRange(caret, caret); + window.getSelection()?.removeAllRanges(); +})() +""" + +Shortcut = tuple[str, str | None, str] + +# A shortcut is declared once as (keys, compact label, full description). +SHORTCUTS: dict[str, list[Shortcut]] = { + "Selection": [ + ("1–9", "Label", "Apply the matching visible label"), + ("Esc", "Clear", "Clear the pending selection"), + ("Ctrl/⌘ Z", "Undo", "Undo the most recent assignment"), + ], + "Navigation": [ + ("Ctrl/⌘ Enter", "Save + next", "Save the draft and move to the next event"), + ], + "Workspace": [ + ("Ctrl/⌘ S", None, "Save the current draft"), + ("Ctrl/⌘ /", None, "Open this shortcut reference"), + ], +} + +SHORTCUT_BAR_ITEMS = [ + (keys, compact_label) + for shortcuts in SHORTCUTS.values() + for keys, compact_label, _description in shortcuts + if compact_label is not None +] + +SHORTCUT_GROUPS = { + group: [(keys, description) for keys, _compact_label, description in shortcuts] + for group, shortcuts in SHORTCUTS.items() +} diff --git a/src/spanselector/configuration.py b/src/spanselector/configuration.py index 3c2100e..d863803 100644 --- a/src/spanselector/configuration.py +++ b/src/spanselector/configuration.py @@ -30,6 +30,7 @@ class Settings(BaseSettings): host: str = Field(default="127.0.0.1", description="Application bind address") port: int = Field(default=8080, ge=1, le=65535, description="Application port") reload: bool = Field(default=False, description="Enable NiceGUI development reload") + show: bool = Field(default=True, description="Open the workbench in the default browser") log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" model_config = { diff --git a/src/spanselector/main.py b/src/spanselector/main.py index f5ffac9..c689be4 100644 --- a/src/spanselector/main.py +++ b/src/spanselector/main.py @@ -1,1263 +1,23 @@ from __future__ import annotations import logging -from html import escape -from pathlib import Path from nicegui import ui from .configuration import settings -from .data_importer import load_events -from .exporter import annotations_to_jsonl -from .models import AppState, Assignment, UIRefs -from .schema_loader import load_schemas_from_dir -from .selection import CAPTURE_SELECTION_EVENT_JS, apply_selection_payload - -logging.basicConfig(level=getattr(logging, settings.log_level)) - -# Load schemas (defaults + YAML files) -SCHEMAS = load_schemas_from_dir(settings.schema_dir) - -WORKSPACE_SHORTCUT_EVENT_JS = """ -(event) => { - if (window.spanselectorShortcutsEnabled === false) return; - - const target = event.target; - const sourceTextarea = target instanceof HTMLTextAreaElement - && target.readOnly - && target.closest('.log-box'); - const editable = target instanceof HTMLInputElement - || target instanceof HTMLSelectElement - || (target instanceof HTMLTextAreaElement && !sourceTextarea) - || target?.isContentEditable; - if (editable) return; - - const primary = event.ctrlKey || event.metaKey; - let command = null; - if (sourceTextarea - && !primary - && !event.altKey - && !event.shiftKey - && /^Digit[1-9]$/.test(event.code)) { - command = `label:${event.code.slice(-1)}`; - } else if (sourceTextarea && event.key === 'Escape') { - command = 'clear-selection'; - } else if (primary && !event.altKey && event.code === 'KeyZ') { - command = 'undo'; - } else if (primary && !event.altKey && event.code === 'KeyS') { - command = 'save'; - } else if (primary && !event.altKey && event.code === 'Enter') { - command = 'save-next'; - } else if (primary && !event.altKey && event.code === 'Slash') { - command = 'show-shortcuts'; - } - - if (!command) return; - event.preventDefault(); - event.stopPropagation(); - emit({command}); -} -""" - -CLEAR_BROWSER_SELECTION_JS = """ -(() => { - const textarea = document.querySelector('.log-box textarea'); - if (!textarea) return; - const caret = textarea.selectionEnd ?? 0; - textarea.setSelectionRange(caret, caret); - window.getSelection()?.removeAllRanges(); -})() -""" - -SHORTCUT_GROUPS = { - "Selection": [ - ("1–9", "Apply the matching visible label"), - ("Esc", "Clear the pending selection"), - ("Ctrl/⌘ Z", "Undo the most recent assignment"), - ], - "Navigation": [ - ("Ctrl/⌘ Enter", "Save the draft and move to the next event"), - ], - "Workspace": [ - ("Ctrl/⌘ S", "Save the current draft"), - ("Ctrl/⌘ /", "Open this shortcut reference"), - ], -} - - -def _select_default_schema() -> str: - if settings.schema_name in SCHEMAS: - return settings.schema_name - return next(iter(SCHEMAS)) - - -def build_initial_state() -> AppState: - events = load_events(Path(settings.data_path)) - saved_assignments: dict[str, list[Assignment]] = {event.id: [] for event in events} - selected_schema = _select_default_schema() - schema_fields = list(SCHEMAS[selected_schema].fields) - selected_field = schema_fields[0] - return AppState( - events=events, - saved_assignments=saved_assignments, - selected_schema=selected_schema, - schema_fields=schema_fields, - selected_field=selected_field, - ) - - -state = build_initial_state() -refs = UIRefs() - - -def get_field_colors() -> dict[str, str]: - """Get color palette for the currently selected schema.""" - return SCHEMAS[state.selected_schema].colors - - -def clone_assignments(assignments: list[Assignment]) -> list[Assignment]: - return [a.model_copy() for a in assignments] - - -def update_nav_state() -> None: - if refs.event_index_label is not None: - refs.event_index_label.text = f"Event {state.current_index + 1} of {len(state.events)}" - if refs.event_id_label is not None: - refs.event_id_label.text = state.current_event().id - if refs.status_label is not None: - refs.status_label.text = "Unsaved changes" if state.dirty else "Draft kept in session" - if refs.progress_bar is not None: - refs.progress_bar.value = (state.current_index + 1) / len(state.events) - if refs.previous_button is not None: - refs.previous_button.set_enabled(state.current_index > 0) - if refs.next_button is not None: - refs.next_button.set_enabled(state.current_index < len(state.events) - 1) - - -def total_assignment_count() -> int: - """Count saved assignments, substituting the active draft for the current event.""" - current_event_id = state.current_event().id - return sum( - len(state.assignment_manager.get_all()) - if event.id == current_event_id - else len(state.saved_assignments.get(event.id, [])) - for event in state.events - ) - - -def update_counts() -> None: - count = len(state.assignment_manager.get_all()) - if refs.total_assignments_label is not None: - refs.total_assignments_label.text = f"{total_assignment_count()} total" - if refs.count_label is not None: - refs.count_label.text = f"{count} on this event" - - -def set_dirty(value: bool) -> None: - state.dirty = value - update_nav_state() - - -def clear_selection_state(*, refresh: bool = True) -> None: - state.selection.start = 0 - state.selection.end = 0 - state.selection.text = "" - if refresh: - selection_action_tray.refresh() - - -def set_selected_field(value: str) -> None: - state.selected_field = value - if refs.selected_field_label is not None: - refs.selected_field_label.text = f"Selected: {value}" - - -def has_any_saved_assignments() -> bool: - if state.assignment_manager.get_all(): - return True - return any(state.saved_assignments.get(event.id) for event in state.events) - - -def handle_selection_change(e) -> None: - """Handle selection change events and update UI.""" - if isinstance(e.args, dict): - try: - if int(e.args.get("end", 0)) <= int(e.args.get("start", 0)): - return - except (TypeError, ValueError): - pass - if not apply_selection_payload(state.selection, e.args): - return - selection_action_tray.refresh() - - -def add_assignment(field: str | None = None, *, notify_missing: bool = True) -> Assignment | None: - if state.selection.end <= state.selection.start: - if notify_missing: - ui.notify("Select a text span first", color="warning") - return None - - target_field = field or state.selected_field - if target_field not in state.schema_fields: - ui.notify(f"Unknown label: {target_field}", color="negative") - return None - - selected_text = state.current_event().text[state.selection.start : state.selection.end] - if selected_text != state.selection.text: - ui.notify("Selection is stale; select the span again", color="warning") - return None - - try: - new_assignment = Assignment( - start=state.selection.start, - end=state.selection.end, - text=state.selection.text, - field=target_field, - ) - state.assignment_manager.add(new_assignment) - state.undo_stack.append(new_assignment.model_copy()) - set_selected_field(target_field) - clear_selection_state() - set_dirty(True) - refresh_views() - ui.notify( - f"Assigned '{new_assignment.text}' to {new_assignment.field}", - color="positive", - ) - return new_assignment - except ValueError as e: - ui.notify(f"Invalid assignment: {e}", color="negative") - return None - - -async def assign_selected_span(field: str, *, notify_missing: bool = True) -> None: - if add_assignment(field, notify_missing=notify_missing) is not None: - await ui.run_javascript(CLEAR_BROWSER_SELECTION_JS) - - -def undo_last_assignment() -> None: - while state.undo_stack: - assignment = state.undo_stack.pop() - if not state.assignment_manager.remove_exact(assignment): - continue - set_dirty(True) - refresh_views() - ui.notify(f"Removed {assignment.field} assignment", color="info") - return - ui.notify("Nothing to undo on this event", color="warning") - - -async def handle_workspace_shortcut(e) -> None: - if not isinstance(e.args, dict): - return - - command = e.args.get("command") - if isinstance(command, str) and command.startswith("label:"): - index = int(command.removeprefix("label:")) - 1 - if 0 <= index < len(state.schema_fields): - await assign_selected_span(state.schema_fields[index], notify_missing=False) - return - if command == "clear-selection": - clear_selection_state() - await ui.run_javascript(CLEAR_BROWSER_SELECTION_JS) - elif command == "undo": - undo_last_assignment() - elif command == "save": - save_current() - elif command == "save-next": - # Firefox can restore focused-control DOM after keydown. Navigate on the - # next event-loop turn so the new event renders as one coherent update. - ui.timer(0.01, save_and_move_next, once=True) - elif command == "show-shortcuts": - await ui.run_javascript("document.querySelector('.shortcut-dialog-native')?.showModal()") - - -def remove_assignment(index: int) -> None: - try: - state.assignment_manager.remove(index) - set_dirty(True) - refresh_views() - except IndexError as e: - logging.warning("Tried to remove assignment with invalid index %s: %s", index, e) - - -def save_current() -> None: - event_id = state.current_event().id - state.saved_assignments[event_id] = clone_assignments(state.assignment_manager.get_all()) - set_dirty(False) - ui.notify("Draft kept for this session", color="positive") - - -def save_and_move_next() -> None: - """Persist the current draft and advance without a confirmation round trip.""" - event_id = state.current_event().id - state.saved_assignments[event_id] = clone_assignments(state.assignment_manager.get_all()) - if state.current_index >= len(state.events) - 1: - set_dirty(False) - ui.notify("Draft kept for this session", color="positive") - return - navigate(1) - ui.notify("Draft saved; moved to the next event", color="positive") - - -def export_annotations() -> None: - """Include the active draft and download the full labeled dataset as JSONL.""" - event_id = state.current_event().id - state.saved_assignments[event_id] = clone_assignments(state.assignment_manager.get_all()) - set_dirty(False) - payload = annotations_to_jsonl( - state.events, - state.saved_assignments, - schema_name=state.selected_schema, - ) - ui.download( - payload, filename="spanselector-annotations.jsonl", media_type="application/x-ndjson" - ) - ui.notify("Annotation export downloaded", color="positive") - - -def discard_current() -> None: - state.assignment_manager.replace_all( - clone_assignments(state.saved_assignments.get(state.current_event().id, [])) - ) - state.undo_stack.clear() - set_dirty(False) - refresh_views() - - -def load_event(index: int, *, refresh: bool = True) -> None: - state.current_index = index - state.assignment_manager.replace_all( - clone_assignments(state.saved_assignments.get(state.current_event().id, [])) - ) - state.undo_stack.clear() - clear_selection_state(refresh=False) - state.dirty = False - if refresh: - event_workspace.refresh() - assignment_section.refresh() - update_counts() - event_navigation_controls.refresh() - else: - if refs.text_box is not None: - refs.text_box.value = state.current_event().text - update_counts() - update_nav_state() - - -def navigate(delta: int) -> None: - new_index = state.current_index + delta - if new_index < 0 or new_index >= len(state.events): - ui.notify("No more events", color="warning") - return - load_event(new_index) - - -def request_navigation(delta: int) -> None: - if not state.dirty: - navigate(delta) - return - state.pending_nav_delta = delta - if refs.confirm_dialog is not None: - refs.confirm_dialog.open() - - -def save_and_navigate() -> None: - if refs.confirm_dialog is not None: - refs.confirm_dialog.close() - save_current() - navigate(state.pending_nav_delta) - - -def discard_and_navigate() -> None: - if refs.confirm_dialog is not None: - refs.confirm_dialog.close() - navigate(state.pending_nav_delta) - - -def apply_schema_change(new_schema: str, *, discard_existing: bool) -> None: - if discard_existing: - for event in state.events: - state.saved_assignments[event.id] = [] - state.assignment_manager.replace_all([]) - set_dirty(False) - state.undo_stack.clear() - clear_selection_state() - state.selected_schema = new_schema - state.schema_fields = list(SCHEMAS[state.selected_schema].fields) - set_selected_field(state.schema_fields[0]) - - -def render_annotated_text(raw: str, spans: list[Assignment]) -> str: - if not spans: - return f"
{escape(raw)}
" - - parts: list[str] = [] - cursor = 0 - colors = get_field_colors() - - for a in sorted(spans, key=lambda x: x.start): - parts.append(escape(raw[cursor : a.start])) - color = colors.get(a.field, "#fff3bf") - marked = escape(raw[a.start : a.end]) - parts.append( - f"" - f"{marked}{escape(a.field)}" - "" - ) - cursor = a.end - - parts.append(escape(raw[cursor:])) - return f"
{''.join(parts)}
" - - -@ui.refreshable -def annotated_preview() -> None: - ui.html( - render_annotated_text( - state.current_event().text, - state.assignment_manager.get_all(), - ) - ).classes("preview-panel w-full") - - -@ui.refreshable -def assignment_rows() -> None: - colors = get_field_colors() - assignments = state.assignment_manager.get_all() - if not assignments: - ui.label("No spans assigned yet").classes("empty-state") - return - for i, a in enumerate(assignments): - with ui.row().classes("assignment-row items-center gap-2 w-full"): - color = colors.get(a.field, "#fff3bf") - ui.badge(a.field, color=color, text_color="#222") - ui.label(f"{a.start}..{a.end}").classes("text-xs text-grey-6") - ui.label(a.text.replace("\n", "↵")).classes("assignment-text") - ui.space() - ui.button(icon="close", on_click=lambda _=None, idx=i: remove_assignment(idx)).props( - "flat round dense color=negative aria-label='Remove annotation'" - ).tooltip("Remove annotation") - - -def refresh_views() -> None: - annotated_preview.refresh() - assignment_section.refresh() - update_counts() - update_nav_state() - - -@ui.refreshable -def selection_action_tray() -> None: - has_selection = state.selection.end > state.selection.start and bool(state.selection.text) - tray_class = "selection-tray is-pending" if has_selection else "selection-tray is-idle" - with ui.element("section").classes(tray_class).props("aria-live=polite"): - if not has_selection: - with ui.row().classes("items-center justify-between gap-3 w-full"): - with ui.row().classes("items-center gap-2"): - ui.icon("format_quote", size="18px").classes("selection-icon") - ui.label("Select text to reveal labels").classes("supporting-text") - ui.label("Drag or use Shift + Arrow").classes("selection-hint") - return - - with ui.row().classes("selection-summary items-start justify-between gap-3 w-full"): - with ui.column().classes("gap-1 min-w-0"): - ui.label("Selected span").classes("section-label") - refs.selected_text_label = ui.label(state.selection.text).classes( - "selection-value selection-text font-mono" - ) - refs.selected_range_label = ui.label( - f"{state.selection.start}..{state.selection.end}" - ).classes("selection-value selection-range font-mono") - - ui.label("Apply a label").classes("section-label") - with ui.element("div").classes("label-grid"): - colors = get_field_colors() - for index, field in enumerate(state.schema_fields, start=1): - - async def apply_field(_event=None, *, target_field: str = field) -> None: - await assign_selected_span(target_field) - - shortcut = str(index) if index <= 9 else "" - button_text = f"{shortcut} {field}" if shortcut else field - button = ( - ui.button(button_text, on_click=apply_field) - .classes("label-option") - .props("outline no-caps") - .style(f"--label-color: {colors.get(field, '#7c5cfc')}") - ) - if shortcut: - button.props["aria-keyshortcuts"] = shortcut - - -def build_header() -> None: - with ui.row().classes("hero items-start justify-between w-full"): - with ui.column().classes("gap-1"): - with ui.row().classes("items-center gap-2"): - ui.label("spanselector").classes("brand") - ui.badge("ALPHA").classes("release-badge") - ui.label("Turn unstructured text into reviewable training data.").classes("hero-copy") - ui.badge("LOCAL · SESSION ONLY", color="grey-9").classes("scope-badge") - - -def build_sidebar() -> None: - ui.label("Annotation setup").classes("panel-title") - ui.label("Choose a schema, then label selections beside the source text.").classes( - "supporting-text" - ) - ui.separator() - with ui.row().classes("items-center justify-between w-full"): - ui.label("Schema").classes("section-label") - refs.total_assignments_label = ui.label(f"{total_assignment_count()} total").classes( - "count-pill" - ) - - def on_schema_change(e) -> None: - new_schema = e.value - if new_schema == state.selected_schema: - return - if state.dirty or has_any_saved_assignments(): - state.pending_schema = new_schema - if refs.schema_confirm_dialog is not None: - refs.schema_confirm_dialog.open() - schema_select.value = state.selected_schema - return - apply_schema_change(new_schema, discard_existing=False) - selection_action_tray.refresh() - refresh_views() - - schema_select = ui.select( - list(SCHEMAS.keys()), - value=state.selected_schema, - on_change=on_schema_change, - ).classes("w-full") - - build_schema_confirm_dialog(schema_select) - - assignment_section() - - -@ui.refreshable -def assignment_section() -> None: - ui.separator() - with ui.row().classes("items-center justify-between w-full"): - ui.label("Assignments").classes("section-label") - refs.count_label = ui.label( - f"{len(state.assignment_manager.get_all())} on this event" - ).classes("supporting-text") - with ui.column().classes("w-full gap-2 mt-2 max-h-96 overflow-y-auto"): - assignment_rows() - - -@ui.refreshable -def event_workspace() -> None: - with ui.row().classes("items-center justify-between w-full"): - ui.label("Source text").classes("panel-title") - ui.label("Drag across text to select a span").classes("supporting-text") - - refs.text_box = ( - ui.textarea(value=state.current_event().text) - .props("outlined readonly rows=10") - .classes("w-full log-box") - ) - refs.text_box.on( - "select", - handle_selection_change, - throttle=0.12, - leading_events=False, - trailing_events=True, - js_handler=CAPTURE_SELECTION_EVENT_JS, - ) - refs.text_box.on( - "keyup", - handle_selection_change, - js_handler=CAPTURE_SELECTION_EVENT_JS, - ) - - selection_action_tray() - - ui.separator() - ui.label("Annotated preview").classes("panel-title") - annotated_preview() - - -def build_main_panel() -> None: - with ui.element("main").classes("workspace-grid w-full"): - with ui.column().classes("content-panel min-w-0"): - event_workspace() - - with ui.element("aside").classes("setup-panel"): - build_sidebar() - - -@ui.refreshable -def shortcut_bar() -> None: - with ui.row().classes("shortcut-bar items-center justify-between gap-3 w-full"): - with ui.row().classes("shortcut-items items-center gap-4"): - ui.label("Shortcuts").classes("shortcut-title") - for keys, description in [ - ("1–9", "Label"), - ("Esc", "Clear"), - ("Ctrl/⌘ Z", "Undo"), - ("Ctrl/⌘ Enter", "Save + next"), - ]: - with ui.row().classes("shortcut-item items-center gap-1"): - ui.label(keys).classes("keycap") - ui.label(description).classes("shortcut-description") - with ui.row().classes("items-center gap-2"): - ui.badge("On").classes("shortcut-status") - ui.html( - """ - - """, - sanitize=False, - ) - - -@ui.refreshable -def event_navigation_controls() -> None: - with ui.row().classes("items-center justify-between gap-3 w-full"): - with ui.column().classes("gap-0"): - refs.event_index_label = ui.label("").classes("panel-title") - with ui.row().classes("items-center gap-2"): - refs.event_id_label = ui.label("").classes("supporting-text font-mono") - refs.status_label = ui.label("").classes("status-text") - with ui.row().classes("navigation-actions items-center gap-2"): - refs.previous_button = ui.button( - "Previous", icon="arrow_back", on_click=lambda: request_navigation(-1) - ).props("outline no-caps") - refs.next_button = ui.button( - "Next", icon="arrow_forward", on_click=lambda: request_navigation(1) - ).props("outline no-caps icon-right") - ui.button("Save draft", icon="bookmark", on_click=save_current).props( - "flat no-caps" - ).tooltip("Keep this event's annotations in memory") - ui.button( - "Export JSONL", - icon="download", - on_click=export_annotations, - color="primary", - ).classes("primary-action").props("no-caps unelevated") - refs.progress_bar = ui.linear_progress( - value=(state.current_index + 1) / len(state.events), - color="primary", - size="4px", - show_value=False, - ).classes("event-progress") - update_nav_state() - - -def build_navigation() -> None: - with ui.element("section").classes("navigation-panel w-full"): - event_navigation_controls() - ui.separator().classes("shortcut-separator") - shortcut_bar() - - -def build_shortcuts_dialog() -> None: - groups = "".join( - f'

{escape(group)}

' - '
' - + "".join( - '
' - f'{escape(description)}' - f'{escape(keys)}' - "
" - for keys, description in shortcuts - ) - + "
" - for group, shortcuts in SHORTCUT_GROUPS.items() - ) - ui.html( - f""" - -
-
-

Keyboard shortcuts

-

Keep labeling actions close to the source text.

-
- -
- -

- Number keys only apply labels while the source text has focus. - Shortcuts are ignored in editable controls and dialogs. -

- {groups} -
- """, - sanitize=False, - ) - - -def build_confirm_dialog() -> None: - refs.confirm_dialog = ui.dialog() - with refs.confirm_dialog: - with ui.card().classes("w-96"): - ui.label("Unsaved changes").classes("text-subtitle2 font-semibold") - ui.label("Save changes before moving to another event?") - with ui.row().classes("justify-end gap-2"): - ui.button("Cancel", on_click=refs.confirm_dialog.close) - ui.button("Discard", on_click=discard_and_navigate).props("flat color=warning") - ui.button("Save", on_click=save_and_navigate, color="primary") - - -def build_schema_confirm_dialog(schema_select) -> None: - refs.schema_confirm_dialog = ui.dialog() - with refs.schema_confirm_dialog: - with ui.card().classes("w-96"): - ui.label("Discard existing annotations?").classes("text-subtitle2 font-semibold") - ui.label("Changing the schema will discard all saved annotations. Continue?") - with ui.row().classes("justify-end gap-2"): - - def cancel_change() -> None: - if refs.schema_confirm_dialog is not None: - refs.schema_confirm_dialog.close() - state.pending_schema = None - - ui.button("Cancel", on_click=cancel_change) - - def discard_and_change() -> None: - if refs.schema_confirm_dialog is not None: - refs.schema_confirm_dialog.close() - if state.pending_schema is None: - return - apply_schema_change(state.pending_schema, discard_existing=True) - schema_select.value = state.selected_schema - selection_action_tray.refresh() - refresh_views() - state.pending_schema = None - - ui.button("Discard & Switch", on_click=discard_and_change).props( - "flat color=warning" - ) - - -def create_ui() -> None: - ui.dark_mode(True) - ui.colors( - primary="#7C5CFC", - secondary="#2DD4BF", - accent="#A78BFA", - positive="#22C55E", - negative="#FB7185", - info="#38BDF8", - warning="#F59E0B", - ) - ui.add_head_html( - """ - - """ - ) - refs.app_shell = ( - ui.column() - .classes("app-shell") - .props("tabindex=0") - .on( - "keydown", - handle_workspace_shortcut, - js_handler=WORKSPACE_SHORTCUT_EVENT_JS, - ) - ) - with refs.app_shell: - build_header() - build_main_panel() - build_navigation() - build_confirm_dialog() - build_shortcuts_dialog() - load_event(state.current_index, refresh=False) +from .web_ui import create_ui def run() -> None: """Start the local annotation workbench.""" + logging.basicConfig(level=getattr(logging, settings.log_level)) ui.run( create_ui, title="spanselector", host=settings.host, port=settings.port, reload=settings.reload, + show=settings.show, ) diff --git a/src/spanselector/presentation.py b/src/spanselector/presentation.py new file mode 100644 index 0000000..245975e --- /dev/null +++ b/src/spanselector/presentation.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from html import escape + +from .models import Assignment + + +def render_annotated_text( + raw: str, + spans: Sequence[Assignment], + colors: Mapping[str, str], +) -> str: + """Render escaped source text with its annotations highlighted.""" + if not spans: + return f"
{escape(raw)}
" + + parts: list[str] = [] + cursor = 0 + for assignment in sorted(spans, key=lambda item: item.start): + parts.append(escape(raw[cursor : assignment.start])) + color = colors.get(assignment.field, "#fff3bf") + marked = escape(raw[assignment.start : assignment.end]) + parts.append( + f"" + f"{marked}{escape(assignment.field)}" + "" + ) + cursor = assignment.end + + parts.append(escape(raw[cursor:])) + return f"
{''.join(parts)}
" + + +def render_shortcut_dialog(groups: Mapping[str, Sequence[tuple[str, str]]]) -> str: + """Render the native shortcut reference dialog.""" + rendered_groups = "".join( + f'

{escape(group)}

' + '
' + + "".join( + '
' + f'{escape(description)}' + f'{escape(keys)}' + "
" + for keys, description in shortcuts + ) + + "
" + for group, shortcuts in groups.items() + ) + return f""" + +
+
+

Keyboard shortcuts

+

Keep labeling actions close to the source text.

+
+ +
+ +

+ Number keys only apply labels while the source text has focus. + Shortcuts are ignored in editable controls and dialogs. +

+ {rendered_groups} +
+ """ diff --git a/src/spanselector/session.py b/src/spanselector/session.py new file mode 100644 index 0000000..631c733 --- /dev/null +++ b/src/spanselector/session.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +from .models import AppState, Assignment, EventRecord, SchemaDefinition + + +class SelectionRequiredError(ValueError): + """Raised when an assignment is requested without a selected span.""" + + +class StaleSelectionError(ValueError): + """Raised when the selected text no longer matches the current event.""" + + +class UnknownLabelError(ValueError): + """Raised when an assignment refers to a label outside the active schema.""" + + +def _clone_assignments(assignments: Sequence[Assignment]) -> list[Assignment]: + return [assignment.model_copy() for assignment in assignments] + + +class AnnotationSession: + """Framework-free state transitions for an annotation session.""" + + def __init__( + self, + state: AppState, + schemas: Mapping[str, SchemaDefinition], + ) -> None: + self.state = state + self.schemas = schemas + + @classmethod + def create( + cls, + events: Sequence[EventRecord], + schemas: Mapping[str, SchemaDefinition], + preferred_schema: str, + ) -> AnnotationSession: + if not schemas: + raise ValueError("At least one annotation schema is required") + + selected_schema = preferred_schema if preferred_schema in schemas else next(iter(schemas)) + schema_fields = list(schemas[selected_schema].fields) + event_list = list(events) + state = AppState( + events=event_list, + saved_assignments={event.id: [] for event in event_list}, + selected_schema=selected_schema, + schema_fields=schema_fields, + selected_field=schema_fields[0], + ) + return cls(state, schemas) + + @property + def field_colors(self) -> dict[str, str]: + return self.schemas[self.state.selected_schema].colors + + def total_assignment_count(self) -> int: + """Count saved assignments, substituting the active event's draft.""" + current_event_id = self.state.current_event().id + return sum( + len(self.state.assignment_manager.get_all()) + if event.id == current_event_id + else len(self.state.saved_assignments.get(event.id, [])) + for event in self.state.events + ) + + def has_assignments(self) -> bool: + return bool(self.state.assignment_manager.get_all()) or any( + self.state.saved_assignments.get(event.id) for event in self.state.events + ) + + def clear_selection(self) -> None: + self.state.selection.start = 0 + self.state.selection.end = 0 + self.state.selection.text = "" + + def add_assignment(self, field: str | None = None) -> Assignment: + selection = self.state.selection + if selection.end <= selection.start: + raise SelectionRequiredError("Select a text span first") + + target_field = field or self.state.selected_field + if target_field not in self.state.schema_fields: + raise UnknownLabelError(f"Unknown label: {target_field}") + + selected_text = self.state.current_event().text[selection.start : selection.end] + if selected_text != selection.text: + raise StaleSelectionError("Selection is stale; select the span again") + + assignment = Assignment( + start=selection.start, + end=selection.end, + text=selection.text, + field=target_field, + ) + self.state.assignment_manager.add(assignment) + self.state.undo_stack.append(assignment.model_copy()) + self.state.selected_field = target_field + self.clear_selection() + self.state.dirty = True + return assignment + + def undo_last_assignment(self) -> Assignment | None: + while self.state.undo_stack: + assignment = self.state.undo_stack.pop() + if not self.state.assignment_manager.remove_exact(assignment): + continue + self.state.dirty = True + return assignment + return None + + def remove_assignment(self, index: int) -> None: + self.state.assignment_manager.remove(index) + self.state.dirty = True + + def save_current(self) -> None: + event_id = self.state.current_event().id + self.state.saved_assignments[event_id] = _clone_assignments( + self.state.assignment_manager.get_all() + ) + self.state.dirty = False + + def discard_current(self) -> None: + saved = self.state.saved_assignments.get(self.state.current_event().id, []) + self.state.assignment_manager.replace_all(_clone_assignments(saved)) + self.state.undo_stack.clear() + self.state.dirty = False + + def load_event(self, index: int) -> None: + if not 0 <= index < len(self.state.events): + raise IndexError("event index out of range") + self.state.current_index = index + saved = self.state.saved_assignments.get(self.state.current_event().id, []) + self.state.assignment_manager.replace_all(_clone_assignments(saved)) + self.state.undo_stack.clear() + self.clear_selection() + self.state.dirty = False + + def apply_schema_change(self, new_schema: str, *, discard_existing: bool) -> None: + if new_schema not in self.schemas: + raise ValueError(f"Unknown schema: {new_schema}") + if discard_existing: + for event in self.state.events: + self.state.saved_assignments[event.id] = [] + self.state.assignment_manager.replace_all([]) + self.state.dirty = False + self.state.undo_stack.clear() + self.clear_selection() + self.state.selected_schema = new_schema + self.state.schema_fields = list(self.schemas[new_schema].fields) + self.state.selected_field = self.state.schema_fields[0] diff --git a/src/spanselector/theme.py b/src/spanselector/theme.py new file mode 100644 index 0000000..29ff60a --- /dev/null +++ b/src/spanselector/theme.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from importlib.resources import files + +from nicegui import ui + +THEME_COLORS = { + "primary": "#7C5CFC", + "secondary": "#2DD4BF", + "accent": "#A78BFA", + "positive": "#22C55E", + "negative": "#FB7185", + "info": "#38BDF8", + "warning": "#F59E0B", +} + + +def load_stylesheet() -> str: + """Load the packaged application stylesheet.""" + return files("spanselector").joinpath("assets", "app.css").read_text(encoding="utf-8") + + +def configure_theme() -> None: + """Apply the visual theme to the current NiceGUI page.""" + ui.dark_mode(True) + ui.colors(**THEME_COLORS) + ui.add_head_html(f"") diff --git a/src/spanselector/web_ui.py b/src/spanselector/web_ui.py new file mode 100644 index 0000000..6e76f9b --- /dev/null +++ b/src/spanselector/web_ui.py @@ -0,0 +1,572 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +from nicegui import ui + +from .browser import ( + CLEAR_BROWSER_SELECTION_JS, + SHORTCUT_BAR_ITEMS, + SHORTCUT_GROUPS, + WORKSPACE_SHORTCUT_EVENT_JS, +) +from .configuration import settings +from .data_importer import load_events +from .exporter import annotations_to_jsonl +from .models import Assignment, UIRefs +from .presentation import render_annotated_text, render_shortcut_dialog +from .schema_loader import load_schemas_from_dir +from .selection import CAPTURE_SELECTION_EVENT_JS, apply_selection_payload +from .session import ( + AnnotationSession, + SelectionRequiredError, + StaleSelectionError, + UnknownLabelError, +) +from .theme import configure_theme + +logger = logging.getLogger(__name__) + +# Load schemas (defaults + YAML files) +SCHEMAS = load_schemas_from_dir(settings.schema_dir) +session = AnnotationSession.create( + load_events(Path(settings.data_path)), + SCHEMAS, + settings.schema_name, +) +state = session.state +refs = UIRefs() + + +def get_field_colors() -> dict[str, str]: + """Get color palette for the currently selected schema.""" + return session.field_colors + + +def update_nav_state() -> None: + if refs.event_index_label is not None: + refs.event_index_label.text = f"Event {state.current_index + 1} of {len(state.events)}" + if refs.event_id_label is not None: + refs.event_id_label.text = state.current_event().id + if refs.status_label is not None: + refs.status_label.text = "Unsaved changes" if state.dirty else "Draft kept in session" + if refs.progress_bar is not None: + refs.progress_bar.value = (state.current_index + 1) / len(state.events) + if refs.previous_button is not None: + refs.previous_button.set_enabled(state.current_index > 0) + if refs.next_button is not None: + refs.next_button.set_enabled(state.current_index < len(state.events) - 1) + + +def total_assignment_count() -> int: + return session.total_assignment_count() + + +def update_counts() -> None: + count = len(state.assignment_manager.get_all()) + if refs.total_assignments_label is not None: + refs.total_assignments_label.text = f"{total_assignment_count()} total" + if refs.count_label is not None: + refs.count_label.text = f"{count} on this event" + + +def clear_selection_state(*, refresh: bool = True) -> None: + session.clear_selection() + if refresh: + selection_action_tray.refresh() + + +def update_selected_field_label() -> None: + if refs.selected_field_label is not None: + refs.selected_field_label.text = f"Selected: {state.selected_field}" + + +def has_any_saved_assignments() -> bool: + return session.has_assignments() + + +def handle_selection_change(e) -> None: + """Handle selection change events and update UI.""" + if isinstance(e.args, dict): + try: + if int(e.args.get("end", 0)) <= int(e.args.get("start", 0)): + return + except (TypeError, ValueError): + pass + if not apply_selection_payload(state.selection, e.args): + return + selection_action_tray.refresh() + + +def add_assignment(field: str | None = None, *, notify_missing: bool = True) -> Assignment | None: + try: + new_assignment = session.add_assignment(field) + except SelectionRequiredError as error: + if notify_missing: + ui.notify(str(error), color="warning") + return None + except UnknownLabelError as error: + ui.notify(str(error), color="negative") + return None + except StaleSelectionError as error: + ui.notify(str(error), color="warning") + return None + except ValueError as error: + ui.notify(f"Invalid assignment: {error}", color="negative") + return None + + update_selected_field_label() + selection_action_tray.refresh() + refresh_views() + ui.notify( + f"Assigned '{new_assignment.text}' to {new_assignment.field}", + color="positive", + ) + return new_assignment + + +async def assign_selected_span(field: str, *, notify_missing: bool = True) -> None: + if add_assignment(field, notify_missing=notify_missing) is not None: + await ui.run_javascript(CLEAR_BROWSER_SELECTION_JS) + + +def undo_last_assignment() -> None: + assignment = session.undo_last_assignment() + if assignment is None: + ui.notify("Nothing to undo on this event", color="warning") + return + refresh_views() + ui.notify(f"Removed {assignment.field} assignment", color="info") + + +async def handle_workspace_shortcut(e) -> None: + if not isinstance(e.args, dict): + return + + command = e.args.get("command") + if isinstance(command, str) and command.startswith("label:"): + index = int(command.removeprefix("label:")) - 1 + if 0 <= index < len(state.schema_fields): + await assign_selected_span(state.schema_fields[index], notify_missing=False) + return + if command == "clear-selection": + clear_selection_state() + await ui.run_javascript(CLEAR_BROWSER_SELECTION_JS) + elif command == "undo": + undo_last_assignment() + elif command == "save": + save_current() + elif command == "save-next": + # Firefox can restore focused-control DOM after keydown. Navigate on the + # next event-loop turn so the new event renders as one coherent update. + ui.timer(0.01, save_and_move_next, once=True) + elif command == "show-shortcuts": + await ui.run_javascript("document.querySelector('.shortcut-dialog-native')?.showModal()") + + +def remove_assignment(index: int) -> None: + try: + session.remove_assignment(index) + refresh_views() + except IndexError as e: + logger.warning("Tried to remove assignment with invalid index %s: %s", index, e) + + +def save_current() -> None: + session.save_current() + update_nav_state() + ui.notify("Draft kept for this session", color="positive") + + +def save_and_move_next() -> None: + """Persist the current draft and advance without a confirmation round trip.""" + session.save_current() + if state.current_index >= len(state.events) - 1: + update_nav_state() + ui.notify("Draft kept for this session", color="positive") + return + navigate(1) + ui.notify("Draft saved; moved to the next event", color="positive") + + +def export_annotations() -> None: + """Include the active draft and download the full labeled dataset as JSONL.""" + session.save_current() + update_nav_state() + payload = annotations_to_jsonl( + state.events, + state.saved_assignments, + schema_name=state.selected_schema, + ) + ui.download( + payload, filename="spanselector-annotations.jsonl", media_type="application/x-ndjson" + ) + ui.notify("Annotation export downloaded", color="positive") + + +def discard_current() -> None: + session.discard_current() + refresh_views() + + +def load_event(index: int, *, refresh: bool = True) -> None: + session.load_event(index) + if refresh: + event_workspace.refresh() + assignment_section.refresh() + update_counts() + event_navigation_controls.refresh() + else: + if refs.text_box is not None: + refs.text_box.value = state.current_event().text + update_counts() + update_nav_state() + + +def navigate(delta: int) -> None: + new_index = state.current_index + delta + if new_index < 0 or new_index >= len(state.events): + ui.notify("No more events", color="warning") + return + load_event(new_index) + + +def request_navigation(delta: int) -> None: + if not state.dirty: + navigate(delta) + return + state.pending_nav_delta = delta + if refs.confirm_dialog is not None: + refs.confirm_dialog.open() + + +def save_and_navigate() -> None: + if refs.confirm_dialog is not None: + refs.confirm_dialog.close() + save_current() + navigate(state.pending_nav_delta) + + +def discard_and_navigate() -> None: + if refs.confirm_dialog is not None: + refs.confirm_dialog.close() + navigate(state.pending_nav_delta) + + +def apply_schema_change(new_schema: str, *, discard_existing: bool) -> None: + session.apply_schema_change(new_schema, discard_existing=discard_existing) + update_selected_field_label() + selection_action_tray.refresh() + + +@ui.refreshable +def annotated_preview() -> None: + ui.html( + render_annotated_text( + state.current_event().text, + state.assignment_manager.get_all(), + get_field_colors(), + ) + ).classes("preview-panel w-full") + + +@ui.refreshable +def assignment_rows() -> None: + colors = get_field_colors() + assignments = state.assignment_manager.get_all() + if not assignments: + ui.label("No spans assigned yet").classes("empty-state") + return + for i, a in enumerate(assignments): + with ui.row().classes("assignment-row items-center gap-2 w-full"): + color = colors.get(a.field, "#fff3bf") + ui.badge(a.field, color=color, text_color="#222") + ui.label(f"{a.start}..{a.end}").classes("text-xs text-grey-6") + ui.label(a.text.replace("\n", "↵")).classes("assignment-text") + ui.space() + ui.button(icon="close", on_click=lambda _=None, idx=i: remove_assignment(idx)).props( + "flat round dense color=negative aria-label='Remove annotation'" + ).tooltip("Remove annotation") + + +def refresh_views() -> None: + annotated_preview.refresh() + assignment_section.refresh() + update_counts() + update_nav_state() + + +@ui.refreshable +def selection_action_tray() -> None: + has_selection = state.selection.end > state.selection.start and bool(state.selection.text) + tray_class = "selection-tray is-pending" if has_selection else "selection-tray is-idle" + with ui.element("section").classes(tray_class).props("aria-live=polite"): + if not has_selection: + with ui.row().classes("items-center justify-between gap-3 w-full"): + with ui.row().classes("items-center gap-2"): + ui.icon("format_quote", size="18px").classes("selection-icon") + ui.label("Select text to reveal labels").classes("supporting-text") + ui.label("Drag or use Shift + Arrow").classes("selection-hint") + return + + with ui.row().classes("selection-summary items-start justify-between gap-3 w-full"): + with ui.column().classes("gap-1 min-w-0"): + ui.label("Selected span").classes("section-label") + refs.selected_text_label = ui.label(state.selection.text).classes( + "selection-value selection-text font-mono" + ) + refs.selected_range_label = ui.label( + f"{state.selection.start}..{state.selection.end}" + ).classes("selection-value selection-range font-mono") + + ui.label("Apply a label").classes("section-label") + with ui.element("div").classes("label-grid"): + colors = get_field_colors() + for index, field in enumerate(state.schema_fields, start=1): + + async def apply_field(_event=None, *, target_field: str = field) -> None: + await assign_selected_span(target_field) + + shortcut = str(index) if index <= 9 else "" + button_text = f"{shortcut} {field}" if shortcut else field + button = ( + ui.button(button_text, on_click=apply_field) + .classes("label-option") + .props("outline no-caps") + .style(f"--label-color: {colors.get(field, '#7c5cfc')}") + ) + if shortcut: + button.props["aria-keyshortcuts"] = shortcut + + +def build_header() -> None: + with ui.row().classes("hero items-start justify-between w-full"): + with ui.column().classes("gap-1"): + with ui.row().classes("items-center gap-2"): + ui.label("spanselector").classes("brand") + ui.badge("ALPHA").classes("release-badge") + ui.label("Turn unstructured text into reviewable training data.").classes("hero-copy") + ui.badge("LOCAL · SESSION ONLY", color="grey-9").classes("scope-badge") + + +def build_sidebar() -> None: + ui.label("Annotation setup").classes("panel-title") + ui.label("Choose a schema, then label selections beside the source text.").classes( + "supporting-text" + ) + ui.separator() + with ui.row().classes("items-center justify-between w-full"): + ui.label("Schema").classes("section-label") + refs.total_assignments_label = ui.label(f"{total_assignment_count()} total").classes( + "count-pill" + ) + + def on_schema_change(e) -> None: + new_schema = e.value + if new_schema == state.selected_schema: + return + if state.dirty or has_any_saved_assignments(): + state.pending_schema = new_schema + if refs.schema_confirm_dialog is not None: + refs.schema_confirm_dialog.open() + schema_select.value = state.selected_schema + return + apply_schema_change(new_schema, discard_existing=False) + selection_action_tray.refresh() + refresh_views() + + schema_select = ui.select( + list(SCHEMAS.keys()), + value=state.selected_schema, + on_change=on_schema_change, + ).classes("w-full") + + build_schema_confirm_dialog(schema_select) + + assignment_section() + + +@ui.refreshable +def assignment_section() -> None: + ui.separator() + with ui.row().classes("items-center justify-between w-full"): + ui.label("Assignments").classes("section-label") + refs.count_label = ui.label( + f"{len(state.assignment_manager.get_all())} on this event" + ).classes("supporting-text") + with ui.column().classes("w-full gap-2 mt-2 max-h-96 overflow-y-auto"): + assignment_rows() + + +@ui.refreshable +def event_workspace() -> None: + with ui.row().classes("items-center justify-between w-full"): + ui.label("Source text").classes("panel-title") + ui.label("Drag across text to select a span").classes("supporting-text") + + refs.text_box = ( + ui.textarea(value=state.current_event().text) + .props("outlined readonly rows=10") + .classes("w-full log-box") + ) + refs.text_box.on( + "select", + handle_selection_change, + throttle=0.12, + leading_events=False, + trailing_events=True, + js_handler=CAPTURE_SELECTION_EVENT_JS, + ) + refs.text_box.on( + "keyup", + handle_selection_change, + js_handler=CAPTURE_SELECTION_EVENT_JS, + ) + + selection_action_tray() + + ui.separator() + ui.label("Annotated preview").classes("panel-title") + annotated_preview() + + +def build_main_panel() -> None: + with ui.element("main").classes("workspace-grid w-full"): + with ui.column().classes("content-panel min-w-0"): + event_workspace() + + with ui.element("aside").classes("setup-panel"): + build_sidebar() + + +@ui.refreshable +def shortcut_bar() -> None: + with ui.row().classes("shortcut-bar items-center justify-between gap-3 w-full"): + with ui.row().classes("shortcut-items items-center gap-4"): + ui.label("Shortcuts").classes("shortcut-title") + for keys, description in SHORTCUT_BAR_ITEMS: + with ui.row().classes("shortcut-item items-center gap-1"): + ui.label(keys).classes("keycap") + ui.label(description).classes("shortcut-description") + with ui.row().classes("items-center gap-2"): + ui.badge("On").classes("shortcut-status") + ui.html( + """ + + """, + sanitize=False, + ) + + +@ui.refreshable +def event_navigation_controls() -> None: + with ui.row().classes("items-center justify-between gap-3 w-full"): + with ui.column().classes("gap-0"): + refs.event_index_label = ui.label("").classes("panel-title") + with ui.row().classes("items-center gap-2"): + refs.event_id_label = ui.label("").classes("supporting-text font-mono") + refs.status_label = ui.label("").classes("status-text") + with ui.row().classes("navigation-actions items-center gap-2"): + refs.previous_button = ui.button( + "Previous", icon="arrow_back", on_click=lambda: request_navigation(-1) + ).props("outline no-caps") + refs.next_button = ui.button( + "Next", icon="arrow_forward", on_click=lambda: request_navigation(1) + ).props("outline no-caps icon-right") + ui.button("Save draft", icon="bookmark", on_click=save_current).props( + "flat no-caps" + ).tooltip("Keep this event's annotations in memory") + ui.button( + "Export JSONL", + icon="download", + on_click=export_annotations, + color="primary", + ).classes("primary-action").props("no-caps unelevated") + refs.progress_bar = ui.linear_progress( + value=(state.current_index + 1) / len(state.events), + color="primary", + size="4px", + show_value=False, + ).classes("event-progress") + update_nav_state() + + +def build_navigation() -> None: + with ui.element("section").classes("navigation-panel w-full"): + event_navigation_controls() + ui.separator().classes("shortcut-separator") + shortcut_bar() + + +def build_shortcuts_dialog() -> None: + ui.html(render_shortcut_dialog(SHORTCUT_GROUPS), sanitize=False) + + +def build_confirm_dialog() -> None: + refs.confirm_dialog = ui.dialog() + with refs.confirm_dialog: + with ui.card().classes("w-96"): + ui.label("Unsaved changes").classes("text-subtitle2 font-semibold") + ui.label("Save changes before moving to another event?") + with ui.row().classes("justify-end gap-2"): + ui.button("Cancel", on_click=refs.confirm_dialog.close) + ui.button("Discard", on_click=discard_and_navigate).props("flat color=warning") + ui.button("Save", on_click=save_and_navigate, color="primary") + + +def build_schema_confirm_dialog(schema_select) -> None: + refs.schema_confirm_dialog = ui.dialog() + with refs.schema_confirm_dialog: + with ui.card().classes("w-96"): + ui.label("Discard existing annotations?").classes("text-subtitle2 font-semibold") + ui.label("Changing the schema will discard all saved annotations. Continue?") + with ui.row().classes("justify-end gap-2"): + + def cancel_change() -> None: + if refs.schema_confirm_dialog is not None: + refs.schema_confirm_dialog.close() + state.pending_schema = None + + ui.button("Cancel", on_click=cancel_change) + + def discard_and_change() -> None: + if refs.schema_confirm_dialog is not None: + refs.schema_confirm_dialog.close() + if state.pending_schema is None: + return + apply_schema_change(state.pending_schema, discard_existing=True) + schema_select.value = state.selected_schema + selection_action_tray.refresh() + refresh_views() + state.pending_schema = None + + ui.button("Discard & Switch", on_click=discard_and_change).props( + "flat color=warning" + ) + + +def create_ui() -> None: + configure_theme() + refs.app_shell = ( + ui.column() + .classes("app-shell") + .props("tabindex=0") + .on( + "keydown", + handle_workspace_shortcut, + js_handler=WORKSPACE_SHORTCUT_EVENT_JS, + ) + ) + with refs.app_shell: + build_header() + build_main_panel() + build_navigation() + build_confirm_dialog() + build_shortcuts_dialog() + load_event(state.current_index, refresh=False) diff --git a/tests/e2e/test_selection.py b/tests/e2e/test_selection.py index 908345d..b238f10 100644 --- a/tests/e2e/test_selection.py +++ b/tests/e2e/test_selection.py @@ -74,6 +74,8 @@ def live_server() -> Iterator[str]: "HOST": "127.0.0.1", "PORT": str(port), "RELOAD": "false", + # NiceGUI otherwise opens the system browser for every test server. + "SHOW": "false", } ) process = subprocess.Popen( diff --git a/tests/test_browser.py b/tests/test_browser.py new file mode 100644 index 0000000..57c9a9e --- /dev/null +++ b/tests/test_browser.py @@ -0,0 +1,9 @@ +from spanselector.browser import SHORTCUT_BAR_ITEMS, SHORTCUT_GROUPS + + +def test_shortcut_views_are_derived_from_the_same_definitions() -> None: + assert ("Ctrl/⌘ Enter", "Save + next") in SHORTCUT_BAR_ITEMS + assert ( + "Ctrl/⌘ Enter", + "Save the draft and move to the next event", + ) in SHORTCUT_GROUPS["Navigation"] diff --git a/tests/test_configuration.py b/tests/test_configuration.py index b3c3e03..fe22e09 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -13,3 +13,11 @@ def test_data_path_accepts_legacy_environment_name(monkeypatch, tmp_path: Path) configured = Settings(_env_file=None) assert configured.data_path == legacy_path + + +def test_browser_auto_open_can_be_disabled(monkeypatch) -> None: + monkeypatch.setenv("SHOW", "false") + + configured = Settings(_env_file=None) + + assert configured.show is False diff --git a/tests/test_presentation.py b/tests/test_presentation.py new file mode 100644 index 0000000..403e9f3 --- /dev/null +++ b/tests/test_presentation.py @@ -0,0 +1,28 @@ +from spanselector.models import Assignment +from spanselector.presentation import render_annotated_text, render_shortcut_dialog + + +def test_render_annotated_text_escapes_source_and_labels() -> None: + assignment = Assignment(start=0, end=5, text="", field="label