diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90fdf75b..8266625d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,33 @@ jobs: - name: Check formatting run: cargo fmt --all -- --check + # ============================================ + # Design tokens - every guaranteed colour pair, every theme + # ============================================ + design-tokens: + name: Design Token Contrast + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Check contrast + run: python3 -I design/tools/check_contrast.py design/tokens/tokens.json + + - name: Colour gate + # Colours come from the theme tokens only. Exceptions: Color32::TRANSPARENT, the export + # back ends converting token colours to PDF/PNG types, and a byte triple in the Link parser. + run: | + hits=$(grep -rnE 'Color32::|Rgba\(|Rgb::new|\[u8; 3\]' src/ \ + | grep -v '^src/ui/theme/' \ + | grep -v 'Color32::TRANSPARENT' \ + | grep -vE '^src/ui/export_sheet/(pdf|png)\.rs:' \ + | grep -v '^src/parsers/link.rs:' || true) + if [ -n "$hits" ]; then + echo "Hard-coded colours outside src/ui/theme/:"; echo "$hits"; exit 1 + fi + # ============================================ # Lint - Clippy analysis # ============================================ diff --git a/CLAUDE.md b/CLAUDE.md index e17a3dcd..fa1642f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ src/ ├── units.rs # Unit preference types and conversions ├── normalize.rs # Field name normalization system ├── computed.rs # Computed channels data types and library -├── colormap.rs # Viridis/Turbo colormap LUTs for track coloring +├── colormap.rs # Colour-map entry point: theme Sequential / Diverging maps (no rainbow LUTs) ├── laps.rs # GPS coordinate-format normalization, track sanitization, lap detection ├── tiles.rs # Map tile providers, bounded worker pool, disk/texture caches ├── expression/ @@ -144,23 +144,43 @@ src/ ├── tool_properties_panel.rs # Dynamic panel showing controls for the active tool (channels / histogram / scatter) ├── analysis_panel.rs # Window for running analysis algorithms (src/analysis) on the active log ├── table_generator.rs # Table tools: Tool Properties setup + central heatmap / inspector view - ├── table_export.rs # PNG / PDF export for generated tables (pure renderers + app wrappers) + ├── table_export.rs # ExportSheet model for generated tables (`table_sheet`) ├── data_panel.rs # Right-side data panel hosting DataWidget panes (rail, header, hide/restore) ├── widgets/ │ ├── mod.rs # DataWidget trait + static widget registry │ └── track_map.rs # GPS Track Map widget (polyline, laps, tile backgrounds) - ├── sidebar.rs # Legacy files panel, superseded by files_panel (kept, not wired into render path) - ├── channels.rs # Selected-channel cards; legacy channel-picker superseded by tool_properties_panel + ├── channels.rs # Selected-channel cards ├── chart.rs # Chart rendering, legends, LTTB algorithm ├── timeline.rs # Timeline scrubber and playback controls ├── menu.rs # Menu bar (Units, Tools, Help menus) ├── toast.rs # Toast notification system - ├── icons.rs # Custom icon drawing utilities ├── tab_bar.rs # Multi-file tab interface + ├── fonts.rs # Font families (per weight), script/CJK fallbacks, the type scale as TextStyles + ├── formula_syntax.rs # Formula tokenizer + LayoutJob colouring (editor, help window) + ├── plot_style.rs # Chart chrome: series strokes, bloom, Ultra floor, cursor, custom legend, direct labels + ├── heatmap.rs # Heatmap/table cells: seq/div fill, value plate, hatch, count badges, ring, ColorScale + ├── components/ + │ ├── mod.rs # Night Drive components; re-exports Phosphor as `icon` + │ ├── button.rs # primary / secondary / ghost / danger / sm / icon buttons + │ ├── controls.rs # segmented, checkbox, Ultra intensity slider + │ ├── notice.rs # Status (icon + word), notice, status and ECU badges + │ └── window.rs # window(): Saira title; finish_window(): redline + Esc + ├── theme/ + │ ├── mod.rs # Night Drive runtime: tokens_at, series/seq/div, egui Visuals, install/current + │ ├── tokens.rs # GENERATED from design/tokens/tokens.json — never edit by hand + │ └── codegen.rs # tokens.json -> tokens.rs generator (cargo run --example gen_theme) ├── tool_switcher.rs # Switch between the five tools (ActiveTool::ALL) ├── scatter_plot.rs # XY scatter plot visualization ├── histogram.rs # 2D histogram/heatmap view for channel distributions - ├── export.rs # PNG and PDF export functionality + ├── export.rs # Builds the ExportSheet per tool; file export and Copy image + ├── export_dialog.rs # ExportDialog: format, Paper/Graphite, Copy image / Export + ├── about_window.rs # About window (Help menu): lockup on bg_void, version, links + ├── export_sheet/ # ExportSheet layout + Canvas back ends (see Export Contract) + │ ├── mod.rs # Sheet / ChartModel / GridModel, draw_sheet, render_png/pdf + │ ├── canvas.rs # Canvas trait (points, top-left origin, baseline text) + │ ├── text.rs # skrifa layout, font fallback, glyph outlines + │ ├── png.rs # vello_cpu back end (2×), lockup composited after render + │ └── pdf.rs # printpdf back end, bundled TTFs embedded ├── normalization_editor.rs # Custom field mapping editor ├── computed_channels_manager.rs # Computed channels library UI ├── formula_editor.rs # Formula creation and editing @@ -179,7 +199,7 @@ src/ - `LoadedFile` - Represents a parsed log file - `SelectedChannel` - A channel selected for visualization - `CacheKey`, `LoadResult`, `LoadingState`, `DownsampleViewKey` - Internal state types - - Color palette constants (`CHART_COLORS`, `COLORBLIND_COLORS`) + - No colour constants: series colours come from `theme::series` (see the Theme Contract) - **`settings.rs`** - `UserSettings` persistence (see [Settings Persistence Contract](#settings-persistence-contract) below) @@ -288,9 +308,11 @@ low cells are never interpolated and export blank. differences are exactly zero). The previous reading for interpolation is the last update, but no earlier than `t - effective_update_interval`: a flat baseline is still being sampled every interval, so interpolating from its last value change would place every crossing far too early. -- **Invalid-sample masking** (`events::mask_invalid`) - Haltech writes an i32 sentinel family +- **Invalid-sample masking** (`events::mask_invalid`) - ECUs write an i32 sentinel family (`-2147483617`, `…637`, …) for "no reading"; those and out-of-band samples become `NaN` before any - math, and a window with >10 % masked lambda rejects as `InvalidSamples`. + math, and a window with >10 % masked lambda rejects as `InvalidSamples`. Haltech sentinels never + get this far: the Haltech parser fills them (see its load-bearing behaviors), so a Haltech wideband + dropout reaches the generators as a held value and is caught by steadiness / band gating instead. - **Alignment** (`tables::mapped_column`) - `Log::get_channel_data` drops ragged rows, so a column can be shorter than `times`; the generators refuse with `ComputationError` rather than index misaligned pairs (same contract as `channel_series` in the IPC handler). @@ -313,11 +335,11 @@ low cells are never interpolated and export blank. holds the accumulators, mappings and export options for both generators. The tables are multi-log by design, so the active tab only decides which file *Run / Add current file* reads; switching tabs or tools does not lose a table. -- **PNG has no text** - the crate has no font rasterizer, so `table_export::render_table_png` - draws cells and grid lines only (same as the histogram PNG). `render_table_pdf` draws values, - counts and axis labels with built-in Helvetica, whose encoding turns `~` into an arrow, so the - confidence markers are `*` (medium) and `?` (low). Both go through `tables::export::cell_value` - so PNG, PDF, CSV and clipboard blank the same cells and convert delay units identically. +- **Exports go through the ExportSheet** - `table_export::table_sheet` builds a `GridModel` + (values, count badges whose fill style shows confidence, edge labels) and `ui::export_sheet` + draws PNG and PDF from it (see the Export Contract below). Cell values go through + `tables::export::cell_value`, so PNG, PDF, CSV and clipboard blank the same cells and convert + delay units identically. - **Auto-suggestion** (`channel_map::suggest_mapping`) - normalization hit (100) → strong name hints (50) → spec category + hint (60) → generic hints (40), then a data-plausibility veto on the channel median; `overall|avg|average` names lose 10 points so a single sensor beats an averaged @@ -382,27 +404,25 @@ it, so a ragged log yields a pair that is *misaligned*, not merely short — and ### UI Modules (src/ui/) -UI rendering is split into focused modules that implement methods on `UltraLogApp`. The current layout is a VS Code-style activity bar + side panel; a couple of pre-activity-bar modules remain in the tree but are superseded (noted below): +UI rendering is split into focused modules that implement methods on `UltraLogApp`. The layout is a VS Code-style activity bar + side panel: - **`activity_bar.rs`** - Vertical icon strip on the far left for switching between side-panel sections (Files / Tool Properties / Tools / Settings) - **`side_panel.rs`** - Container that routes to the panel selected in the activity bar -- **`files_panel.rs`** - File management, loading, and file list (current files panel — see `sidebar.rs` note below) +- **`files_panel.rs`** - File management, loading, and file list - **`tools_panel.rs`** - Analysis tools, computed channels library, and export options, inline in the side panel - **`settings_panel.rs`** - Consolidated settings: display, units, normalization, updates - **`tool_properties_panel.rs`** - Dynamic panel showing controls for the active tool: channel selection for Log Viewer, axis/grid/filter controls for Histogram, controls for Scatter Plot - **`analysis_panel.rs`** - Window for running `src/analysis` algorithms (filters, statistics, AFR, derived metrics) against the active log, with category tabs -- **`sidebar.rs`** - Legacy file list/drop-zone panel, superseded by `files_panel.rs` (module still compiles but its render methods are not called from `app.rs`) -- **`channels.rs`** - Selected-channel cards (`render_selected_channels`, still active) plus a legacy channel-picker list (`render_channel_selection`) superseded by `tool_properties_panel.rs` +- **`channels.rs`** - Selected-channel cards (`render_selected_channels`); channel picking lives in `tool_properties_panel.rs` - **`chart.rs`** - Main chart with egui_plot, min/max legend overlay, LTTB downsampling, normalization - **`timeline.rs`** - Bottom panel: playback controls (play/pause/stop), speed selector, timeline scrubber - **`menu.rs`** - Top menu bar (File, Edit, View, Help; Units submenu with 8 unit categories) - **`toast.rs`** - Toast notification overlay for user feedback -- **`icons.rs`** - Custom icon drawing (upload icon for drop zone) - **`tab_bar.rs`** - Chrome-style tabs for multi-file support - **`tool_switcher.rs`** - Switch between the five tools in `ActiveTool::ALL` (Log Viewer, Scatter Plot, Histogram, Lambda Delay, Accel Enrichment) - **`scatter_plot.rs`** - XY scatter plot for channel correlation analysis - **`histogram.rs`** - 2D histogram/heatmap view of channel distributions, with configurable cell coloring (average Z-value or hit count) -- **`export.rs`** - PNG and PDF export with chart rendering +- **`export.rs`** / **`export_dialog.rs`** / **`export_sheet/`** - the ExportDialog and the one ExportSheet layout every PNG/PDF export uses (see the Export Contract) - **`normalization_editor.rs`** - Dialog for creating custom field name mappings - **`computed_channels_manager.rs`** - Window for managing computed channel library - **`formula_editor.rs`** - Dialog for creating/editing computed channel formulas @@ -616,6 +636,7 @@ preferences convert them. **Haltech parser load-bearing behaviors** (`src/parsers/haltech.rs`, added for wall-clock-timestamped exports): - **Last-known-value substitution** - Unparseable/blank fields are filled with the last successfully parsed value for that column (`0.0` before the first valid sample), matching the approach used in `parsers/ecumaster.rs`. This preserves column alignment instead of shifting subsequent columns when a field fails to parse. +- **"No reading" sentinels are filled before scaling** - NSP writes an i32 sentinel family (`-2147483617`, `-2147483637`, ...) when a channel has no reading. The parser checks the *raw* value with `analysis::tables::events::is_invalid_sample` and treats a hit like a blank field (last known value, `0.0` before the first). It must run before `ChannelType::convert_value`: once scaled (lambda / 1000, pressure / 10) the sentinel drops below `SENTINEL_MAGNITUDE`, no longer matches, and showed up as a -31,568,009 AFR minimum on the channel card and a flattened chart. - **Midnight rollover handling** - Timestamps are wall-clock time-of-day, so a logging session that crosses midnight wraps from ~86400 back toward 0. The parser tracks the previous raw timestamp and accumulates a 24h (`86_400.0`) offset on each backwards jump greater than 1 second (the 1s guard avoids tripping on timestamp jitter), keeping `times` monotonically increasing. This is the same technique used in `parsers/woolich.rs` (`Log Time` column) — monotonic times are required because computed-channel time-shift lookups binary-search the `times` vector. ### Data Flow @@ -651,10 +672,11 @@ preferences convert them. ### Settings Persistence Contract -`UserSettings` (`src/settings.rs`) is a `Serialize`/`Deserialize` struct persisted as JSON at `{app_data_dir}/UltraLog/settings.json` (or `.../ultralog/settings.json` on Linux). It currently covers: `language`, `scroll_to_zoom`, `show_grid`, `grid_opacity`, `unit_preferences`, `font_scale`, `color_blind_mode`, `field_normalization`, `cursor_tracking`, `auto_check_updates`, `custom_normalizations`, and the Track Map preferences (`tile_provider`, `tile_cache_max_mb`, `tiles_enabled`, `tile_opacity`, `tile_grayscale`, `tile_privacy_notice_seen`, `hidden_widgets`). +`UserSettings` (`src/settings.rs`) is a `Serialize`/`Deserialize` struct persisted as JSON at `{app_data_dir}/UltraLog/settings.json` (or `.../ultralog/settings.json` on Linux). It currently covers: `language`, `scroll_to_zoom`, `show_grid`, `grid_opacity`, `unit_preferences`, `font_scale`, `color_blind_mode`, `ultra_intensity`, `high_contrast`, `field_normalization`, `cursor_tracking`, `auto_check_updates`, `custom_normalizations`, and the Track Map preferences (`tile_provider`, `tile_cache_max_mb`, `tiles_enabled`, `tile_opacity`, `tile_grayscale`, `tile_privacy_notice_seen`, `hidden_widgets`), and `export` (the ExportDialog's last format and theme per format). - **Load** - `UltraLogApp::new` calls `UserSettings::load()` and copies each field into the corresponding live `UltraLogApp` field (e.g. `app.color_blind_mode = user_settings.color_blind_mode`). - **Save** - `UltraLogApp` implements `eframe::App::save`, which eframe calls on its auto-save interval (~30s) and again at shutdown. `save()` rebuilds a `UserSettings` from the current live fields, and only writes to disk if it differs from the last-loaded/saved value. +- **Defaults for new fields must match the in-app default, not the type default.** `serde(default)` on a `u8` gives 0; `ultra_intensity` uses `default_ultra_intensity`, which returns `theme::DEFAULT_INTENSITY` (0, Stealth), so a 2.x settings file opens at the same level as a new install (`test_settings_from_before_3_0_open_at_stealth`). `DEFAULT_INTENSITY` is the default only; the Night Drive anchor that `tokens_at` blends around is `NIGHT_DRIVE_INTENSITY` (35). - **Load-bearing invariant:** because the sync is manual field-by-field (not `#[derive]` magic), **any new persisted preference must be added in three places**: the field on the `UserSettings` struct, the copy in `UltraLogApp::new`, and the reconstruction in `eframe::App::save` in `src/app.rs`. Missing any one of the three means the preference silently resets on restart or never saves at all. ### Chart/Scatter Cache Invalidation Contract @@ -667,33 +689,198 @@ Several `UltraLogApp` fields cache expensive per-file, per-channel computations **Load-bearing invariant:** these caches are keyed by index (file index / channel index), not by identity. Any code path that mutates or reindexes channel data — removing a file, or removing/editing a computed channel (which shifts later computed channels' indices down) — **must clear the relevant caches**, or stale entries will silently render the wrong data against a different channel's index. See `remove_computed_channel` and the file-removal path in `src/app.rs` for the current call sites (`self.downsample_cache.clear()`, `self.minmax_cache.clear()`, `self.scatter_histogram_cache.clear()`). +A cache that holds derived **colours** has a second key: `UltraLogApp::theme_generation`, which +increments on every theme install. The Track Map's coloured-polyline cache stores it +(`theme_generation` in `state.rs`) and rebuilds when it differs, so a change of intensity, Color +blind mode or High contrast never leaves the old palette on screen. + +### Log Compare Contract (issue #98) + +A tab can overlay a second log: `Tab::compare: Option` names log **B** (the tab's own +file is log **A**), log B's time offset, the Align-at-cursor pick flag and the A | B list switch. +Design: `docs/plans/2026-10-25-log-compare.md` (decisions D1-D5, rules R1-R4). Session-only; not +in `UserSettings`. + +- **One time axis.** Tab time is log A's time. Log B's sample at file time `t` is drawn at + `t + offset_s` (`Tab::time_offset`). Values at the cursor come from `UltraLogApp::record_at` + (nearest sample, the same rule as the cursor record and the track-map dot, and **`None` outside + that log's span**, R4) for every file, in the legend and in IPC `get_cursor_values`. The tab's + `cursor_record` is a record of log A only; arrow keys step it with `state::arrow_step`, which + enters log A from the near edge when the cursor is outside A's span. The track-map dot hides + outside A's span. Align at cursor stores the cursor time when it starts (`align_target`), and + a pick is cancelled on a tab or tool switch. `Tab::time_range` is the union of A and + shifted B (`update_tab_time_range`, which also clamps the cursor); call it after any compare or + offset change (`set_compare_file`, `set_compare_offset` do). +- **Pairs are colour slots.** A pair is an A and a B channel with the same `color_index` + (`Tab::partner` / `partner_of`). `insert_channel` pairs by `pair_key` (normalized display name, + case-insensitive); computed channels are never paired (R3). A pair counts as **one** against + `MAX_CHANNELS` / `MAX_CHANNELS_PER_PLOT` / `MAX_TOTAL_CHANNELS` (`Tab::channel_units`, + `plot_has_capacity`, R2). +- **A pair shares one y range** (R1): `UltraLogApp::pair_y_range` on screen and the same union + in `chart_sheet`. Otherwise every channel is scaled to its own 0..1 and two pulls with different + peaks look identical. +- **The downsample cache holds raw file-time points.** `compute_viewport_points` applies the + offset and the y scale to the copy it returns, so a nudge smaller than one LTTB bucket (or a new + pair range) shows at once. Never store shifted or normalized points in `downsample_cache`. +- **Every file-index consumer must accept log B or refuse it on purpose.** The add guards use + `Tab::accepts_file`; the track map's "Color by" and `color_source` take log A only (B's values + do not line up with the track's records); scatter, histogram and table generators read log A. + A Min/Max jump on a B card goes through `cursor_for_file_sample`. +- **Removal is index-shifting, in every tab.** `remove_file` calls `Tab::remap_after_file_removed` + on **all** tabs (log B can be any file, before or after the tab's own), and every removal of a + selected channel goes through `Tab::remove_selected_channel`, which also shifts + `plot_areas.channel_indices` and the track-map colour slot. A stale index renders another file's + channel with no error (same class as the cache invalidation contract). +- **Exports** use the tab range (`get_time_range`, not the app-wide `time_range`), slice log B in + its own time and shift it, dash it via `SeriesModel::log`, tag the legend `A · ` / `B · ` and add + a meta line with both files and the offset. +- **MCP/IPC:** no compare commands. `get_cursor_values` reads the tab's own file at the cursor + record and any other file (log B included) through `record_at`, so log B gets its own record and + no values outside its span. `deselect_all_channels` keeps log B. `set_time_range` writes the + tab's range directly, so the next compare or offset change replaces it with the data range. + ### Map Tile Fetching Contract (src/tiles.rs) The Track Map widget can draw map tile backgrounds. Tiles are **opt-in** (off by default) and only two hardcoded HTTPS providers exist: Esri World Imagery and OpenStreetMap. Requests carry integer tile coordinates plus the UltraLog User-Agent, nothing else. The first time a user enables tiles, a one-time toast (`tile_privacy_notice_seen`) notes that the track's approximate location is shared with the provider. **Load-bearing invariant:** network fetches are gated by per-provider permits (`FetchPermits` + `TileProvider::max_concurrent_fetches`). The OSM tile usage policy allows at most **2 simultaneous download connections** (), so `OpenStreetMap::max_concurrent_fetches` returns 2. Do not raise it and do not bypass the permit gate — exceeding the policy risks a per-IP block that would hit every UltraLog user at once. +### Theme Contract (src/ui/theme/) + +UI work follows the Night Drive design system in `design/` (brand book +`design/design-system/README.md`, brief `design/HANDOFF-APP.md`, implementation plan +`docs/plans/2026-10-23-night-drive-design-system.md`). It shipped in 3.0.0; every screen reads +the tokens, and the CI colour gate keeps it that way. + +- **Never hard-code a colour, size or radius.** Read `UltraLogApp::tokens` (or + `theme::current(ctx)` where there is no app reference, e.g. `DataWidget`s) and the + `tokens::{space, radius, stroke, text}` modules. +- **`tokens.rs` is generated.** Edit `design/tokens/tokens.json`, then run + `cargo run --example gen_theme`. The test `committed_tokens_match_json` fails when the two + drift. The module is `#[rustfmt::skip]` so the generator's output is what gets committed. + Resolution rules (night fallback, `{ref}` in-theme, effects from the shadow tokens) are + documented at the top of `codegen.rs`; they match `design/tokens/tokens.ts`. +- **One install path.** `UltraLogApp::sync_theme` runs every frame from `logic()`, derives + `ThemeSettings` from the live preference fields and reinstalls only on a change, so a setting + toggled anywhere applies next frame without its own hook. `theme::install` pins + `ThemePreference::Dark` (otherwise egui follows the OS appearance and swaps in its light + style) and stores the tokens in `ctx.data`. +- **Data colours ignore intensity.** Series, colour maps, status and cursor follow Color blind + mode only (tested by `data_colors_ignore_intensity`). The exception is by design: the diverging + midpoint `div_2` is a ground-tinted neutral outside Color blind mode. +- **Series beyond the palette** change stroke, not colour: `theme::series` returns + `SeriesStroke::Dashed` for the second lap and `Dotted` after (laps of 10, or of 8 when + `color_blind_like` — Color blind mode, High contrast and exports). **One exception:** while a + tab compares two logs, `theme::compare_series` uses the stroke for the log (A solid, B dashed), + so slots past the palette repeat colours without a stroke change; the A/B tag in the legend, + direct labels and cards tells them apart. Draw selected channels through + `UltraLogApp::channel_style`, never `series_style`, so compare mode is respected. +- **Colour maps:** never add a rainbow (jet, Turbo) or a hand-rolled ramp; everything that colours by magnitude goes through `colormap::sample` / `theme::seq` / `theme::div`, cells through `ui::heatmap` (value plate, hatched empty cells, badge fill styles). +- **Caches of derived colours** must key on `UltraLogApp::theme_generation`, which increments on + every install, or they keep the old palette after a theme change. +- **Fonts** (`src/ui/fonts.rs`): egui families have no weight axis, so each weight is its own + family (`ui-medium`, `ui-semibold`, `display`, `display-bold-italic`, `display-black-italic`, + `mono-medium`; Proportional = Outfit 400, Monospace = JetBrains Mono 400), built from static + instances in `assets/fonts/` (`scripts/fonts/build_static_fonts.sh`; egui renders only a + variable font's default instance). Every family falls back to egui's defaults (Greek, + Cyrillic, emoji — Outfit has none of these), bundled Noto Devanagari/Bengali/Arabic, a 3 KB + Noto SC subset holding only the language picker's 简体中文日本語, and for `ja`/`zh-CN` only a + system CJK `.ttc` read at runtime (`cjk_candidates`; bundling CJK would add ~10 MB). + `UltraLogApp::sync_fonts` reinstalls on a language change. The type scale lives in + `Style::text_styles` (installed with the visuals, scaled by `FontScale`); use `TextStyle`s and + `fonts::style::*` names rather than `FontId::proportional(scaled_font(n))`. + Arabic/Urdu still lack right-to-left layout (an egui limitation). +- **Components** (`src/ui/components/`) read `theme::current(ui.ctx())`, so any `Ui` can call + them. New floating windows go through `components::window(..)` and must call + `components::finish_window` after `.show(..)` (redline, Esc closes the topmost window). Use + `components::button(.., Kind::..)` instead of hand-built `Frame` buttons; one primary per view. + Status is always icon + word (`components::Status`), never colour alone. +- **Charts** draw series through `plot_style::draw_series` and the legend through + `plot_style::legend_overlay` (egui_plot's built-in `Legend` is off: it cannot show the Cursor row, + live mono values or the solo fade). The soloed series is kept per plot in egui temp memory + (`plot_style::solo_memory`). Colour slots are unbounded (`state::next_color_index`); past the + palette `theme::series` repeats colours dashed, then dotted. +- **Custom painting** stays in `src/ui/theme/paint.rs`: redline, glow, floor, dashed outline and + the one keyboard focus ring (`keyboard_focus_ring`, called once per frame at the end of + `ui()`; it shows after Tab/arrow navigation or on a text field, not after a mouse click). + Chart-specific painting (series strokes and Ultra bloom, the legend overlay, direct labels) + lives in `src/ui/plot_style.rs`. +- **Icons are Phosphor** (`components::icon::*`, regular outline). The `phosphor` font sits right + after each brand face in every family, so an icon codepoint works in any label; the filled + set (`fonts::PHOSPHOR_FILL`) is only for transport glyphs and the favourite star. No emoji. +- **Brand assets are generated, not edited.** `assets/icons/*` come from + `design/logos/ultralog-app-icon-1024.png` via `scripts/brand/build_app_icons.sh` (macOS keeps + Apple's 824 px tile in the 1024 canvas; the paths are fixed because `main.rs`, `build.rs` and + `release.yml` read them). `assets/brand/*` lockups come from `scripts/brand/rasterize_lockups.sh`. +- **Colour gate (CI):** `grep -rnE 'Color32::|Rgba\(|Rgb::new|\[u8; 3\]' src/` may match only + `src/ui/theme/`, `Color32::TRANSPARENT`, the PDF/PNG type conversion in + `src/ui/export_sheet/{pdf,png}.rs` and a byte triple in `parsers/link.rs`. Image tints go + through `theme::image_tint`, translucent export colours through `theme::flatten`. +- **CI** runs `python3 design/tools/check_contrast.py design/tokens/tokens.json`. A token change + that breaks a pair fails the build: fix the token, never relax the check. + +### Export Contract (src/ui/export.rs, src/ui/export_sheet/) + +Every PNG and PDF export is one ExportSheet (`design/design-system/4-exports.md`): sunset +redline, lockup, title, mono meta (time range, counts), the chart or grids, a legend or +ColorScale, and the footer (ultralog.co · version · export date). + +- **One path.** `UltraLogApp::export_sheet` builds a `Sheet` for the active tool (chart, scatter, + histogram, table via `table_export::table_sheet`); `export_to_file` and `copy_export_image` are + the only writers. Menu, Tools panel and Cmd+E open the ExportDialog; do not add a direct + per-tool export again. +- **Models, not app state, are drawn.** `draw_sheet` lays out `ChartModel` / `GridModel` once on a + `Canvas`; `PngCanvas` (vello_cpu, 960 × 540 pt at 2×) and `PdfCanvas` (printpdf, A4 landscape) + implement it. A layout change is made once and both formats follow. +- **Exports ignore app settings.** Colours come from `export_tokens(ExportTheme)` only, the series + palette is always the colour-blind one (`theme::series(.., true)`), and there are no intensity + effects. PDF has no cheap alpha, so translucent tokens go through `canvas::flatten`. +- **PDF text is embedded TTF, PNG text is outlines.** `PdfCanvas` embeds the bundled faces + (`fonts::face_bytes`) so text stays selectable and `−`, `λ`, `°`, Cyrillic and Devanagari print; + never go back to built-in Helvetica (its encoding mangles anything past Latin-1). PNG text is + skrifa glyph outlines filled by vello_cpu. `text::layout` has no shaping or kerning and falls + back per character Outfit/Saira → JetBrains Mono → Noto scripts → a system CJK face + (`fonts::export_cjk_face`, any UI language), so Arabic prints unjoined. A character no face has + prints as `?`, never `.notdef`: printpdf's `.notdef` width differs from skrifa's and shifted the + text after it. +- **Exported scales match the screen.** The table export picks the diverging map for values on + both sides of zero exactly as `render_table_tool_view` does, and only lambda-deviation measures + (`MeasureSpec::lean_is_positive`) get the rich · target · lean labels; PW step does not. +- **The lockup is a pre-rasterized PNG** (`assets/brand/ultralog-lockup{,-paper}.png`, + `scripts/brand/rasterize_lockups.sh`, rsvg-convert): there is no SVG renderer at runtime. PNG + composites it after the vector render; PDF places it as an image XObject. +- **The export matches the screen.** The chart exports the visible range + (`chart_last_x_bounds[0]`) normalized per channel like the chart; the histogram applies the + same custom ranges, sample filters, min-hits blanking and `binning::uniform_bin` as + `render_histogram_grid`. Change one and change the other. +- **Remembered choices** live in `UserSettings::export` (`ExportChoice`: last format, last theme + per format), synced in the usual three places. +- **Visual check:** `EXPORT_SHEET_DUMP= cargo test --lib dump_ -- --ignored` writes sample + chart, scatter and table sheets in both themes. + ## Key Features - **Multi-ECU Support** - Haltech, ECUMaster, RomRaider, Speeduino, rusEFI, AiM, Link, Emerald, MegaSquirt, TunerStudio MSL, MHD Tuning, Motorsport Electronics, RaceChrono, Torque Pro, Woolich Racing Tuned, BlueDriver, DynamicEFI, and Locomotive log formats - **Computed Channels** - Create virtual channels from mathematical formulas with time-shifting (e.g., `RPM[-1]`, `Boost@-0.5s`) - **Analysis Algorithms** - AFR/Lambda drift and zone detection, derived metrics (VE, injector duty cycle), signal filters, and descriptive statistics (`src/analysis/`) - **Table Generators** - Lambda delay and acceleration enrichment tuning tables mined from one or more logs, each a top-level tool beside Histogram, with auto-suggested channel roles, confidence-tiered cells, an event inspector, and CSV/clipboard/PNG/PDF export (`src/analysis/tables/`, `src/ui/table_generator.rs`, `src/ui/table_export.rs`) -- **GPS Track Map** - Right-side data panel with a track map: lap detection, channel-colored polyline (Viridis/Turbo with editable range), hover-scrub/click-seek cursor sync, and opt-in Esri/OSM tile backgrounds (`src/ui/widgets/track_map.rs`, `src/tiles.rs`, `src/laps.rs`). GPS coordinate encodings are auto-detected and normalized to decimal degrees (`GpsCoordSpec` in `src/laps.rs`): NMEA `DDMM.mmmm`, milli/micro/1e-7-scaled integer degrees, and 0-360 longitude. Detection is conservative - values already in valid degree ranges are never transformed, and radians are deliberately not detected (ambiguous with genuine near-equator degree tracks). +- **GPS Track Map** - Right-side data panel with a track map: lap detection, channel-colored polyline (theme Sequential / Diverging map with editable range, 3px with a bg_void casing), hover-scrub/click-seek cursor sync, and opt-in Esri/OSM tile backgrounds (`src/ui/widgets/track_map.rs`, `src/tiles.rs`, `src/laps.rs`). GPS coordinate encodings are auto-detected and normalized to decimal degrees (`GpsCoordSpec` in `src/laps.rs`): NMEA `DDMM.mmmm`, milli/micro/1e-7-scaled integer degrees, and 0-360 longitude. Detection is conservative - values already in valid degree ranges are never transformed, and radians are deliberately not detected (ambiguous with genuine near-equator degree tracks). - **Claude Desktop / MCP Integration** - Embedded MCP server (`src/mcp/`) lets Claude control the running app over `http://localhost:52385/mcp` — select channels, add computed channels, query log data - **Unit Preferences** - Users can select display units for temperature, pressure, speed, distance, fuel economy, volume, flow rate, and acceleration - **Field Normalization** - Maps ECU-specific channel names to standardized names for cross-ECU comparison - **Scatter Plot Tool** - XY scatter plot for analyzing channel correlations - **Histogram Tool** - 2D heatmap view of channel distributions -- **Export Options** - Export charts as PNG or PDF -- **Colorblind Mode** - Wong's optimized color palette for accessibility +- **Export Options** - Export the chart, scatter plots, histogram or a tuning table as PNG or PDF on the Paper or Graphite sheet, or copy it as an image for pasting +- **Log Compare** - Overlay a second log in the same tab (issue #98): channels pair by name, log B is dashed in its pair's colour on a shared scale, with a time offset, Align at cursor, A/B tags and compare-aware exports (see the Log Compare Contract) +- **Night Drive design system** - one dark theme with an Ultra intensity slider (Stealth · Night Drive · Ultra), Color blind mode (Okabe-Ito series, cividis and blue/orange maps) and High contrast; tokens generated from `design/tokens/tokens.json` (see the Theme Contract) +- **About window** - Help → About UltraLog: version, licence, links, Check for Updates - **Playback** - Play through log data at 0.25x to 8x speed - **Cursor Tracking** - Lock view to follow cursor during playback/scrubbing - **Min/Max Legend** - Shows peak values for each channel - **Initial Zoom** - Charts start zoomed to first 60 seconds for better initial view - **Multi-File Tabs** - Chrome-style tabs for working with multiple log files simultaneously - **Internationalization** - `rust-i18n`-backed language selection (`src/i18n.rs`) -- **Settings Persistence** - Unit/display/normalization preferences survive restarts (see [Settings Persistence Contract](#settings-persistence-contract)) +- **Settings Persistence** - Unit/display/theme/normalization/export preferences survive restarts (see [Settings Persistence Contract](#settings-persistence-contract)) - **Auto-Update** - Automatic update checking and installation ### Keyboard Shortcuts @@ -708,7 +895,7 @@ Handled in `UltraLogApp::handle_keyboard_shortcuts` (`src/app.rs`); ignored whil - **Arrow Left/Right** - Step cursor one record (Shift = 10 records) - **Home/End** - Jump cursor to start/end of log - **Escape** - Stop playback -- **Cmd/Ctrl+E** - Export the current view (chart, scatter plot, or histogram) as PNG +- **Cmd/Ctrl+E** - Open the ExportDialog for the current view - **Space** - Toggle play/pause ## Key Dependencies @@ -730,7 +917,8 @@ Handled in `UltraLogApp::handle_keyboard_shortcuts` (`src/app.rs`); ignored whil `serde_yaml` fork, and unlike `serde_yaml_ng` its libyaml backend is maintained too. Do not go back to `serde_yml` or `serde_yaml`. - **printpdf** (0.12) - PDF generation -- **image** (0.25) - PNG export +- **image** (0.25) - PNG encoding +- **skrifa** (0.44) / **vello_cpu** (0.1) - PNG export text and shapes: skrifa reads glyph outlines from the bundled fonts, vello_cpu fills them as paths (its glyph-atlas `text` feature is off) - **memmap2** (0.9) - Memory-mapped file loading for large files - **rayon** (1.12) - Parallel iteration for parsing - **dirs** (6.0) - Cross-platform app data directory detection for cache and settings diff --git a/Cargo.lock b/Cargo.lock index 1dadae2c..b71534d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1054,6 +1054,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "egui-phosphor" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4299551488dddaa8f757727e1eb4ffdaf277ffe6e30b413403468f5ecf4634" +dependencies = [ + "egui", +] + [[package]] name = "egui-wgpu" version = "0.36.1" @@ -4578,7 +4587,7 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "ultralog" -version = "2.16.2" +version = "3.0.0" dependencies = [ "anyhow", "arboard", @@ -4586,6 +4595,7 @@ dependencies = [ "chrono", "dirs", "eframe", + "egui-phosphor", "egui_extras", "egui_plot", "encoding_rs", @@ -4606,6 +4616,7 @@ dependencies = [ "serde", "serde_json", "serde_norway", + "skrifa", "strum", "tar", "thiserror 2.0.20", @@ -4614,6 +4625,7 @@ dependencies = [ "tracing-subscriber", "ureq", "uuid", + "vello_cpu", "winresource", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index 2c8a9759..8fe1f641 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ultralog" -version = "2.16.2" +version = "3.0.0" edition = "2024" # egui/eframe 0.36 is the binding constraint on the minimum supported Rust # version; edition 2024 itself only needs 1.85. @@ -32,6 +32,8 @@ eframe = { version = "0.36.1", default-features = false, features = [ "wayland", ] } egui_plot = "0.37.0" +# Phosphor icons (regular outline; fill for transport and the favourite star) +egui-phosphor = { version = "0.14", features = ["fill"] } egui_extras = { version = "0.36.1", features = ["image"] } # Serialization @@ -68,6 +70,10 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] # PDF generation for chart export printpdf = "0.12" +# Export rasterizing: glyph outlines (skrifa) filled as paths by vello_cpu for PNG text. +# Same versions epaint already builds; vello_cpu without its glyph-atlas "text" feature. +skrifa = "0.44" +vello_cpu = { version = "0.1", default-features = false, features = ["std", "u8_pipeline"] } # Error handling thiserror = "2.0.20" diff --git a/README.md b/README.md index 1b59f420..2e186296 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A high-performance, cross-platform ECU log viewer written in Rust. ![CI](https://github.com/ClassicMiniDIY/UltraLog/actions/workflows/ci.yml/badge.svg) ![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg) -![Version](https://img.shields.io/badge/version-2.16.2-green.svg) +![Version](https://img.shields.io/badge/version-3.0.0-green.svg) --- @@ -20,11 +20,26 @@ UltraLog is an **independent, open-source** desktop application designed for aut - **Fast** - Handles massive log files without lag using LTTB downsampling - **Universal** - Supports multiple ECU formats in one unified interface for cross-platform data analysis - **Cross-platform** - Runs natively on Windows, macOS, and Linux -- **Accessible** - Colorblind-friendly palette and clear visualization +- **Accessible** - Color blind mode, High contrast, keyboard focus and status icons on every message - **Open** - AGPL-3.0 licensed with documented format specifications for community benefit --- +## Screenshots + +![Log Viewer in Night Drive](docs/screenshots/chart-night.png) +*Log Viewer in Night Drive: a Haltech pull with RPM, MAP, TPS and ignition timing.* + +| Ultra | Color blind mode | +| --- | --- | +| ![The same chart at Ultra intensity](docs/screenshots/chart-ultra.png) | ![The same chart in Color blind mode](docs/screenshots/chart-cvd.png) | + +| Lambda Delay table | Export dialog | +| --- | --- | +| ![Lambda Delay table with confidence badges](docs/screenshots/lambda-delay.png) | ![Export dialog with Paper and Graphite sheets](docs/screenshots/export-dialog.png) | + +--- + ## Features ### Data Visualization @@ -46,6 +61,7 @@ UltraLog is an **independent, open-source** desktop application designed for aut - **Drag and drop** - Simply drop files onto the window to load them - **Per-tab state** - Each tab maintains its own channel selections and view settings - **Duplicate detection** - Prevents loading the same file twice +- **Log compare** - Overlay a second log in the same tab: channels pair by name, log B is dashed in the same colour on a shared scale, with a time offset and **Align at cursor** to line up two pulls ### Unit Conversion Configurable units for 8 measurement categories: @@ -60,8 +76,11 @@ Configurable units for 8 measurement categories: - **AFR/Lambda** - Air-Fuel Ratio, Lambda ### Export Options -- **PNG Export** - Save chart views as PNG images -- **PDF Export** - Generate PDF reports of your visualizations +- **Export dialog** - PDF or PNG on a light **Paper** or dark **Graphite** sheet, remembered per format (`Ctrl/Cmd + E`) +- **One sheet for every tool** - lockup, title, time range and record counts, the chart or table, its legend or colour scale, and a footer with version and date +- **PDF with real text** - A4 landscape, fonts embedded, so `−`, `λ`, `°` and non-Latin channel names print and stay selectable; PNG is 1920×1080 +- **Copy image** - the chart or table alone, ready to paste into a document +- **Always colour-blind safe** - exports use the colour-blind safe palette whatever your app settings are ### Computed Channels - **Formula-based virtual channels** - Create custom channels from mathematical expressions @@ -77,7 +96,7 @@ Configurable units for 8 measurement categories: ### Additional Tools - **Scatter Plot** - XY scatter visualization for channel correlation analysis -- **Histogram** - 2D heatmap visualization with configurable grid sizes (10x10 to 25x25) for analyzing channel distributions +- **Histogram** - 2D heatmap visualization with configurable grid sizes for analyzing channel distributions, coloured with the theme's colour-blind safe maps - **MCP Server** - Built-in Model Context Protocol server lets Claude Desktop (or any MCP client) control UltraLog — load files, select channels, get stats, create computed channels, and run analysis via `http://localhost:52453/mcp` - **Table Generators** - Mine logged events into tuning tables. Each is a tool tab beside Histogram (⌘4 / ⌘5), with setup in the Tool Properties panel and CSV, clipboard, PNG and PDF export: - **Lambda Delay Table** - time from injector pulse-width steps to the wideband response, binned by RPM × load, for closed-loop O2 delay tables (ms, engine cycles or ignition events) @@ -91,10 +110,16 @@ Configurable units for 8 measurement categories: - **Normalization Editor** - Create custom field name mappings for cross-ECU comparison - **Field Normalization** - Maps ECU-specific channel names to standard names (e.g., "Act_AFR" → "AFR") +### Night Drive Look +- **Ultra intensity** - one slider from **Stealth** (quiet greys, the default) through **Night Drive** and **Neon** to **Ultra** (glow, gradients and a grid floor under the chart), in the menu bar and in Settings. It never changes data colours or text contrast +- **Built for data** - one cyan accent, a sunset redline, mono numbers, and heatmaps on perceptual colour maps (no rainbow) + ### Accessibility -- **Colorblind mode** - Wong's optimized color palette designed for deuteranopia, protanopia, and tritanopia +- **Color blind mode** - Okabe-Ito series colours, cividis and blue/orange heatmap maps, dashed and labelled lines, a white cursor and stronger text contrast +- **High contrast** - black grounds, white text and outlined controls, with the neon effects off +- **Keyboard focus** - a visible focus ring after Tab or arrow navigation; every status message shows an icon and words, never colour alone - **Font scaling** - Four font sizes (S, M, L, XL) for improved readability -- **Custom font** - Clear, readable Outfit typeface +- **Fonts** - Outfit for the interface, Saira for titles, JetBrains Mono for numbers, Noto for Devanagari, Bengali and Arabic - **Toast notifications** - Non-intrusive feedback for user actions - **Multilingual support** - 15 languages including English, Spanish, German, French, Italian, Portuguese, Chinese, Japanese, Hindi, Arabic, Bengali, Russian, Urdu, and Indonesian @@ -316,7 +341,7 @@ cargo build --release 5. **Customize your view** - - Change units via the **Units** menu - Enable **Cursor Tracking** to keep the cursor centered - - Enable **Colorblind Mode** for accessible colors + - Set the look in **Settings → Display**: Ultra intensity, **Color blind mode**, **High contrast** --- @@ -372,6 +397,18 @@ UltraLog automatically detects the ECU format based on file contents: - Click and drag to pan the view - Double-click to reset zoom +### Comparing Two Logs + +Overlay a second log on the current tab, for example the same pull before and after a tune change: + +1. Load both logs and open the tab of the first one (log **A**) +2. In the Channels panel, choose the second log under **Compare** (or **File → Compare with…**). **Open file…** loads a log that is not open yet +3. Pick channels as usual. Each channel brings its match from log **B** along, paired by name (with field normalization on, `Engine Speed` pairs with `RPM`). Log B is drawn dashed in the same colour, and a pair shares one scale so the higher peak looks higher +4. Use the **Log A | Log B** switch to add a channel that only log B has. A card marked "not in B" has no match +5. Line the logs up with **Log B offset** (drag, type, or −1 / −0.1 / +0.1 / +1 s), or with **Align at cursor**: put the cursor on an event in log A, click **Align at cursor**, then click the same event in log B. Esc cancels + +The legend, the direct labels and the channel cards tag every series **A** or **B**. Legend values come from each log at the cursor and stay blank where a log has no data. Exports draw log B dashed, tag the legend and name both files and the offset. + ### Timeline and Playback **Timeline controls (bottom of window):** @@ -480,17 +517,18 @@ Boost rate of change (kPa/second) - Edit or delete templates from the Computed Channels window - Templates can be reused across different log files -### Exporting Charts +### Exporting -**PNG Export:** -1. File menu → Export → PNG -2. Choose save location -3. Current chart view is saved as a PNG image +1. Open the **Export** dialog: File → Export..., the **Export...** button in the Tools panel, or `Ctrl/Cmd + E` +2. Choose **PDF** or **PNG** +3. Choose the sheet: **Paper** (light, prints cleanly) or **Graphite** (dark grey). UltraLog remembers the theme for each format +4. Click **Export PDF** / **Export PNG** and choose where to save, or **Copy image** to put the chart or table on the clipboard -**PDF Export:** -1. File menu → Export → PDF -2. Choose save location -3. Chart is exported as a PDF document +What each tool exports: +- **Log Viewer** - the visible time range of the chart, every selected channel with its unit in the legend +- **Scatter Plots** - both density plots side by side +- **Histogram** - the grid as the tool shows it (custom ranges, sample filters and minimum hits applied) +- **Lambda Delay / Accel Enrichment** - the table with values, count badges and edge labels ### Scatter Plot Tool @@ -522,7 +560,7 @@ The histogram tool creates 2D heatmap visualizations for analyzing channel distr 7. Toggle between "Hit Count" and "Average Z-Value" modes **Features:** -- Heat gradient from blue (low) to red (high) +- Cells on the theme's sequential colour map (cividis in Color blind mode); empty cells are hatched - Cell statistics on hover/click - Cursor position tracking during playback - Analyze data distributions and correlations @@ -547,16 +585,20 @@ The Analysis Tools panel provides built-in signal processing and statistical ana ### Accessibility Features -**Colorblind Mode:** -- Enable via Settings panel → Display → Colorblind Mode -- Uses Wong's optimized 8-color palette -- Designed to be distinguishable for deuteranopia, protanopia, and tritanopia +**Color blind mode:** +- Enable via Settings panel → Display → **Color blind mode** +- Series use the 8 Okabe-Ito colours; heatmaps use cividis and a blue/orange diverging map +- Lines past the palette repeat dashed, then dotted, and the chart labels each line at its end +- The cursor turns white so it never matches a series -**Standard color palette:** -Blue, Orange, Green, Red, Purple, Brown, Pink, Gray, Yellow, Cyan +**High contrast:** +- Enable via Settings panel → Display → **High contrast** +- Black grounds, white text, outlined controls; turns off glow, gradients and the grid floor -**Colorblind palette:** -Black, Orange, Sky Blue, Bluish Green, Yellow, Blue, Vermillion, Reddish Purple +**Ultra intensity:** +- The theme menu at the end of the menu bar (it shows the current level), or Settings panel → Display → **Ultra intensity** +- 0-100 with stops at Stealth (default), Night Drive, Neon and Ultra +- Changes only the chrome; data colours and text contrast stay the same **Font Scaling:** - Access via Settings panel → Display → Font Size @@ -578,7 +620,7 @@ Black, Orange, Sky Blue, Bluish Green, Yellow, Blue, Vermillion, Reddish Purple | ---------- | -------------- | | Open file | `Ctrl/Cmd + O` | | Close tab | `Ctrl/Cmd + W` | -| Export PNG | `Ctrl/Cmd + E` | +| Export | `Ctrl/Cmd + E` | | Play/Pause | `Space` | | Stop | `Escape` | @@ -594,6 +636,8 @@ Black, Orange, Sky Blue, Bluish Green, Yellow, Blue, Vermillion, Reddish Purple | File Dialogs | [rfd](https://github.com/PolyMeilex/rfd) 0.17 | | Image Processing | [image](https://github.com/image-rs/image) 0.25 | | PDF Generation | [printpdf](https://github.com/fschutt/printpdf) 0.12 | +| Export rendering | [vello_cpu](https://github.com/linebender/vello) 0.1 / [skrifa](https://github.com/googlefonts/fontations) 0.44 | +| Icons | [Phosphor](https://phosphoricons.com) via egui-phosphor 0.14 | | Serialization | serde / serde_json 1.0 | | Error Handling | thiserror 2.0 / anyhow 1.0 | | Logging | tracing / tracing-subscriber 0.3 | @@ -801,5 +845,6 @@ Contributions are welcome! Please feel free to submit a Pull Request. ## Acknowledgments - [egui](https://github.com/emilk/egui) - The immediate mode GUI library that makes this possible -- Wong's colorblind-safe palette for accessibility research +- Masataka Okabe and Kei Ito's colour-blind safe palette, and the cividis colour map (Nuñez, Anderton and Renslow) +- [Phosphor Icons](https://phosphoricons.com), [Outfit](https://fonts.google.com/specimen/Outfit), [Saira](https://fonts.google.com/specimen/Saira), [JetBrains Mono](https://www.jetbrains.com/lp/mono/) and Noto - The automotive tuning community for feedback and feature requests diff --git a/assets/Outfit-Bold.ttf b/assets/Outfit-Bold.ttf deleted file mode 100644 index 03068242..00000000 Binary files a/assets/Outfit-Bold.ttf and /dev/null differ diff --git a/assets/Outfit-Regular.ttf b/assets/Outfit-Regular.ttf deleted file mode 100644 index 6e783e96..00000000 Binary files a/assets/Outfit-Regular.ttf and /dev/null differ diff --git a/assets/brand/ultralog-lockup-paper.png b/assets/brand/ultralog-lockup-paper.png new file mode 100644 index 00000000..0eb15e7e Binary files /dev/null and b/assets/brand/ultralog-lockup-paper.png differ diff --git a/assets/brand/ultralog-lockup.png b/assets/brand/ultralog-lockup.png new file mode 100644 index 00000000..03573d72 Binary files /dev/null and b/assets/brand/ultralog-lockup.png differ diff --git a/assets/fonts/JetBrainsMono-Medium.ttf b/assets/fonts/JetBrainsMono-Medium.ttf new file mode 100644 index 00000000..3b7c532c Binary files /dev/null and b/assets/fonts/JetBrainsMono-Medium.ttf differ diff --git a/assets/fonts/JetBrainsMono-OFL.txt b/assets/fonts/JetBrainsMono-OFL.txt new file mode 100644 index 00000000..821a3dac --- /dev/null +++ b/assets/fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/JetBrainsMono-Regular.ttf b/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 00000000..ae4c8745 Binary files /dev/null and b/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/assets/fonts/NotoSansArabic-OFL.txt b/assets/fonts/NotoSansArabic-OFL.txt new file mode 100644 index 00000000..14c589f6 --- /dev/null +++ b/assets/fonts/NotoSansArabic-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/NotoSansArabic-Regular.ttf b/assets/fonts/NotoSansArabic-Regular.ttf new file mode 100644 index 00000000..ea1d1b71 Binary files /dev/null and b/assets/fonts/NotoSansArabic-Regular.ttf differ diff --git a/assets/fonts/NotoSansBengali-OFL.txt b/assets/fonts/NotoSansBengali-OFL.txt new file mode 100644 index 00000000..d11cac69 --- /dev/null +++ b/assets/fonts/NotoSansBengali-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/bengali) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/NotoSansBengali-Regular.ttf b/assets/fonts/NotoSansBengali-Regular.ttf new file mode 100644 index 00000000..6419ad2b Binary files /dev/null and b/assets/fonts/NotoSansBengali-Regular.ttf differ diff --git a/assets/fonts/NotoSansDevanagari-OFL.txt b/assets/fonts/NotoSansDevanagari-OFL.txt new file mode 100644 index 00000000..cd2cc5c9 --- /dev/null +++ b/assets/fonts/NotoSansDevanagari-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/devanagari) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/NotoSansDevanagari-Regular.ttf b/assets/fonts/NotoSansDevanagari-Regular.ttf new file mode 100644 index 00000000..7c81e847 Binary files /dev/null and b/assets/fonts/NotoSansDevanagari-Regular.ttf differ diff --git a/assets/fonts/NotoSansSC-LanguageNames.ttf b/assets/fonts/NotoSansSC-LanguageNames.ttf new file mode 100644 index 00000000..b61bb1d6 Binary files /dev/null and b/assets/fonts/NotoSansSC-LanguageNames.ttf differ diff --git a/assets/fonts/NotoSansSC-OFL.txt b/assets/fonts/NotoSansSC-OFL.txt new file mode 100644 index 00000000..1c9f4328 --- /dev/null +++ b/assets/fonts/NotoSansSC-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Outfit-Medium.ttf b/assets/fonts/Outfit-Medium.ttf new file mode 100644 index 00000000..3298a272 Binary files /dev/null and b/assets/fonts/Outfit-Medium.ttf differ diff --git a/assets/fonts/Outfit-OFL.txt b/assets/fonts/Outfit-OFL.txt new file mode 100644 index 00000000..723cd447 --- /dev/null +++ b/assets/fonts/Outfit-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Outfit-Regular.ttf b/assets/fonts/Outfit-Regular.ttf new file mode 100644 index 00000000..15bd6dc3 Binary files /dev/null and b/assets/fonts/Outfit-Regular.ttf differ diff --git a/assets/fonts/Outfit-SemiBold.ttf b/assets/fonts/Outfit-SemiBold.ttf new file mode 100644 index 00000000..9902685e Binary files /dev/null and b/assets/fonts/Outfit-SemiBold.ttf differ diff --git a/assets/fonts/Saira-BoldItalic.ttf b/assets/fonts/Saira-BoldItalic.ttf new file mode 100644 index 00000000..913ff89c Binary files /dev/null and b/assets/fonts/Saira-BoldItalic.ttf differ diff --git a/assets/fonts/Saira-ExtraBoldItalic.ttf b/assets/fonts/Saira-ExtraBoldItalic.ttf new file mode 100644 index 00000000..74892101 Binary files /dev/null and b/assets/fonts/Saira-ExtraBoldItalic.ttf differ diff --git a/assets/fonts/Saira-OFL.txt b/assets/fonts/Saira-OFL.txt new file mode 100644 index 00000000..38d9b881 --- /dev/null +++ b/assets/fonts/Saira-OFL.txt @@ -0,0 +1,94 @@ +Copyright 2020 The Saira Project Authors (https://github.com/Omnibus-Type/Saira) + + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Saira-SemiBold.ttf b/assets/fonts/Saira-SemiBold.ttf new file mode 100644 index 00000000..2a47886d Binary files /dev/null and b/assets/fonts/Saira-SemiBold.ttf differ diff --git a/assets/icons/linux.png b/assets/icons/linux.png index 35e6a28c..abe5c82a 100644 Binary files a/assets/icons/linux.png and b/assets/icons/linux.png differ diff --git a/assets/icons/mac.png b/assets/icons/mac.png index 35e6a28c..c4c45f92 100644 Binary files a/assets/icons/mac.png and b/assets/icons/mac.png differ diff --git a/assets/icons/windows.ico b/assets/icons/windows.ico index 163eca0b..ceabf21f 100644 Binary files a/assets/icons/windows.ico and b/assets/icons/windows.ico differ diff --git a/assets/icons/windows.png b/assets/icons/windows.png index 35e6a28c..abe5c82a 100644 Binary files a/assets/icons/windows.png and b/assets/icons/windows.png differ diff --git a/design/HANDOFF-APP.md b/design/HANDOFF-APP.md new file mode 100644 index 00000000..c2908c2c --- /dev/null +++ b/design/HANDOFF-APP.md @@ -0,0 +1,77 @@ +# Handoff: UltraLog desktop app (Rust + egui) + +Implement the Night Drive design system in the UltraLog desktop app. The design is final; this brief says what to build and how to check it. The brand book is `design-system/README.md`; component specs are `design-system/components//README.md`, each with a `preview.html` and a render in `reference/-night.png`. + +## Ground rules + +- **Never hard-code a colour, size or radius.** Every value comes from `Tokens` (`rust/ultralog_theme.rs`) or its `space`, `radius`, `stroke`, `text` modules. +- **Stay inside egui's normal theme system** for widgets (`Visuals`, `Style`, `Button::fill`). Custom painting is allowed only for the four helpers in `design-system/6-implementation.md`: redline, glow (built-in `Shadow`), series bloom, perspective floor. +- **Data colours never depend on intensity.** Series, colour maps, status and cursor come from the palette (standard or color-blind safe), not from the intensity slider. +- **Text contrast is tested, not hoped for.** Keep `theme.rs`'s tests passing and add `tools/check_contrast.py` to CI. +- Match the reference renders closely, but the app's real layout wins where they disagree; the renders are component studies, not full screens. +- One commit (or PR) per phase, app building and running at every step. + +## Phase 0: Explore and plan + +Report before changing anything: egui / eframe / egui_plot versions; where `Visuals`, `Style` and fonts are set; how settings are persisted (and the existing Color blind mode / Wong palette code); how series colours are assigned; how the plot, scatter, histogram, lambda-delay and accel-enrichment views draw colour (find every Turbo/jet usage); how PNG/PDF export renders. Produce a plan that maps each phase below onto real files. + +## Phase 1: Tokens and theme runtime + +1. Add `rust/ultralog_theme.rs` and `rust/theme.rs` to the app crate (for example `src/ui/theme/`). They target egui 0.31; if the app is on another version, adapt field names (`CornerRadius` was `Rounding` before 0.31; `Shadow` fields changed around then too). +2. Keep `ultralog_theme.rs` generated: add a small build step or script that regenerates it from `design/tokens/tokens.json` (the bundle's generator logic is simple: one `Color32` per token per theme, alpha premultiplied). Commit the generated file so builds don't need Python. +3. Run `cargo test`: `anchors_are_exact`, `text_contrast_holds_across_the_slider`, `data_colors_ignore_intensity` must pass. + +## Phase 2: Settings + +In Settings → Display: +- **Ultra intensity** slider 0-100, default 0 (Stealth), with named stops (Stealth 0, Night Drive 35, Neon 70, Ultra 100); show "Night Drive · 35" style label; caption "How much neon the app wears. Never changes your data colours or text contrast." See `components/IntensityControl`. +- **Color blind mode** (existing checkbox): now drives the `cvd` data palette and AAA text through `theme::tokens_at`. +- **High contrast** (new checkbox). When on, disable the intensity slider and say why in its caption. +- Persist all three; migrate the existing color-blind setting. Recompute `Tokens` and call `ctx.set_visuals(theme::visuals(&t))` when any changes; store the current `Tokens` somewhere every view can read (context data or app state). + +## Phase 3: Fonts and type + +- Register three families: Outfit (proportional default), JetBrains Mono (monospace), Saira as `FontFamily::Name("display")`. Map the type scale in `ultralog_theme::text` to `TextStyle`s (body 14, button/label 13, small/caption 12, heading 15, plus named styles for title 18, overline 11 uppercase, data-lg 18 mono). +- egui needs TTF/OTF. `fonts/ttf/` holds Latin subsets for a quick start. For release, use full-coverage files: download Saira, Outfit and JetBrains Mono from https://github.com/google/fonts (`ofl/saira`, `ofl/outfit`, `ofl/jetbrainsmono`). Saira and Outfit there are variable fonts and egui renders only the default instance, so create static weights with `fonttools varLib.instancer` (Saira 600, 700 italic, 800 italic; Outfit 400/500/600). JetBrains Mono ships static TTFs. +- The app supports 15 languages: keep the existing fallback fonts (CJK, Cyrillic, etc.) after these in each family's fallback list. +- Numbers in tables, legends, channel cards and the playback bar use the monospace family. + +## Phase 4: Chrome components + +Implement, in this order, each per its `components//README.md` and reference render: **ModeNav** (top workspace switch, uppercase Saira, cyan active bar with glow), **ActivityRail**, **FileTabs** (ECU source badge, middle-truncated names), **Button** variants (primary = `cyan` fill + `on_cyan`, secondary, ghost, danger, icon), **Segmented**, **TextField/select**, **Checkbox**, **Slider**, **Window** (floating tools: redline on top edge, title in display font), **Notice** and **Badge** (status = icon + word), **DropZone** (welcome screen with perspective floor), **LibraryItem** and **FormulaEditor** (syntax colours: channels cyan, numbers gold, functions violet, time shifts orange). + +Icons: switch to Phosphor regular via `egui-phosphor` if the app isn't already on it. Status icons are fixed: check, warning triangle, octagon, info circle. + +## Phase 5: Data views + +- **Series assignment:** by selection order, stable for the session (removing a channel doesn't recolour others). Series 9-10 dashed when the palette is color-blind safe (`theme::series` returns the dashed flag). +- **Plot / stacked plots:** `plot_bg`, `plot_grid` / `plot_grid_major`, `plot_axis` ticks in mono, `plot_cursor` line (never a series colour), `plot_selection` for zoom/lap ranges, floating legend on `plot_legend_bg` with Cursor row first. With color-blind palette: direct end labels and legend top-left. Bloom (`fx.bloom` > 0): draw a wider translucent copy of each line underneath. Ultra (`fx.plot_floor`): perspective floor behind data. +- **ChannelCard / ChannelList:** series swatch (line glyph) + 2px top strip; names in ink, values mono. +- **Heatmaps** (Histogram, Lambda Delay, Accel Enrichment) and **scatter density / track map:** replace Turbo/jet with `theme::seq` (7-stop OKLab interpolation). Signed values around a target (AFR vs target, corrections) use the diverging map (`div_0..div_4`). Cell text uses `on_seq_N` / `on_div_N` of the nearest stop, on a `cell_plate` rectangle when the plate's alpha > 0. Empty cells hatched on `cell_empty`. Confidence badge by fill style (filled high, outlined medium, dashed low). Selected cell: 2px cyan ring with a `bg_void` gap. Always show the ColorScale. +- **PlaybackBar:** cyan play button, time readout in cyan mono matching the cursor, thin-space-grouped record counter. + +## Phase 6: Exports + +- **ExportDialog:** format PDF/PNG and theme Paper (default) / Graphite with thumbnails; info notice that exports use the color-blind safe palette. Remember last choice. +- Render exports with `theme::export_tokens(..)`, ignoring intensity, color blind mode and high contrast. Paper uses `logos/ultralog-lockup-paper.svg`, Graphite `logos/ultralog-lockup.svg` (rasterise once at build, e.g. with `resvg`, or ship PNGs). +- **ExportSheet layout:** redline top edge, lockup, title, time range + record/channel counts, chart, legend, footer (ultralog.co · version · date). Graphite PDFs keep a white page margin. PNG at 2×. "Copy for paste" copies the chart only. + +## Phase 7: App icon and brand assets + +- Generate `.icns` (macOS), `.ico` (Windows) and Linux PNG sizes from `logos/ultralog-app-icon-1024.png`; wire them into the bundler config. +- About window and splash: `ultralog-lockup.svg` on `bg_void` with the redline. + +## Phase 8: Verify + +- `cargo test`, `cargo clippy`, `python3 design/tools/check_contrast.py design/tokens/tokens.json`. +- Screenshot each mode in Night Drive, Stealth, Ultra, Color-blind safe and High contrast; compare with `reference/`. Check: no hard-coded colours remain (grep for `Color32::from_rgb` outside the theme module), no series ever equals the cursor colour, keyboard focus visible everywhere, every status shows an icon and a word. + +## Acceptance criteria + +- [ ] Intensity slider moves smoothly from Stealth to Ultra; data colours and text contrast don't change. +- [ ] Color blind mode: Okabe-Ito series, dashed 9-10, direct labels, cividis maps, value plates, AAA text, no bloom/floor. +- [ ] High contrast: black grounds, outlined surfaces, 5px focus ring, slider disabled with reason. +- [ ] No Turbo/jet colormap anywhere. +- [ ] Exports default to Paper, offer Graphite, always color-blind safe, carry the lockup and footer. +- [ ] New app icon in all platform bundles. +- [ ] All tests and the contrast checker pass in CI. diff --git a/design/HANDOFF-WEBSITE.md b/design/HANDOFF-WEBSITE.md new file mode 100644 index 00000000..8a625b35 --- /dev/null +++ b/design/HANDOFF-WEBSITE.md @@ -0,0 +1,41 @@ +# Handoff: ultralog.co (Nuxt + Vue + TypeScript on Cloudflare) + +Rebuild ultralog.co, currently a single GitHub Pages page, as a Nuxt site deployed on Cloudflare, with download and release content generated from the app's GitHub releases. Structure, look and behaviour are in `design-system/5-website.md`; components in `design-system/components/{SiteHero,FeatureCard,DownloadCard,ReleaseCard}`, with renders in `reference/`. + +## Stack + +- Latest stable Nuxt, Vue 3, TypeScript (strict). Nuxt Content for docs pages if the wiki is imported. +- Styling: `design/tokens/tokens.css` imported globally (it declares every token as a CSS variable, with `[data-theme="…"]` overrides) plus scoped component CSS, or Tailwind/UnoCSS whose theme maps to the variables (`colors: { cyan: 'var(--cyan)', … }`). Never hard-code colours. +- Fonts: self-host `design/fonts/woff2/` (the `@font-face` rules are already in `tokens.css`; adjust the URL path to where the files are served). +- `design/tokens/tokens.ts` gives typed, resolved values for anything that needs colours in JS (OG images, charts). + +## Pages + +`/` (hero, features, mode screenshots, supported ECUs, downloads, latest release), `/download`, `/releases`, `/docs` (start by linking to the GitHub wiki; optionally import it into Nuxt Content), `/ecus/[slug]`. Copy existing text from the current ultralog.co where it still applies; follow the voice rules in `design-system/README.md`. + +## Release-driven content + +- A server/build utility fetches `GET https://api.github.com/repos/{owner}/{repo}/releases` (use a `GITHUB_TOKEN` to avoid rate limits). Map assets to platforms by file name (`.dmg` → macOS + arch, `.msi`/`.exe` → Windows, `.AppImage`/`.deb` → Linux), sizes from the asset, notes from the release body (render Markdown). +- Prerender these pages at build. Rebuild when a release is published: in the **app** repo, add a GitHub Actions workflow on `release: published` that sends a `repository_dispatch` to the **site** repo (or calls a Cloudflare deploy hook); the site's deploy workflow listens for it. +- DownloadCard picks the visitor's OS/architecture client-side (user agent / `navigator.userAgentData`) and marks it "Your system"; without JS, all three cards show equally. + +## Themes and accessibility + +- Default `data-theme` is `night`. Footer offers **Color blind mode** (sets `cvd`) and optionally an Ultra intensity toggle (`stealth` / `night` / `ultra`), remembered in `localStorage`, applied before first paint (inline script in `app.vue` head) to avoid a flash. +- Respect `prefers-contrast: more` → `hc`, and `prefers-reduced-motion`. +- Meet WCAG AA everywhere (AAA in `cvd`/`hc`, as the tokens guarantee); real alt text on screenshots; visible focus (`--focus-ring`). +- Social/OG image: `design/logos/hero-sunset-dash.png`; favicon from `ultralog-mark.svg`. + +## Cloudflare deployment + +- Deploy to Cloudflare (Workers with static assets, or Pages; follow Nuxt's current Cloudflare deployment docs for the right Nitro preset). The site can be fully prerendered (`nuxt generate`) unless server routes are wanted. +- CI: a GitHub Actions workflow builds and deploys with `cloudflare/wrangler-action` on push to `main`, on `repository_dispatch` (new app release), and on a manual trigger. Secrets: `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `GITHUB_TOKEN` (read-only for releases). +- Domain: move ultralog.co DNS to Cloudflare (or add the zone), attach the custom domain to the project, then retire the GitHub Pages site and its CNAME. Keep old URLs working with redirects if any existed. + +## Acceptance criteria + +- [ ] Lighthouse: performance ≥ 90, accessibility 100 on `/`. +- [ ] Publishing a GitHub release updates `/`, `/download` and `/releases` without a manual commit. +- [ ] Theme toggles persist and don't flash on load; `cvd` and `hc` match the app. +- [ ] No hard-coded colours; all from tokens. +- [ ] Hero, feature cards, downloads and release cards match `reference/` renders. diff --git a/design/README.md b/design/README.md new file mode 100644 index 00000000..b98b6c23 --- /dev/null +++ b/design/README.md @@ -0,0 +1,48 @@ +# UltraLog design handoff + +Everything needed to implement the UltraLog "Night Drive" design system in the desktop app (Rust + egui) and to rebuild ultralog.co (Nuxt on Cloudflare). Hand this folder to Claude Code; it is written to be read by Claude Code as well as by you. + +## What's inside + +| Folder | Contents | +| --- | --- | +| `HANDOFF-APP.md` | Task brief for the desktop app: phases, file-by-file guidance, acceptance criteria | +| `HANDOFF-WEBSITE.md` | Task brief for the Nuxt + Cloudflare website | +| `design-system/` | The full brand book: `README.md` (principles, colour, type, logo, voice), numbered sections (intensity, accessibility, data viz, exports, website, implementation), and every component's guidelines + HTML/CSS preview | +| `tokens/` | `tokens.json` (source of truth, 7 themes), `tokens.css` (web), `tokens.ts` (typed, resolved hex per theme) | +| `rust/` | `ultralog_theme.rs` (all tokens as egui `Color32` consts) and `theme.rs` (settings → live tokens, OKLab blending for the intensity slider, egui `Visuals` mapping, glow helper, tests). Compiles and passes tests against egui 0.31 | +| `fonts/` | `woff2/` for the web, `ttf/` for egui (Latin subsets; see the app brief for full-coverage files) | +| `logos/` | Lockups (dark, paper, mono), mark (colour, mono ink, mono white), app icon (SVG + 1024 PNG), original badge, hero art | +| `reference/` | PNG renders of every component in Night Drive, plus key components in Color-blind safe, High contrast, Ultra, Stealth and Paper. Claude Code can open these to compare its work | +| `tools/check_contrast.py` | Verifies every guaranteed colour pair in every theme; run in CI | + +## Starting in Claude Code + +1. **Install Claude Code** (once). macOS / Linux / WSL: `curl -fsSL https://claude.ai/install.sh | bash` (or `brew install --cask claude-code`). Windows PowerShell: `irm https://claude.ai/install.ps1 | iex`. Then open a new terminal and check `claude --version`. Docs: https://code.claude.com/docs/en/overview +2. **Copy this folder into the repo** at `design/` (so the app repo has `design/HANDOFF-APP.md`, `design/tokens/…`). Commit it; it's the reference for future work too. +3. **Open the repo and start a session:** + ```bash + cd path/to/ultralog + claude + ``` + The Claude desktop app's **Code** tab works too, and so does the VS Code extension. +4. **Paste a kickoff prompt** (below). Ask it to plan first; review the plan; then let it work phase by phase, one PR or commit per phase. + +### Kickoff prompt: desktop app + +``` +Read design/README.md, then design/HANDOFF-APP.md in full, then design/design-system/README.md and the numbered sections it references. Explore this codebase and report: egui/eframe/egui_plot versions, where Visuals/fonts are set today, how the color blind mode and settings are stored, how plots, heatmaps and the PNG/PDF export are drawn. Then write an implementation plan following the phases in HANDOFF-APP.md, adapted to what you found. Don't change code until I approve the plan. +``` + +### Kickoff prompt: website + +Run this in a new repo (or a `site/` folder) with this bundle copied to `design/`: + +``` +Read design/README.md, design/HANDOFF-WEBSITE.md and design/design-system/5-website.md. Then scaffold the Nuxt site described there, using design/tokens/tokens.css and the component guidelines in design/design-system/components/ (SiteHero, FeatureCard, DownloadCard, ReleaseCard). Start with a plan and the Cloudflare deployment setup; ask me for the GitHub repo owner/name and the Cloudflare account details you need. +``` + +### Tips + +- Add a short `CLAUDE.md` to each repo root pointing at `design/` ("UI work follows design/design-system; never hard-code colours, use the theme tokens"). Claude Code reads it every session. +- If the design changes later, update the design system page first, export new tokens, and rerun `python3 design/tools/check_contrast.py design/tokens/tokens.json`. diff --git a/design/design-system/1-ultra-intensity.md b/design/design-system/1-ultra-intensity.md new file mode 100644 index 00000000..85a9b71f --- /dev/null +++ b/design/design-system/1-ultra-intensity.md @@ -0,0 +1,28 @@ +# Ultra intensity + +One slider in Settings → Display, labelled **Ultra intensity**, 0-100, default 0 (Stealth). The app also shows it in a theme menu at the end of the menu bar. It lets people who find neon distracting turn it down to a clean dark tool, and lets people who want the full synthwave cockpit turn it all the way up. It is a single dial so it stays simple; the levels are names on the track, not separate settings. + +| | Stealth · 0 (default) | Night Drive · 35 | Neon · 70 | Ultra · 100 | +| --- | --- | --- | --- | --- | +| Grounds (`bg-*`, `line-*`) | Neutral greys | Indigo-black | Toward purple | Deep purple | +| `cyan` accent | Softened `#5cc8dc` | `#2de2ff` | Interpolated | Hotter `#3df0ff` | +| Sunset redline (`redline-*`) | Flat grey hairline | Full gradient | Full gradient | Full gradient | +| `glow-cyan` (primary hover, active tab) | none | 16px soft | Wider | 22px + 48px halo | +| `focus-ring` | Solid ring | Solid ring | Solid ring | Solid ring + glow | +| Grid floor (`grid-floor`) on drop zone | Barely there | `line` | Magenta tint | Hot magenta | +| Series bloom (`bloom-series`) | 0 | 0 | 2px | 4px | +| Plot floor (`plot-floor`) | off | off | faint | Perspective grid behind data | +| Data colours, text, contrast | **unchanged** | **unchanged** | **unchanged** | **unchanged** | + +## How it works + +- `stealth`, `night` and `ultra` are anchor themes with the same token names. The app interpolates every colour between the two nearest anchors in OKLab, and every glow radius and alpha linearly, so any value of the slider is valid. Neon (70) sits just past the midpoint between Night Drive and Ultra. +- Text contrast is tested across the whole slider: a unit test sweeps intensity 0-100 (with and without Color blind mode) and fails if any `ink-*` / `bg-*` pair drops below its guaranteed ratio. +- Series, colour maps and status colours never change with intensity; the cursor follows the accent tint. Bloom is drawn *under* the line in the line's own colour, so the line itself stays crisp. + +## Guard rails + +- **Color blind mode on:** intensity still changes chrome, but `bloom-series` and `plot-floor` are forced off. +- **High contrast on:** the slider is disabled at its High contrast values (no glows, no gradients, no floor) and its caption says why. +- **Exports:** ignore intensity entirely; Paper and Graphite have their own fixed values. +- **Reduced motion / low power:** glows render without blur animation; on integrated GPUs Ultra may drop the plot floor if frame time exceeds budget. diff --git a/design/design-system/2-accessibility.md b/design/design-system/2-accessibility.md new file mode 100644 index 00000000..dd426fda --- /dev/null +++ b/design/design-system/2-accessibility.md @@ -0,0 +1,43 @@ +# Accessibility + +There is no data on which kinds of colour vision deficiency UltraLog users have, so the color-blind safe theme is built for all of them (protan, deutan and tritan) and to WCAG 2.2 **AAA** where AAA applies. High contrast goes further for low vision. + +## Every theme (the floor) + +- Text 4.5:1 on every ground; borders of controls, the focus ring, icons and data lines 3:1. +- Every status has an icon shape and a word: check, triangle, octagon, circle-i. +- Confidence is a fill style (filled / outlined / dashed), never a colour. +- Empty cells are hatched, not just dark. +- Sequential maps rise monotonically in lightness, so they read in greyscale. +- Hovering a legend entry solos that series; every chart can be explored by interaction as well as colour. +- Keyboard: every control reachable, `focus-ring` visible, no keyboard traps in floating windows (Esc closes). + +## Color-blind safe (`cvd`), the Color blind mode setting + +| Role | Night Drive | Color-blind safe | +| --- | --- | --- | +| Series 1-8 | Neon set | Okabe-Ito / Wong, lightened for a dark ground: sky blue, orange, bluish green, yellow, blue, vermillion, reddish purple, grey | +| Series 9-10 | Coral, lilac | Repeat series 1-2, **dashed 6/4** (swatches dashed too) | +| Series identity | Legend | Legend **plus direct labels** at each line's end; legend moves top-left | +| Cursor | `cyan` | `ink` (white), since series-1 is sky blue | +| Sequential map | Sunset | Cividis-style blue → yellow | +| Diverging map | Cyan ↔ orange | Blue ↔ amber | +| Cell values | Text straight on the fill | Text on a solid `cell-plate`, 7:1+ on every stop | +| Text | AA | **AAA**: `ink-muted` and `ink-subtle` raised to 7:1+ on every ground | +| Success / danger | Green / red | Sky blue / vermillion-orange, off the red-green axis | +| Warning / info | Amber / cyan | Yellow / lavender (info never reads as success) | +| Bloom, plot floor | Follow intensity | Forced off | + +Okabe-Ito is distinguishable for all three common deficiency types; the dashes and direct labels cover the cases where two lines still converge, and full achromatopsia. + +## High contrast (`hc`) + +- True black grounds, white `ink`, `ink-muted` and `ink-subtle` all 7:1+ (most 12:1+). +- Surfaces separated by borders (`line` 6:1, `line-control` 12:1), not by shade. +- Focus ring 5px. +- Uses the color-blind safe data palette, brightened, with direct labels and value plates. +- All intensity effects off; the Ultra intensity slider is disabled with an explanation. + +## Checks + +The token build verifies every pair above in every theme. A token change that breaks a pair fails the build; fix the token, don't relax the check. diff --git a/design/design-system/3-data-visualization.md b/design/design-system/3-data-visualization.md new file mode 100644 index 00000000..fec2c7a8 --- /dev/null +++ b/design/design-system/3-data-visualization.md @@ -0,0 +1,40 @@ +# Data visualization + +Charts are the product. These rules keep every view — line plots, stacked plots, scatter density, histograms, delay and enrichment tables, the GPS track map — speaking one visual language. + +## Series colours + +- Assign `series-1`…`series-10` in the order channels are selected, and keep a channel's colour for the life of the session even if others are removed (no reshuffling). +- The same colour appears in three places only: the plot line, the legend swatch and the channel card strip + swatch. Channel names stay `ink`. +- Lines are `stroke-series` (1.5px); the hovered or soloed series goes to 2px and the others drop to 40% opacity. +- Night Drive order alternates warm and cool and light and dark so neighbours never blur: magenta, gold, sky, lime, orange, violet, mint, sand, coral, lilac. +- No series is ever `cyan`: cyan belongs to the cursor. +- Color-blind safe, High contrast and both export themes switch to the Okabe-Ito set; series 9-10 repeat 1-2 dashed, and lines get direct end labels. +- Ultra intensity never changes series colours; at Neon and above it adds `bloom-series` under each line. + +## Plot chrome + +- Canvas `plot-bg` (`bg-void`); grid `plot-grid`, every fifth line `plot-grid-major`; "Show grid" opacity scales the grid only. +- Ticks in `data-sm`, `plot-axis`. Time axis in seconds or `m:ss.mmm`; never mix in one view. +- The cursor is a full-height `plot-cursor` line at `stroke-cursor`. The playback time readout uses the same colour so the two read as one. +- Legend: floating `plot-legend-bg` box top-right, Cursor row first, then each channel with swatch, name and live value in mono. +- Stacked plots share one x-axis and cursor; each panel keeps its own series colours from the global assignment. + +## Colour maps + +**Sequential** (`seq-0`…`seq-6`) for magnitudes: hit counts, dead time, RPM on the track map, scatter density. Night Drive uses *Sunset* (indigo → violet → magenta → coral → orange → gold), which rises steadily in lightness, so it reads in greyscale too. Replace the current Turbo/jet rainbow everywhere: rainbows invent boundaries and fail colour-blind users. + +**Diverging** (`div-0`…`div-4`) for signed values around a target: AFR or λ versus target, suggested correction %, fuel trim. Rich / negative is cool (`div-0`), on-target is the neutral `div-2`, lean / positive is hot (`div-4`). Cool-vs-hot is blue-vs-orange in both themes. + +Interpolate between stops in OKLab. Value text inside cells uses `on-seq-N` / `on-div-N` on top of `cell-plate`. Color-blind safe, High contrast and Graphite show the plate so every value reaches 7:1; Paper runs the sequential map light → dark so sparse tables cost little ink. + +## Tables and heatmaps + +- Empty cells: `cell-empty` with a 45° hatch. +- Confidence: event-count badge fill style (filled high, outlined medium, dashed low), never hue alone. +- Selected cell: 2px `cyan` ring with a `bg-void` gap; stats for it appear under the table. +- Always show a ColorScale beside the table with the real min / max. + +## Track map + +GPS traces colour by the sequential map at 3px with a 1px `bg-void` casing so the line separates from a satellite background. Start/finish is a `ink` tick; the current position is a `plot-cursor` dot with a 2px `bg-void` ring. diff --git a/design/design-system/4-exports.md b/design/design-system/4-exports.md new file mode 100644 index 00000000..c0f9baf4 --- /dev/null +++ b/design/design-system/4-exports.md @@ -0,0 +1,20 @@ +# Exports + +PNG and PDF exports leave the app: they get printed, emailed to a customer, posted to a forum, pasted into a tune log. They follow their own rules. + +- **Paper is the default.** White page, ink type, `ultralog-lockup-paper`. Every token has a `paper` value. +- **Graphite is the dark option.** A mid-grey page (`#24222c` plot, `#2a2833` page) instead of black: still reads as "dark mode" on screen, uses far less toner or ink than a black page, and keeps a white margin in PDFs so a printer never floods the sheet edge-to-edge. +- **Always color-blind safe.** Both export themes use the Okabe-Ito series, cividis maps and the blue/orange diverging map whatever the user's in-app setting, with series 9-10 dashed. The reader's colour vision is unknown. +- **No intensity effects.** No glow, bloom or grid floor in exports; the sunset redline along the top edge is the only brand flourish. +- **Paper maps run light to dark.** On paper, low values are pale (`seq-0` cream) and high values dark (`seq-6` navy), so sparse tables cost almost no ink. + +## Layout (ExportSheet) + +1. Redline across the top edge. +2. Lockup, then the title in `headline` (default: date + ECU + file name; editable). +3. Time range and record/channel counts, right-aligned in mono. +4. The chart or table at full width; plot frame in `plot-grid-major`. +5. Legend with every series (swatch + name + unit), or the ColorScale for maps. +6. Footer: ultralog.co · version · export date, in `data-sm`. + +PNG exports are the same layout at 2× for crisp screenshots; "Copy for paste" copies a PNG of the chart only, without header and footer. diff --git a/design/design-system/5-website.md b/design/design-system/5-website.md new file mode 100644 index 00000000..8821cc50 --- /dev/null +++ b/design/design-system/5-website.md @@ -0,0 +1,26 @@ +# Website + +ultralog.co moves from a single GitHub Pages page to a Nuxt site that builds its release-driven content from the GitHub API. + +## Structure + +- **/** Hero (SiteHero) · three FeatureCards · screenshot band per mode (Log Viewer, Scatter, Histogram, Lambda Delay, Accel Enrichment) · supported ECUs grid · DownloadCards · latest ReleaseCard · footer. +- **/download** All builds for the latest release, checksums, older versions. +- **/releases** Every ReleaseCard, generated from GitHub release notes. +- **/docs** Wiki content rendered with Nuxt Content. +- **/ecus** One page per supported ECU/format: file types, channels mapped by field normalization, sample log. + +## Programmatic behaviour + +- At build (and on a release webhook), fetch `/repos/{owner}/ultralog/releases`; generate DownloadCards from each release's assets (OS from the file name, size from the asset), ReleaseCards from the body, and the version shown in the hero. +- Detect the visitor's OS and architecture to choose the primary download; the others stay secondary. +- Offer the same Color blind mode toggle as the app (sets `data-theme="cvd"`, remembered in localStorage); respect `prefers-contrast: more` with `hc` and `prefers-reduced-motion`. +- Optional: an intensity toggle in the footer for the full Ultra look. + +## Look + +- `bg-void` page, sections separated by `space-12` and `line-subtle` rules. +- One SiteHero per page, with the striped sun and the perspective floor. +- Feature and ECU cards use the chamfered hexagon `ul-hex` frame; buttons, downloads and releases use `radius-lg` cards. +- Screenshots of the app sit on `bg-panel` with a 1px `line` frame and the redline on top, never with fake window chrome beyond the app's own. +- The hero art (`hero-sunset-dash.png`) is for social cards; the live hero is built from tokens so it follows the theme. diff --git a/design/design-system/6-implementation.md b/design/design-system/6-implementation.md new file mode 100644 index 00000000..6ec6416e --- /dev/null +++ b/design/design-system/6-implementation.md @@ -0,0 +1,69 @@ +# Implementation + +`tokens.json` is the single source. Generate platform code from it rather than copying hex values by hand. + +## Desktop app (Rust + egui) + +Generate a `theme.rs` from `tokens.json` in a build script: one `const` per colour token per theme, plus spacing and radius. Then map tokens onto `egui::Visuals` (field names as of egui 0.31): + +```rust +pub fn visuals(t: &Tokens) -> egui::Visuals { + use egui::{CornerRadius, Stroke, Visuals}; + let mut v = Visuals::dark(); + v.panel_fill = t.bg_panel; // side panels, top bar + v.window_fill = t.bg_raised; // floating windows + v.extreme_bg_color = t.bg_field; // text edits, plot canvas uses t.plot_bg + v.faint_bg_color = t.bg_base; + v.code_bg_color = t.bg_field; + v.hyperlink_color = t.cyan; + v.selection.bg_fill = t.cyan_soft; + v.selection.stroke = Stroke::new(1.0, t.cyan); + v.window_stroke = Stroke::new(1.0, t.line); + v.window_corner_radius = CornerRadius::same(10); // radius-lg + v.menu_corner_radius = CornerRadius::same(6); // radius-md + v.warn_fg_color = t.warning; + v.error_fg_color = t.danger; + + let w = &mut v.widgets; + w.noninteractive.bg_stroke = Stroke::new(1.0, t.line); + w.noninteractive.fg_stroke = Stroke::new(1.0, t.ink_muted); + w.inactive.weak_bg_fill = t.bg_raised; w.inactive.bg_fill = t.bg_field; + w.inactive.bg_stroke = Stroke::new(1.0, t.line_control); + w.inactive.fg_stroke = Stroke::new(1.0, t.ink); + w.hovered.weak_bg_fill = t.bg_hover; w.hovered.bg_stroke = Stroke::new(1.0, t.line_control); + w.active.weak_bg_fill = t.bg_active; w.active.bg_stroke = Stroke::new(1.0, t.cyan); + for s in [&mut w.noninteractive, &mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] { + s.corner_radius = CornerRadius::same(6); + } + v +} +``` + +### How much custom painting + +Stay inside egui's normal theme system for every widget: buttons, inputs, checkboxes, sliders, tabs, panels and windows all come from `Visuals` plus a handful of `Button::fill` calls. That keeps upgrades painless. Custom painting is limited to four small, isolated helpers, each a few dozen lines with the egui `Painter`: + +1. **Redline**: a 2px `Mesh` with per-vertex colours along a window's top edge. +2. **Glow**: `egui::Shadow` (built in) with the token's blur and colour for the primary button hover and active tab; zero at Stealth. +3. **Series bloom**: draw each line twice in `egui_plot`, a wide translucent copy under the 1.5px line. Width and alpha come from intensity; off below Neon. +4. **Plot floor / drop-zone floor**: straight line segments from a vanishing point, painted before the plot data. + +Everything else (chamfered hexagons, the striped sun) is website-only. + +### Intensity at runtime + +Store the seven anchor tables as generated consts and build the live table whenever a setting changes: `tokens_at(settings)` picks High contrast outright, otherwise blends Stealth → Night Drive (0-35) or Night Drive → Ultra (35-100) in OKLab, then, with Color blind mode on, copies the data and status tokens from `cvd`, raises the text tokens to their AAA values and switches off bloom and the plot floor. A reference implementation with tests (`theme.rs`, written against egui 0.31) ships in the design handoff bundle. + +Exports call `Tokens::PAPER` or `Tokens::GRAPHITE` directly and ignore all three settings. + +### Widgets + +- **Primary buttons** are not a Visuals state: draw them with `egui::Button::new(..).fill(t.cyan)` and an `on_cyan` label. +- **Fonts:** egui loads TTF/OTF, not WOFF2. Embed Saira (600, 700 italic, 800 italic), Outfit (400/500/600) and JetBrains Mono (400/500) TTFs with `include_bytes!` and register `FontFamily::Name("display")`; make Outfit the proportional default and JetBrains Mono the monospace family. The WOFF2 files in `fonts/` are for the web. +- **Plots** (`egui_plot`): `Line::new(..).color(series[i]).width(1.5)`, `.style(LineStyle::dashed_loose())` for series 9-10 in cvd; cursor via `VLine` in `plot_cursor`; grid via `Plot::grid_spacing` and the `plot-grid` colours. +- **Sunset redline:** a `Mesh` with per-vertex colours along the window's top edge. +- **Color blind mode** swaps the whole `Tokens` struct (`Tokens::NIGHT` / `Tokens::CVD`) and calls `ctx.set_visuals`. + +## Website (Nuxt / Vue) + +Load the generated `tokens.css`; it declares every token as a CSS custom property on `:root`, with `[data-theme="cvd"]` overrides. Map Tailwind (or UnoCSS) theme keys to the variables (`colors: { cyan: 'var(--cyan)', ... }`) so utility classes and components share one source. Set `data-theme="cvd"` from a toggle stored in `localStorage` to mirror the app setting. Self-host the WOFF2 files from `fonts/` with `font-display: swap`. diff --git a/design/design-system/README.md b/design/design-system/README.md new file mode 100644 index 00000000..b700c8e5 --- /dev/null +++ b/design/design-system/README.md @@ -0,0 +1,90 @@ +UltraLog is a free, open-source ECU log viewer for tuners, from weekend Speeduino builds to professional motorsport. The brand is **Night Drive**: a dark cockpit at dusk where the data glows. The chrome stays quiet and nearly black so the channels, the cursor and the colour maps are the brightest things on screen; the sunset colours of the logo show up only where the brand speaks, and the user decides how loud that is with the **Ultra intensity** dial. + +## Principles + +1. **Data is the brightest thing on screen.** Chrome lives in the `bg-*` indigo-blacks and `ink-muted`; saturated colour is reserved for series, colour maps, the cursor and the one primary action. +2. **One accent does the work.** `cyan` means "interactive and current": primary button, active tab, focus ring, playhead. `magenta` is the brand voice and the mark of computed channels. Nothing else is interactive colour. +3. **Never colour alone.** Every status carries an icon and a word; confidence is a fill style; dashed lines and direct labels extend the palette. Color-blind safe and High contrast meet WCAG AAA. +4. **Numbers are mono.** Every value a tuner compares is set in `font-mono` with tabular figures. +5. **Neon is a dial, not a texture.** Ultra intensity scales glow, gradient and grid from Stealth to full synthwave. It never touches data colours or text contrast. +6. **What leaves the app is safe for anyone.** Exports default to light Paper and always use the color-blind safe palette. + +## Themes + +Seven token tables share every name. The app composes three independent settings, and the themes here are the anchor presets for each: + +| Setting | Values | Themes | +| --- | --- | --- | +| Ultra intensity (slider 0-100) | **Stealth 0** · Night Drive 35 · Neon 70 · Ultra 100 | `stealth` (app default), `night`, `ultra`; values in between interpolate in OKLab | +| Color blind mode (checkbox) | off · on | `cvd`: Okabe-Ito data palette, cividis maps, AAA text, direct labels | +| High contrast (checkbox) | off · on | `hc`: true black, white text, 3:1+ borders everywhere, AAA; locks intensity effects off | +| Export theme (per export) | **Paper** · Graphite | `paper`, `graphite`: export-only, always color-blind safe | + +Composition rule: chrome tokens (`bg-*`, `line-*`, `ink-*`, glows, `redline-*`, `grid-floor`) come from intensity; data tokens (`series-*`, `seq-*`, `div-*`, `on-*`, `cell-plate`, `plot-cursor`, status) come from Color blind mode; High contrast overrides both. Color blind mode also switches off the two effects that touch data (`bloom-series`, `plot-floor`) and raises `ink-muted`/`ink-subtle` to their AAA values. + +## Voice and copy + +UltraLog talks like a sharp tuner at the track: short, confident, specific. Lead with the benefit, back it with the number. + +- Website: "Unlock your performance data." "14 analyzers. 18 log formats. Free, forever." +- App: name the thing and the count. "66 accepted events from 1 log · 29/104 cells filled." Not "Analysis completed successfully!" +- Errors say what happened, then what to do: "Unknown channel "Boost Pres". Check the name or pick from Available Channels." +- **Casing:** Title Case for places: mode nav, panel and window titles ("Lambda Delay", "New Computed Channel"). Sentence case for things you do or read: buttons ("Select a file", "Re-run current file"), form labels, settings ("Color blind mode"), help text and notices. +- **Units:** SI-correct casing with a space: `6297 rpm`, `-36.5 kPa`, `92 °C`, `13.4 %`, `14.7 AFR`, `0.98 λ`, `123 ms`. Channel names may stay as the ECU names them (RPM, MAP, TPS). +- **Numbers:** thin-space thousands in counters ("223 559"), fixed decimals per channel, minus sign "−" in display copy. +- **You / we:** address the user as "you" sparingly; the app never says "we". No emoji, no exclamation marks. +- **Credit the ECU:** name ECUs and formats exactly as their makers do (Haltech, ECUMaster EMU Pro, Link, AiM, rusEFI, Speeduino, MegaSquirt, TunerStudio). + +## Colour + +Grounds step up in lightness as surfaces come forward: `bg-void` (plots, title bar) → `bg-base` (content) → `bg-panel` (sidebars, nav, playback) → `bg-raised` (cards, windows). Inputs sink into `bg-field`. Hover is `bg-hover`, a selected neutral row is `bg-active`. Stealth makes the grounds neutral grey; Ultra pushes them toward deep purple. + +- Text: `ink` for content, `ink-muted` for labels and units, `ink-subtle` for help text and axis ticks. All pass 4.5:1 on every ground in every theme, 7:1 in Color-blind safe and High contrast. `ink-disabled` is for disabled states only. +- Borders: `line` around panels and cards, `line-subtle` for internal dividers, `line-control` (3:1+) around anything you can click or type into. +- Accent: `cyan` with `cyan-soft` behind cyan text and `on-cyan` on cyan fills. +- Brand: `magenta`, `violet`, `orange`, `gold`, the stops of the sunset. In the app, `magenta` marks computed channels (ƒ) and `gold` marks favourites; `violet` and `orange` appear only as formula syntax colours. +- Status: `success`, `warning`, `danger`, `info`, each with a `*-soft` fill and always with an icon and a word. Wherever the data palette is color-blind safe, success is blue and danger is vermillion-orange. +- Data: `series-*`, `seq-*`, `div-*`, `on-seq-*`, `on-div-*`, `cell-plate`, `cell-empty` and the `plot-*` chrome. See *Data visualization*. + +**The sunset signature** is a 2px gradient through `redline-0`…`redline-4` (cyan → violet → magenta → orange → gold, stops at 0/30/55/80/100%). It appears on the top edge of floating windows, the welcome screen, export headers, the splash and the website. Stealth flattens it to a grey hairline. + +## Typography + +Three families, each with one job: + +- **Saira** (`font-display`): the motorsport voice. `display` (48/48, 800 italic) and `headline` (28/32, 700 italic) for brand moments only; `title` (18/24, 600) for panel and window titles; `overline` (11/14, 600, uppercase, 0.08em) for the mode nav, list group headers and table axis titles. +- **Outfit** (`font-ui`): the interface. `heading` 15/20 600, `body` 14/20, `label` 13/18 500, `caption` 12/16. +- **JetBrains Mono** (`font-mono`): numbers, units, formulas, file formats. `data-lg` 18/22 500, `data` 13/18, `data-sm` 11/14. Always `font-variant-numeric: tabular-nums`. + +The italic in Saira echoes the forward lean of the UltraLog wordmark; keep italics for brand moments so the interface stays calm. + +## Space, shape and elevation + +- 4px base: `space-1` 4, `space-2` 8, `space-3` 12, `space-4` 16, `space-6` 24, `space-8` 32, `space-12` 48. Panels pad `space-4`; cards pad `space-3`; forms gap `space-2`. +- Radii are tight and technical: `radius-xs` 2 (cells, badges), `radius-sm` 4 (inputs, tabs), `radius-md` 6 (buttons, cards), `radius-lg` 10 (windows, drop zone). `radius-pill` only for slider tracks and thumbs. +- Elevation is done with ground steps and borders. `shadow-window` only under floating windows and menus. +- **Focus:** every control shows `focus-ring` (a ground-coloured gap, then a solid `cyan` ring; 5px in High contrast) on keyboard focus. +- **Motion:** quick and mechanical. 120 ms ease-out for hover and toggles, 180 ms for windows opening, no bounce. The playhead moves at log rate, never eased. Reduced motion drops glow transitions; intensity changes apply instantly. + +## Logo + +The mark is the badge's hexagon shield with a single trace running through it: a log line that peaks. Its border runs the sunset (cyan → violet → magenta → red-orange) and the trace runs the hot end (lime → gold → orange → magenta), as on the original badge. + +- `ultralog-lockup.svg`: mark + wordmark, "Ultra" in `ink`, "Log" in the hot gradient. Default on dark grounds: website nav, splash, Graphite exports, About window. +- `ultralog-lockup-paper.svg`: the same on white, with "Ultra" in paper ink and "Log" in paper magenta. Paper exports and documents. +- `ultralog-lockup-mono-ink.svg`, `ultralog-mark-mono-ink.svg`, `ultralog-mark-mono-white.svg`: single-ink versions for one-colour print, engraving, stickers and favicons on busy grounds. +- `ultralog-mark.svg`: the mark alone; favicon (≥16px), social avatar, in-app About. +- `ultralog-app-icon.svg` / `-1024.png`: macOS/Windows/Linux app icon. The mark glows over a dusk tile with the magenta grid floor. +- `ultralog-badge.png`: the original neon badge, kept for merch and nostalgia; not the primary logo. + +Clear space around the mark equals the hexagon's side wall height. Never recolour the gradients, outline the wordmark, set "UltraLog" in another face, or separate "Ultra" and "Log" into two colours other than the ones given. The wordmark is outlined Saira ExtraBold Italic. + +## Imagery + +- The **hero art** (`hero-sunset-dash.png`) carries the synthwave world: sunset, neon dash, perspective grid. Use it for social cards and the release-notes header. +- Inside the app, the world is suggested, not painted: the perspective-grid floor (`grid-floor`) on the drop zone and the sunset redline on windows. Ultra adds the floor inside plots. +- The chamfered hexagon (`ul-hex`) frames website feature cards; app components stay rectangular. + +## Iconography + +Outline icons, 24px grid drawn at 16px (20px in the activity rail), 1.6px stroke, round caps and joins, `currentColor`. In the egui app use **Phosphor** (regular weight, via `egui-phosphor`), which matches this style; the previews here draw a small set of equivalents inline. Filled glyphs only for transport (play, stop) and the favourite star. Status shapes are fixed: check = success, triangle = warning, octagon = danger, circle-i = info. The italic Saira **ƒ** in `magenta` marks a computed channel. No emoji. diff --git a/design/design-system/components/ActivityRail/README.md b/design/design-system/components/ActivityRail/README.md new file mode 100644 index 00000000..0f3e485d --- /dev/null +++ b/design/design-system/components/ActivityRail/README.md @@ -0,0 +1,5 @@ +# ActivityRail + +The 48px vertical bar on the far left that switches the side panel: Files, Properties, Tools, then Settings pinned to the bottom. + +Icons are 20px outline at `ink-subtle`; hover `ink` on `bg-hover`; active `cyan` on `cyan-soft` with a 2px cyan indicator at the rail's left edge. Every button has a tooltip with the panel name. Keep it to four or five destinations. diff --git a/design/design-system/components/ActivityRail/preview.html b/design/design-system/components/ActivityRail/preview.html new file mode 100644 index 00000000..8719307a --- /dev/null +++ b/design/design-system/components/ActivityRail/preview.html @@ -0,0 +1,17 @@ + + + +Activity rail — UltraLog + +
+ +
Properties
Panel content sits beside the rail.
+
+ + diff --git a/design/design-system/components/Badge/README.md b/design/design-system/components/Badge/README.md new file mode 100644 index 00000000..e6c2ed97 --- /dev/null +++ b/design/design-system/components/Badge/README.md @@ -0,0 +1,5 @@ +# Badge + +Small labels: ECU source (mono, outlined), computed-channel ƒ, status (always icon + word), version, counts. + +Status badges use the `*-soft` fill with the status colour; the icon shape carries the meaning too (check = success, triangle = warning, octagon = danger, circle-i = info). Event-count badges (`ul-count`) encode confidence by fill style: filled high, outlined medium, dashed low. diff --git a/design/design-system/components/Badge/preview.html b/design/design-system/components/Badge/preview.html new file mode 100644 index 00000000..7fd9ec9f --- /dev/null +++ b/design/design-system/components/Badge/preview.html @@ -0,0 +1,17 @@ + + + +Badge — UltraLog + +
+ HALLNK + ƒComputed + Valid + Missing channel + Parse error + v2.16.1 + 114 ch + 8141 +
+ + diff --git a/design/design-system/components/Button/README.md b/design/design-system/components/Button/README.md new file mode 100644 index 00000000..ac24e4c9 --- /dev/null +++ b/design/design-system/components/Button/README.md @@ -0,0 +1,13 @@ +# Button + +Triggers an action: load a file, re-run an analysis, apply a template, export. + +- **primary** (`cyan` fill, `on-cyan` label): one per view, the action that moves the task forward: *Select a file*, *Save*, *Run & Chart*. Hover adds `glow-cyan`. +- **secondary** (default, `bg-raised` with a `line-control` border): everything else: *Re-run current file*, *Apply*, *Validate*, *PNG*, *PDF*. +- **ghost**: low-weight actions next to a primary (*Cancel*, *Copy for paste*, *+ New* in a list header). +- **danger** (outlined in `danger`): removes data the user loaded (*Remove log*, *Delete template*). Never filled. +- **sm** (24px): inside dense rows (library items, results toolbars). +- **Icon button** (28px, 20px `--sm`): close, more, jump-to-min/max. Always give it an `aria-label` / egui hover text. + +Consumer provides: label (Title Case, 1-3 words, verb first), optional leading 16px icon, `disabled` state. +Don't: colour export buttons by file type (the old blue PNG / red PDF), put two primaries side by side, or use `magenta` for buttons in the app. diff --git a/design/design-system/components/Button/preview.html b/design/design-system/components/Button/preview.html new file mode 100644 index 00000000..cd40d3b7 --- /dev/null +++ b/design/design-system/components/Button/preview.html @@ -0,0 +1,27 @@ + + + +Button — UltraLog + +
+
+ + + + + + +
+
+ + + + + + + + +
+
+ + diff --git a/design/design-system/components/ChannelCard/README.md b/design/design-system/components/ChannelCard/README.md new file mode 100644 index 00000000..cf6e0ed1 --- /dev/null +++ b/design/design-system/components/ChannelCard/README.md @@ -0,0 +1,5 @@ +# ChannelCard + +One card per selected channel in the Selected Channels strip: name, min and max with jump-to buttons, remove. + +The series colour appears twice: a 2px strip along the card's top edge and the line swatch beside the name, the same swatch the legend uses. The name itself stays `ink` (series colours are lines, not text). Values are `font-mono` 15px with the unit in `ink-muted`. In Color-blind safe, High contrast and exports, series 9 and 10 show a dashed swatch (`ul-swatch--cvd-dash`) to match their dashed lines. Consumer provides: channel name, series index (1-10), min, max, unit. diff --git a/design/design-system/components/ChannelCard/preview.html b/design/design-system/components/ChannelCard/preview.html new file mode 100644 index 00000000..12de14c6 --- /dev/null +++ b/design/design-system/components/ChannelCard/preview.html @@ -0,0 +1,24 @@ + + + +Channel card — UltraLog + +
+
RPM
+
Min0.0rpm
+
Max6081.0rpm
+
+
TPS
+
Min0.0%
+
Max88.2%
+
+
MAP
+
Min-101.3kPa
+
Max92.8kPa
+
+
Ignition Timing
+
Min-18.6°
+
Max38.9°
+
+ + diff --git a/design/design-system/components/ChannelList/README.md b/design/design-system/components/ChannelList/README.md new file mode 100644 index 00000000..08b8bd21 --- /dev/null +++ b/design/design-system/components/ChannelList/README.md @@ -0,0 +1,5 @@ +# ChannelList + +The searchable channel picker in the Properties panel: search, a "Selected 4 / 10" count, collapsible groups ("With data · 90"), and one row per channel with checkbox, name, assigned series swatch and unit. + +Checked rows get `bg-active` and show their series swatch, so the list, the cards and the plot legend all agree on colour. Units are mono `ink-subtle`. When ten channels are selected, further checkboxes disable with a caption explaining the limit. diff --git a/design/design-system/components/ChannelList/preview.html b/design/design-system/components/ChannelList/preview.html new file mode 100644 index 00000000..3230c420 --- /dev/null +++ b/design/design-system/components/ChannelList/preview.html @@ -0,0 +1,20 @@ + + + +Channel list — UltraLog + +
+
Search channels…
+
Selected 4 / 10114 channels
+
With data · 90
+
AFRAFR
+
Coolant Temp°C
+
Ignition Timing°
+
MAPkPa
+
Oil PressurekPa
+
RPMrpm
+
TPS%
+
Vehicle Speedkm/h
+
+ + diff --git a/design/design-system/components/Checkbox/README.md b/design/design-system/components/Checkbox/README.md new file mode 100644 index 00000000..120670af --- /dev/null +++ b/design/design-system/components/Checkbox/README.md @@ -0,0 +1,5 @@ +# Checkbox + +Toggles a setting on or off: Color blind mode, Cursor tracking, Scroll to zoom, Show grid, Field normalization, Blank low-confidence cells. + +16px box, `line-control` border on `bg-field`; checked fills `cyan` with an `on-cyan` check. The label is `ink` 13px; an optional caption sits under it, indented to the label, in `ink-subtle`. Disabled uses `ink-disabled` and says why in the caption when the reason isn't obvious. diff --git a/design/design-system/components/Checkbox/preview.html b/design/design-system/components/Checkbox/preview.html new file mode 100644 index 00000000..b96dd8d5 --- /dev/null +++ b/design/design-system/components/Checkbox/preview.html @@ -0,0 +1,13 @@ + + + +Checkbox — UltraLog + +
+
Swap to the Color-blind safe palette and colormaps
+ + + +
+ + diff --git a/design/design-system/components/ColorScale/README.md b/design/design-system/components/ColorScale/README.md new file mode 100644 index 00000000..f7dfa002 --- /dev/null +++ b/design/design-system/components/ColorScale/README.md @@ -0,0 +1,5 @@ +# ColorScale + +The legend bar for any colour-mapped view: heatmaps, scatter density, the GPS track map. + +Sequential bars show min and max values at the ends. Diverging bars label the ends and the centre in words (rich · target · lean) as well as numbers. Always shown beside the view it explains; never decorative. diff --git a/design/design-system/components/ColorScale/preview.html b/design/design-system/components/ColorScale/preview.html new file mode 100644 index 00000000..fbcd229a --- /dev/null +++ b/design/design-system/components/ColorScale/preview.html @@ -0,0 +1,13 @@ + + + +Color scale — UltraLog + +
+
Sequential · hits, RPM, dead time
+
968
6297
+
Diverging · AFR vs target, corrections
+
−50%
richtargetlean
+50%
+
+ + diff --git a/design/design-system/components/Cover/preview.html b/design/design-system/components/Cover/preview.html new file mode 100644 index 00000000..6c99387d --- /dev/null +++ b/design/design-system/components/Cover/preview.html @@ -0,0 +1,46 @@ + + + + + +UltraLog + + + +
+ +
+

UltraLog

+

Unlock your performance data.

+
+
+ + diff --git a/design/design-system/components/DownloadCard/README.md b/design/design-system/components/DownloadCard/README.md new file mode 100644 index 00000000..e9bb3c09 --- /dev/null +++ b/design/design-system/components/DownloadCard/README.md @@ -0,0 +1,3 @@ +# DownloadCard + +One card per platform build, fed by the GitHub releases API. The visitor's detected OS gets the `is-you` treatment (cyan border and top edge, "Your system" badge, primary button); the others use secondary buttons. Show architecture, file type and size in mono. diff --git a/design/design-system/components/DownloadCard/preview.html b/design/design-system/components/DownloadCard/preview.html new file mode 100644 index 00000000..9cee7abc --- /dev/null +++ b/design/design-system/components/DownloadCard/preview.html @@ -0,0 +1,12 @@ + + + +Download card — UltraLog + +
+
Your systemmacOSApple Silicon · .dmg · 18.4 MB
+
x86-64Windows10 / 11 · .msi · 16.9 MB
+
x86-64LinuxAppImage · 21.2 MB
+
+ + diff --git a/design/design-system/components/DropZone/README.md b/design/design-system/components/DropZone/README.md new file mode 100644 index 00000000..e342c5ef --- /dev/null +++ b/design/design-system/components/DropZone/README.md @@ -0,0 +1,5 @@ +# DropZone + +The welcome / empty state of the Files panel: drop target plus file picker. + +A dashed `line-control` border on `bg-panel` with a faint perspective grid rising from the bottom edge (the synthwave floor from the brand art, drawn in `line`). The title is `headline`-style Saira italic; the one primary button is "Select a file"; supported extensions are listed in mono. While a file is dragged over it, the border goes solid `cyan` on `cyan-soft`. diff --git a/design/design-system/components/DropZone/preview.html b/design/design-system/components/DropZone/preview.html new file mode 100644 index 00000000..104bf6b3 --- /dev/null +++ b/design/design-system/components/DropZone/preview.html @@ -0,0 +1,14 @@ + + + +Drop zone — UltraLog + +
+ + +
Drop a log to start
+ +
CSV · LOG · TXT · MSL · MLG · LLG · LLG5 · XRK · DRK · LG1 · LG2
+
+ + diff --git a/design/design-system/components/ExportDialog/README.md b/design/design-system/components/ExportDialog/README.md new file mode 100644 index 00000000..9629f88a --- /dev/null +++ b/design/design-system/components/ExportDialog/README.md @@ -0,0 +1,5 @@ +# ExportDialog + +Choose format (PDF / PNG) and export theme before saving a chart or table. + +Two visual radio cards with live thumbnails: **Paper** (default, selected) and **Graphite**. Thumbnails render with `data-theme="paper"` / `"graphite"` so they are honest previews. An info notice states that exports always use the color-blind safe palette. One primary action labelled with the format ("Export PDF"). Remember the last choice per format. diff --git a/design/design-system/components/ExportDialog/preview.html b/design/design-system/components/ExportDialog/preview.html new file mode 100644 index 00000000..af3b8e8f --- /dev/null +++ b/design/design-system/components/ExportDialog/preview.html @@ -0,0 +1,26 @@ + + + +Export dialog — UltraLog + +
+
+
+
Export Chart
+
+
Format
+
+
Exports always use the color-blind safe palette, so anyone you share them with can read them.
+
+
+
+ + diff --git a/design/design-system/components/ExportSheet/README.md b/design/design-system/components/ExportSheet/README.md new file mode 100644 index 00000000..447aacd4 --- /dev/null +++ b/design/design-system/components/ExportSheet/README.md @@ -0,0 +1,5 @@ +# ExportSheet + +The page layout of an exported PDF or PNG: lockup, title, time range and record stats, the chart, a legend with every series, and a footer with ultralog.co, version and export date. + +Rendered in the `paper` theme (white page, ink type, `ultralog-lockup-paper`) or `graphite` (grey page, light type, `ultralog-lockup`). The 2px sunset redline runs along the top edge. Both use the Okabe-Ito series, cividis maps and blue/orange diverging map, with series 9-10 dashed, because exports leave the app and the reader's colour vision is unknown. Graphite keeps a white paper margin in PDFs so a printed page never floods with ink. diff --git a/design/design-system/components/ExportSheet/preview.html b/design/design-system/components/ExportSheet/preview.html new file mode 100644 index 00000000..696ccacd --- /dev/null +++ b/design/design-system/components/ExportSheet/preview.html @@ -0,0 +1,33 @@ + + + +Export sheet — UltraLog + +
+
+
UltraLog
2025-03-06 Haltech pull
+
13:05.000 – 13:55.000
223 559 records · 114 ch
+ +
RPMTPSMAPIgn. Timing
+
ultralog.cov2.16.1 · exported 2026-10-23
+
+
+
UltraLog
2025-03-06 Haltech pull
+
13:05.000 – 13:55.000
223 559 records · 114 ch
+ +
RPMTPSMAPIgn. Timing
+
ultralog.cov2.16.1 · exported 2026-10-23
+
+ + + diff --git a/design/design-system/components/FeatureCard/README.md b/design/design-system/components/FeatureCard/README.md new file mode 100644 index 00000000..108bccfe --- /dev/null +++ b/design/design-system/components/FeatureCard/README.md @@ -0,0 +1,5 @@ +# FeatureCard + +Website feature tiles: icon in a `cyan-soft` square, a Saira title, two lines of benefit copy. + +Uses the chamfered-hexagon frame (`ul-hex`, 14px corner cuts) taken from the logo badge; this frame is website-only, never in the app. Three per row on desktop, one on mobile. Lead with the number when there is one ("14 analyzers"). diff --git a/design/design-system/components/FeatureCard/preview.html b/design/design-system/components/FeatureCard/preview.html new file mode 100644 index 00000000..e44289a3 --- /dev/null +++ b/design/design-system/components/FeatureCard/preview.html @@ -0,0 +1,8 @@ + + + +Feature card — UltraLog + +

14 analyzers

AFR drift, Butterworth filters, VE and correlation. Run one, chart it in a click.

Lambda delay tables

Mine your logs for injector-to-wideband delay, binned by RPM and load.

Heatmap histograms

Average any channel over RPM × MAP. Copy straight into your tune.

+ + diff --git a/design/design-system/components/FileTabs/README.md b/design/design-system/components/FileTabs/README.md new file mode 100644 index 00000000..a487d6c3 --- /dev/null +++ b/design/design-system/components/FileTabs/README.md @@ -0,0 +1,5 @@ +# FileTabs + +One tab per loaded log. Each shows the ECU source as a three-letter mono `ecu` badge (HAL Haltech, LNK Link, AIM AiM, RUS rusEFI/Speeduino, EMU ECUMaster, MS MegaSquirt…), the file name truncated in the middle so the extension stays visible, and a close button. + +Active tab: `bg-raised`, `ink`, `line-control` border, a 2px `cyan` inset on the top edge. Inactive: `bg-panel`, `ink-muted`. Full file name in the tooltip. Consumer provides: file name, ECU code, active state, close handler. diff --git a/design/design-system/components/FileTabs/preview.html b/design/design-system/components/FileTabs/preview.html new file mode 100644 index 00000000..f249335d --- /dev/null +++ b/design/design-system/components/FileTabs/preview.html @@ -0,0 +1,13 @@ + + + +File tabs — UltraLog + +
+
HAL2025-03-06_0937pm_Logs…874.csv
+
LNKECU Log 2024-03-14 2;04;31 pm.llg5
+
AIMBMW_THill 5mi_BY_Race_a_1448.xrk
+
RUSrusefilog.mlg
+
+ + diff --git a/design/design-system/components/FormulaEditor/README.md b/design/design-system/components/FormulaEditor/README.md new file mode 100644 index 00000000..123ee137 --- /dev/null +++ b/design/design-system/components/FormulaEditor/README.md @@ -0,0 +1,5 @@ +# FormulaEditor + +The formula field of a computed channel, with syntax colouring, insert chips, live validation and a value preview. + +Syntax roles: channel names `cyan`, numbers `gold`, functions (`abs`, `sqrt`) `violet`, time shifts (`@-0.1s`, `[-1]`) `orange`, operators `ink-muted`. Validation shows below the field as an inline status with icon and words ("Formula valid", "Unknown channel …"); the preview row prints min / avg / max in `font-mono`. Colour is never the only signal of a token's role, so the editor stays readable in Color-blind safe. diff --git a/design/design-system/components/FormulaEditor/preview.html b/design/design-system/components/FormulaEditor/preview.html new file mode 100644 index 00000000..39c13c55 --- /dev/null +++ b/design/design-system/components/FormulaEditor/preview.html @@ -0,0 +1,16 @@ + + + +Formula editor — UltraLog + +
+ Formula +
"Manifold Pressure" − "Manifold Pressure"@-0.1s * 10 / abs(RPM[-1])
+
Insert
+
+ Formula valid +
min -14.69avg -8.82max -8.69
+
+
+ + diff --git a/design/design-system/components/HeatmapTable/README.md b/design/design-system/components/HeatmapTable/README.md new file mode 100644 index 00000000..57089f0e --- /dev/null +++ b/design/design-system/components/HeatmapTable/README.md @@ -0,0 +1,10 @@ +# HeatmapTable + +Binned tables: Histogram (Average Z / Hit count), Lambda Delay, Accel Enrichment, and the scatter density plot share it. + +- Fill from the sequential map `seq-0`…`seq-6` for magnitudes (hits, dead time, RPM), or the diverging map `div-0`…`div-4` for signed values centred on a target (AFR vs target, suggested correction %). +- Value text uses the matching `on-seq-N` / `on-div-N` token, set on a `cell-plate`. The plate is invisible in Night Drive, Stealth, Ultra and Paper and becomes a solid dark plate in Color-blind safe, High contrast and Graphite, so every value reaches 7:1 whatever the cell colour (no colormap can give 7:1 on its mid stops by itself). +- Empty cells use `cell-empty` with a 45° hatch, so "no data" never looks like "low value". +- Confidence is shown by the event-count badge's fill, not its hue: **filled** = high, **outlined** = medium, **dashed** = low. "Blank low-confidence cells" renders low cells as empty. +- The selected cell gets a 2px `cyan` ring with a `bg-void` gap; its statistics appear below the table. +- Axis labels in `plot-axis`, axis titles in `overline`. diff --git a/design/design-system/components/HeatmapTable/preview.html b/design/design-system/components/HeatmapTable/preview.html new file mode 100644 index 00000000..052793f0 --- /dev/null +++ b/design/design-system/components/HeatmapTable/preview.html @@ -0,0 +1,41 @@ + + + +Heatmap table — UltraLog + +
+
Dead time (ms) · MAP kPa × RPM
+
+
+ 12high confidence + 3medium + 1low + no samples +
+ + + diff --git a/design/design-system/components/IntensityControl/README.md b/design/design-system/components/IntensityControl/README.md new file mode 100644 index 00000000..c8b675c4 --- /dev/null +++ b/design/design-system/components/IntensityControl/README.md @@ -0,0 +1,5 @@ +# IntensityControl + +The **Ultra intensity** setting: one slider, 0-100, that sets how much neon the app wears. Default 35 (Night Drive). + +Detents and labels at 0 Stealth, 35 Night Drive, 70 Neon, 100 Ultra; the current name and number sit top-right in Saira italic `cyan`. The track itself previews the dial: grey `line-control` into the sunset `redline-*` stops. Show the caption "Never changes your data colours or text contrast." Disabled (locked at the High contrast values) while High contrast is on, with that reason in the caption. See *Ultra intensity* for what each level changes. diff --git a/design/design-system/components/IntensityControl/preview.html b/design/design-system/components/IntensityControl/preview.html new file mode 100644 index 00000000..4f0536c5 --- /dev/null +++ b/design/design-system/components/IntensityControl/preview.html @@ -0,0 +1,43 @@ + + + +Ultra intensity — UltraLog + +
+
+
Ultra intensityNight Drive · 35
+
+
StealthNight DriveNeonUltra
+
How much neon the app wears. Never changes your data colours or text contrast.
+
+
+
+
+
Stealth · 0
+ + + + + +
+
+
+
Night Drive · 35
+ + + + + +
+
+
+
Ultra · 100
+ + + + + +
+
+ + diff --git a/design/design-system/components/LibraryItem/README.md b/design/design-system/components/LibraryItem/README.md new file mode 100644 index 00000000..7a7c7ee1 --- /dev/null +++ b/design/design-system/components/LibraryItem/README.md @@ -0,0 +1,5 @@ +# LibraryItem + +A saved computed-channel template in Your Library: a magenta italic ƒ, the name, its unit, a gold favourite star, the formula in mono, and Apply / more actions. + +The ƒ in `magenta` is the system-wide mark of a computed (virtual) channel; use it wherever a computed channel appears (channel list, legend, file stats). Consumer provides: name, unit, formula, favourite, apply handler. diff --git a/design/design-system/components/LibraryItem/preview.html b/design/design-system/components/LibraryItem/preview.html new file mode 100644 index 00000000..e6a696a9 --- /dev/null +++ b/design/design-system/components/LibraryItem/preview.html @@ -0,0 +1,14 @@ + + + +Library item — UltraLog + +
ƒTPS Rate%/s
+
(TPS − TPS@-0.1s) * 10
+
ƒAFR Deviation%
+
(AFR − 14.7) / 14.7 * 100
+
ƒRPM Z-Scoreσ
+
(RPM − _mean_RPM) / _stdev_RPM
+
+ + diff --git a/design/design-system/components/ModeNav/README.md b/design/design-system/components/ModeNav/README.md new file mode 100644 index 00000000..effc5136 --- /dev/null +++ b/design/design-system/components/ModeNav/README.md @@ -0,0 +1,5 @@ +# ModeNav + +The app's top-level workspace switch: Log Viewer, Scatter Plots, Histogram, Lambda Delay, Accel Enrichment. + +Set in the `overline` style (Saira 600, uppercase, 0.08em) with a 16px icon. The active item is `cyan` with a 2px cyan bar on the bottom edge carrying `glow-cyan`; the only glow in the chrome besides the primary button hover. Sits on `bg-panel` directly under the native menu bar. Consumer provides: items (icon + label), active id. New analysis tools that are a full workspace get a mode; anything that is a panel or dialog does not. diff --git a/design/design-system/components/ModeNav/preview.html b/design/design-system/components/ModeNav/preview.html new file mode 100644 index 00000000..d8bd1741 --- /dev/null +++ b/design/design-system/components/ModeNav/preview.html @@ -0,0 +1,14 @@ + + + +Mode nav — UltraLog + + + + diff --git a/design/design-system/components/Notice/README.md b/design/design-system/components/Notice/README.md new file mode 100644 index 00000000..fc57cfc0 --- /dev/null +++ b/design/design-system/components/Notice/README.md @@ -0,0 +1,5 @@ +# Notice + +An inline message inside a panel or window: analysis summaries, export results, missing-channel warnings, formula errors. + +Status fill `*-soft`, status-coloured icon and bolded lead sentence, the rest in `ink`. Lead with what happened, then what to do. Never stack more than two notices; summarise instead. diff --git a/design/design-system/components/Notice/preview.html b/design/design-system/components/Notice/preview.html new file mode 100644 index 00000000..efbf06fb --- /dev/null +++ b/design/design-system/components/Notice/preview.html @@ -0,0 +1,13 @@ + + + +Notice — UltraLog + +
+
66 accepted events from 1 log · 29/104 cells filled.
+
Export complete. Saved lambda-delay.csv.
+
Clutch state not logged. Gear-shift events can't be rejected; results may be noisier.
+
Unknown channel "Boost Pres". Check the name or pick from Available Channels.
+
+ + diff --git a/design/design-system/components/PlaybackBar/README.md b/design/design-system/components/PlaybackBar/README.md new file mode 100644 index 00000000..e2d60ced --- /dev/null +++ b/design/design-system/components/PlaybackBar/README.md @@ -0,0 +1,5 @@ +# PlaybackBar + +Transport controls along the bottom of every time-based view: play/pause, stop, speed, the current time, the record counter, and the scrubber. + +Play is the one cyan-filled transport button; the time readout is `data-lg` in `cyan`, matching the plot cursor so the two read as one thing. The record counter groups thousands with a thin space ("40 301 / 223 559"). The scrubber thumb is an `ink` bar 18px tall; start and end times sit at the track ends in `data-sm`. diff --git a/design/design-system/components/PlaybackBar/preview.html b/design/design-system/components/PlaybackBar/preview.html new file mode 100644 index 00000000..3eae6ea9 --- /dev/null +++ b/design/design-system/components/PlaybackBar/preview.html @@ -0,0 +1,19 @@ + + + +Playback bar — UltraLog + +
+
+ + + + Speed
1×
+ + 13:26.000 + Record 40 301 / 223 559 +
+
13:05.000
13:55.000
+
+ + diff --git a/design/design-system/components/Plot/README.md b/design/design-system/components/Plot/README.md new file mode 100644 index 00000000..5d2227ea --- /dev/null +++ b/design/design-system/components/Plot/README.md @@ -0,0 +1,13 @@ +# Plot + +The time-series chart at the heart of Log Viewer (and each panel of Stacked Plots). + +- Canvas `plot-bg`; minor grid `plot-grid`, major `plot-grid-major`; ticks `plot-axis` in `data-sm`. +- Series take `series-1`…`series-10` in selection order, 1.5px (2px on hover/solo). In Color-blind safe, series 9-10 are dashed 6/4. +- The playhead is a full-height `plot-cursor` line, 1.5px. No series ever uses the cursor colour. +- Drag-to-zoom and lap ranges fill with `plot-selection`. +- **Direct labels:** in Color-blind safe and High contrast each series is also labelled with its name at its right-hand end, and the legend moves to the top-left. Labels dodge each other vertically. +- **Intensity:** at Neon and above, series lines get `bloom-series` (a soft glow in the line's own colour) and Ultra draws the `plot-floor` perspective grid behind the data. Both are off in Color-blind safe, High contrast and exports. +- The floating legend (`plot-legend-bg`) lists Cursor first with its time, then each channel: swatch, name, current value right-aligned in mono. + +Consumer provides: series (name, unit, samples, index), cursor time, visible range. diff --git a/design/design-system/components/Plot/preview.html b/design/design-system/components/Plot/preview.html new file mode 100644 index 00000000..3c33e423 --- /dev/null +++ b/design/design-system/components/Plot/preview.html @@ -0,0 +1,41 @@ + + + +Plot — UltraLog + +
+ +
+ Cursor13:26.000 + RPM3488.0 + TPS18.10 + MAP-36.50 + Ign. Timing27.70 +
+ + + diff --git a/design/design-system/components/ReleaseCard/README.md b/design/design-system/components/ReleaseCard/README.md new file mode 100644 index 00000000..7a2e0383 --- /dev/null +++ b/design/design-system/components/ReleaseCard/README.md @@ -0,0 +1,3 @@ +# ReleaseCard + +A release entry generated from GitHub release notes: version in Saira italic, a "Latest" success badge on the newest, date and source in mono, then bullets with the feature name bolded. Used on the homepage (latest only) and a /releases page (all). diff --git a/design/design-system/components/ReleaseCard/preview.html b/design/design-system/components/ReleaseCard/preview.html new file mode 100644 index 00000000..a5bbcf9b --- /dev/null +++ b/design/design-system/components/ReleaseCard/preview.html @@ -0,0 +1,11 @@ + + + +Release card — UltraLog + +
+
v2.16.1Latest2026-10-14 · GitHub
+
  • Lambda delay tables mined from your logs, binned by RPM and load.
  • Accel enrichment tables with suggested corrections.
  • TunerStudio MSL, RaceChrono and Torque Pro imports.
  • Stacked plots, up to 10 panels.
+
+ + diff --git a/design/design-system/components/Segmented/README.md b/design/design-system/components/Segmented/README.md new file mode 100644 index 00000000..680f35b9 --- /dev/null +++ b/design/design-system/components/Segmented/README.md @@ -0,0 +1,5 @@ +# Segmented + +A mutually exclusive choice of 2-5 short options shown at once: Plot mode (Single / Stacked), Histogram mode (Hit count / Average Z), Font size (S M L XL), Load axis (MAP / TPS). + +The active segment is `cyan-soft` with `cyan` text and a 1px cyan inset; others are `ink-muted` on `bg-field`. Labels are sentence case or short abbreviations. More than five options, or options longer than two words: use a select. diff --git a/design/design-system/components/Segmented/preview.html b/design/design-system/components/Segmented/preview.html new file mode 100644 index 00000000..93b2ce35 --- /dev/null +++ b/design/design-system/components/Segmented/preview.html @@ -0,0 +1,15 @@ + + + +Segmented — UltraLog + +
+
Plot mode +
+
Font size +
+ Load axis +
+
+ + diff --git a/design/design-system/components/SiteHero/README.md b/design/design-system/components/SiteHero/README.md new file mode 100644 index 00000000..cec40f0f --- /dev/null +++ b/design/design-system/components/SiteHero/README.md @@ -0,0 +1,5 @@ +# SiteHero + +The ultralog.co hero: nav with the lockup, an eyebrow line, the `display` headline with one word in `magenta`, a one-sentence lede, the OS-detected primary download plus a secondary docs link, and the version in mono. + +Background `bg-void`, the striped sunset (from the cover geometry) top-right, and the perspective floor in `grid-floor` along the bottom edge. One hero per page. Follows the visitor's Ultra intensity if the site offers the toggle; defaults to Night Drive. diff --git a/design/design-system/components/SiteHero/preview.html b/design/design-system/components/SiteHero/preview.html new file mode 100644 index 00000000..e4196411 --- /dev/null +++ b/design/design-system/components/SiteHero/preview.html @@ -0,0 +1,17 @@ + + + +Site hero — UltraLog + + +
+ + +
Free · open source · 18 log formats
+

Unlock your performance data

+

The fast, beautiful ECU log viewer for tuners. Haltech to Speeduino, street to track.

+
v2.16.1 · Apple Silicon
+
+ + diff --git a/design/design-system/components/Slider/README.md b/design/design-system/components/Slider/README.md new file mode 100644 index 00000000..af996fab --- /dev/null +++ b/design/design-system/components/Slider/README.md @@ -0,0 +1,5 @@ +# Slider + +Continuous values with a sensible range: cursor tracking window, grid opacity, playback speed fine-tune. + +4px `line` track, `cyan` fill, a 14px `ink` thumb ringed in cyan. Always pair it with a mono number field showing the value and unit, so the value can be typed exactly. diff --git a/design/design-system/components/Slider/preview.html b/design/design-system/components/Slider/preview.html new file mode 100644 index 00000000..79a26976 --- /dev/null +++ b/design/design-system/components/Slider/preview.html @@ -0,0 +1,11 @@ + + + +Slider — UltraLog + +
+
Window
+
Grid opacity
+
+ + diff --git a/design/design-system/components/TextField/README.md b/design/design-system/components/TextField/README.md new file mode 100644 index 00000000..1b79e3aa --- /dev/null +++ b/design/design-system/components/TextField/README.md @@ -0,0 +1,11 @@ +# TextField + +Text input, number field and select (channel picker). Inputs sit in a `bg-field` well with a `line-control` border and `radius-sm`, 28px tall. + +- Focus: border turns `cyan` plus `focus-ring`. +- Required channel mappings mark the label with a magenta `*`. +- Number fields (bins, thresholds, sensitivity) use `font-mono`, tabular figures, right aligned, and show their unit inside the field ("30 s"). +- An unmapped optional channel shows the placeholder "Not logged — optional" in `ink-subtle`, plus a warning badge on the row if results depend on it. +- Error: `danger` border and an inline status below with the octagon icon. + +Forms lay out as a two-column grid: labels (`label` style, `ink-muted`) left, controls right. diff --git a/design/design-system/components/TextField/preview.html b/design/design-system/components/TextField/preview.html new file mode 100644 index 00000000..32e3dd6e --- /dev/null +++ b/design/design-system/components/TextField/preview.html @@ -0,0 +1,22 @@ + + + +Text field and select — UltraLog + +
+
+ +
RPM
+ +
Injector 1 On Time
+ +
Not logged — optional
+ + + + + +
Search channels…
+
+ + diff --git a/design/design-system/components/Window/README.md b/design/design-system/components/Window/README.md new file mode 100644 index 00000000..02d8f324 --- /dev/null +++ b/design/design-system/components/Window/README.md @@ -0,0 +1,5 @@ +# Window + +Floating tool windows and dialogs: Analysis Tools, Computed Channels, New Computed Channel, Field Normalization Editor. + +`bg-raised`, `line` border, `radius-lg`, `shadow-window`, and the 2px sunset redline along the top edge (the app's brand signature). Header: collapse chevron, title in Saira 600 15px, close. Footer actions right-aligned: ghost Cancel, secondary, then one primary. Body padding `space-4`. diff --git a/design/design-system/components/Window/preview.html b/design/design-system/components/Window/preview.html new file mode 100644 index 00000000..a3438625 --- /dev/null +++ b/design/design-system/components/Window/preview.html @@ -0,0 +1,17 @@ + + + +Window — UltraLog + +
+
+ + diff --git a/design/design-system/components/bundle.css b/design/design-system/components/bundle.css new file mode 100644 index 00000000..85769d75 --- /dev/null +++ b/design/design-system/components/bundle.css @@ -0,0 +1,281 @@ +/* UltraLog component styles. Every value comes from tokens.css; nothing raw except geometry. */ +body { margin: 0; background: var(--bg-base); color: var(--ink); font-family: var(--font-ui); font-size: 14px; line-height: 20px; -webkit-font-smoothing: antialiased; } +* { box-sizing: border-box; } +.ul-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } +.ul-stage { padding: var(--space-4); display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; } +.ul-stage--col { flex-direction: column; align-items: stretch; } +.ul-stage--panel { background: var(--bg-panel); } +.ul-caption { font-size: 12px; line-height: 16px; color: var(--ink-subtle); } +.ul-overline { font-family: var(--font-display); font-weight: 600; font-size: 11px; line-height: 14px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-muted); } +.ul-icon { width: 16px; height: 16px; flex: none; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } +.ul-icon--fill { fill: currentColor; stroke: none; } + +/* Signature redline: the sunset gradient, 2px, top edge of brand surfaces */ +.ul-redline { height: 2px; background: linear-gradient(90deg, var(--redline-0), var(--redline-1) 30%, var(--redline-2) 55%, var(--redline-3) 80%, var(--redline-4)); } + +/* Button */ +.ul-btn { font: 500 13px/18px var(--font-ui); height: 32px; padding: 0 14px; display: inline-flex; align-items: center; gap: var(--space-2); border-radius: var(--radius-md); border: 1px solid var(--line-control); background: var(--bg-raised); color: var(--ink); cursor: pointer; white-space: nowrap; } +.ul-btn:hover { background: var(--bg-hover); } +.ul-btn:active { background: var(--bg-active); } +.ul-btn:focus-visible, .ul-btn.is-focus { outline: none; box-shadow: var(--focus-ring); } +.ul-btn--primary { background: var(--cyan); border-color: var(--cyan); color: var(--on-cyan); font-weight: 600; } +.ul-btn--primary:hover { background: var(--cyan); box-shadow: var(--glow-cyan); } +.ul-btn--ghost { background: transparent; border-color: transparent; color: var(--ink-muted); } +.ul-btn--ghost:hover { color: var(--ink); background: var(--bg-hover); } +.ul-btn--danger { background: transparent; border-color: var(--danger); color: var(--danger); } +.ul-btn--danger:hover { background: var(--danger-soft); } +.ul-btn--sm { height: 24px; padding: 0 8px; font-size: 12px; gap: var(--space-1); } +.ul-btn--block { width: 100%; justify-content: center; } +.ul-btn[disabled] { background: var(--bg-panel); border-color: var(--line); color: var(--ink-disabled); cursor: not-allowed; box-shadow: none; } +.ul-iconbtn { width: 28px; height: 28px; padding: 0; display: inline-grid; place-items: center; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--ink-muted); cursor: pointer; } +.ul-iconbtn:hover { background: var(--bg-hover); color: var(--ink); } +.ul-iconbtn--sm { width: 20px; height: 20px; border-radius: var(--radius-sm); } +.ul-iconbtn--sm .ul-icon { width: 12px; height: 12px; } + +/* Segmented */ +.ul-seg { display: inline-flex; gap: 2px; padding: 2px; background: var(--bg-field); border: 1px solid var(--line-control); border-radius: var(--radius-md); } +.ul-seg__item { font: 500 12px/16px var(--font-ui); height: 26px; padding: 0 12px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--ink-muted); cursor: pointer; } +.ul-seg__item:hover { color: var(--ink); background: var(--bg-hover); } +.ul-seg__item.is-active { background: var(--cyan-soft); color: var(--cyan); box-shadow: inset 0 0 0 1px var(--cyan); } + +/* Mode nav */ +.ul-modenav { display: flex; gap: var(--space-1); padding: 0 var(--space-3); background: var(--bg-panel); border-bottom: 1px solid var(--line); } +.ul-modenav__item { position: relative; display: inline-flex; align-items: center; gap: var(--space-2); height: 44px; padding: 0 14px; font-family: var(--font-display); font-weight: 600; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-muted); background: none; border: 0; cursor: pointer; } +.ul-modenav__item:hover { color: var(--ink); } +.ul-modenav__item.is-active { color: var(--cyan); } +.ul-modenav__item.is-active::after { content: ""; position: absolute; left: 10px; right: 10px; bottom: -1px; height: 2px; background: var(--cyan); box-shadow: 0 0 10px var(--cyan); border-radius: 2px 2px 0 0; } + +/* File tabs */ +.ul-filetabs { display: flex; gap: var(--space-1); padding: var(--space-2) var(--space-3) 0; background: var(--bg-base); border-bottom: 1px solid var(--line); } +.ul-filetab { display: inline-flex; align-items: center; gap: var(--space-2); height: 32px; max-width: 280px; padding: 0 4px 0 10px; border: 1px solid var(--line); border-bottom: 0; border-radius: var(--radius-sm) var(--radius-sm) 0 0; background: var(--bg-panel); color: var(--ink-muted); font: 500 13px/18px var(--font-ui); cursor: pointer; margin-bottom: -1px; } +.ul-filetab:hover { color: var(--ink); } +.ul-filetab__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ul-filetab.is-active { background: var(--bg-raised); color: var(--ink); border-color: var(--line-control); box-shadow: inset 0 2px 0 var(--cyan); } + +/* Badge */ +.ul-badge { display: inline-flex; align-items: center; gap: var(--space-1); height: 20px; padding: 0 6px; border-radius: var(--radius-xs); font: 500 11px/14px var(--font-ui); background: var(--bg-active); color: var(--ink-muted); white-space: nowrap; } +.ul-badge .ul-icon { width: 12px; height: 12px; } +.ul-badge--ecu { font-family: var(--font-mono); letter-spacing: .04em; background: transparent; border: 1px solid var(--line-control); color: var(--ink-muted); height: 18px; padding: 0 4px; } +.ul-badge--success { background: var(--success-soft); color: var(--success); } +.ul-badge--warning { background: var(--warning-soft); color: var(--warning); } +.ul-badge--danger { background: var(--danger-soft); color: var(--danger); } +.ul-badge--info { background: var(--cyan-soft); color: var(--info); } +.ul-badge--computed { background: var(--magenta-soft); color: var(--magenta); } +.ul-count { display: inline-grid; place-items: center; min-width: 16px; height: 14px; padding: 0 3px; border-radius: var(--radius-xs); font: 500 10px/12px var(--font-mono); } +.ul-count--high { background: var(--ink); color: var(--bg-field); } +.ul-count--med { border: 1px solid var(--ink); color: var(--ink); background: var(--bg-field); } +.ul-count--low { border: 1px dashed var(--ink-muted); color: var(--ink-muted); background: var(--bg-field); } + +/* Notice */ +.ul-notice { display: flex; gap: var(--space-2); align-items: flex-start; padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); font-size: 13px; line-height: 18px; color: var(--ink); background: var(--bg-raised); border: 1px solid var(--line); } +.ul-notice b { font-weight: 600; } +.ul-notice .ul-icon { margin-top: 1px; } +.ul-notice--info { background: var(--cyan-soft); border-color: transparent; } .ul-notice--info .ul-icon { color: var(--info); } +.ul-notice--success { background: var(--success-soft); border-color: transparent; } .ul-notice--success .ul-icon, .ul-notice--success b { color: var(--success); } +.ul-notice--warning { background: var(--warning-soft); border-color: transparent; } .ul-notice--warning .ul-icon, .ul-notice--warning b { color: var(--warning); } +.ul-notice--danger { background: var(--danger-soft); border-color: transparent; } .ul-notice--danger .ul-icon, .ul-notice--danger b { color: var(--danger); } +.ul-inline-status { display: inline-flex; gap: var(--space-1); align-items: center; font-size: 12px; font-weight: 500; } +.ul-inline-status--success { color: var(--success); } .ul-inline-status--warning { color: var(--warning); } .ul-inline-status--danger { color: var(--danger); } + +/* Fields */ +.ul-form { display: grid; grid-template-columns: 140px 1fr; gap: var(--space-2) var(--space-3); align-items: center; } +.ul-label { font: 500 13px/18px var(--font-ui); color: var(--ink-muted); } +.ul-label .req { color: var(--magenta); margin-left: 2px; } +.ul-input { height: 28px; width: 100%; padding: 0 var(--space-2); background: var(--bg-field); border: 1px solid var(--line-control); border-radius: var(--radius-sm); color: var(--ink); font: 400 13px/18px var(--font-ui); display: flex; align-items: center; gap: var(--space-2); } +.ul-input::placeholder, .ul-input .ph { color: var(--ink-subtle); } +.ul-input:focus, .ul-input.is-focus { outline: none; border-color: var(--cyan); box-shadow: var(--focus-ring); } +.ul-input.is-error { border-color: var(--danger); } +.ul-input--num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; width: 88px; } +.ul-select { justify-content: space-between; cursor: pointer; } +.ul-select .ul-icon { color: var(--ink-muted); width: 14px; height: 14px; } + +/* Checkbox */ +.ul-check { display: inline-flex; align-items: center; gap: var(--space-2); font-size: 13px; color: var(--ink); cursor: pointer; } +.ul-check__box { width: 16px; height: 16px; flex: none; border: 1px solid var(--line-control); border-radius: var(--radius-sm); background: var(--bg-field); display: grid; place-items: center; color: var(--on-cyan); } +.ul-check__box .ul-icon { width: 12px; height: 12px; stroke-width: 2.4; opacity: 0; } +.ul-check.is-on .ul-check__box { background: var(--cyan); border-color: var(--cyan); } +.ul-check.is-on .ul-check__box .ul-icon { opacity: 1; } +.ul-check.is-disabled { color: var(--ink-disabled); } +.ul-check.is-disabled .ul-check__box { border-color: var(--line); } + +/* Slider */ +.ul-slider { display: flex; align-items: center; gap: var(--space-3); } +.ul-slider__track { position: relative; height: 4px; flex: 1; min-width: 140px; background: var(--line); border-radius: var(--radius-pill); } +.ul-slider__fill { position: absolute; inset: 0 auto 0 0; background: var(--cyan); border-radius: var(--radius-pill); } +.ul-slider__thumb { position: absolute; top: 50%; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: var(--radius-pill); background: var(--ink); box-shadow: 0 0 0 2px var(--cyan); } + +/* Activity rail */ +.ul-rail { width: 48px; padding: var(--space-2) 0; display: flex; flex-direction: column; align-items: center; gap: var(--space-1); background: var(--bg-panel); border-right: 1px solid var(--line); } +.ul-rail__btn { position: relative; width: 36px; height: 36px; display: grid; place-items: center; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--ink-subtle); cursor: pointer; } +.ul-rail__btn .ul-icon { width: 20px; height: 20px; } +.ul-rail__btn:hover { color: var(--ink); background: var(--bg-hover); } +.ul-rail__btn.is-active { color: var(--cyan); background: var(--cyan-soft); } +.ul-rail__btn.is-active::before { content: ""; position: absolute; left: -6px; top: 8px; bottom: 8px; width: 2px; border-radius: 2px; background: var(--cyan); } +.ul-rail__spacer { flex: 1; } + +/* Series swatch */ +.ul-swatch { width: 14px; height: 3px; border-radius: 2px; background: var(--c); flex: none; } +[data-theme="cvd"] .ul-swatch--cvd-dash, [data-theme="hc"] .ul-swatch--cvd-dash, [data-theme="paper"] .ul-swatch--cvd-dash, [data-theme="graphite"] .ul-swatch--cvd-dash, .ul-swatch--dash { background: repeating-linear-gradient(90deg, var(--c) 0 5px, transparent 5px 8px); } + +/* Channel card */ +.ul-chcard { width: 196px; padding: 10px 12px 12px; background: var(--bg-raised); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: inset 0 2px 0 var(--c); } +.ul-chcard__head { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2); } +.ul-chcard__name { flex: 1; font: 600 13px/18px var(--font-ui); color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ul-chcard__row { display: grid; grid-template-columns: 30px 1fr 20px; align-items: baseline; gap: var(--space-1); } +.ul-chcard__k { font-size: 11px; line-height: 14px; color: var(--ink-subtle); } +.ul-chcard__v { font: 500 15px/22px var(--font-mono); font-variant-numeric: tabular-nums; color: var(--ink); } +.ul-chcard__u { font: 400 11px/14px var(--font-mono); color: var(--ink-muted); margin-left: 2px; } + +/* Channel list */ +.ul-chlist { width: 280px; background: var(--bg-panel); padding: var(--space-3); display: flex; flex-direction: column; gap: var(--space-2); } +.ul-chlist__meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--ink-subtle); } +.ul-chlist__meta b { color: var(--ink); font-family: var(--font-mono); font-weight: 500; } +.ul-chlist__group { display: flex; align-items: center; gap: var(--space-1); padding: var(--space-2) 0 var(--space-1); } +.ul-chrow { display: flex; align-items: center; gap: var(--space-2); height: 30px; padding: 0 var(--space-2); border-radius: var(--radius-md); cursor: pointer; font-size: 13px; } +.ul-chrow:hover { background: var(--bg-hover); } +.ul-chrow.is-on { background: var(--bg-active); } +.ul-chrow__name { flex: 1; } +.ul-chrow__unit { font: 400 11px/14px var(--font-mono); color: var(--ink-subtle); } + +/* Library item */ +.ul-libitem { display: grid; grid-template-columns: 1fr auto; gap: 2px var(--space-3); align-items: center; padding: var(--space-2) var(--space-3); background: var(--bg-raised); border: 1px solid var(--line); border-radius: var(--radius-md); } +.ul-libitem__title { display: flex; align-items: center; gap: var(--space-2); font: 600 13px/18px var(--font-ui); } +.ul-libitem__fx { font: italic 700 14px/1 var(--font-display); color: var(--magenta); } +.ul-libitem__unit { font: 400 11px/14px var(--font-mono); color: var(--ink-subtle); } +.ul-libitem__star { color: var(--gold); } +.ul-libitem__formula { grid-column: 1; font: 400 12px/16px var(--font-mono); color: var(--ink-muted); } +.ul-libitem__actions { grid-column: 2; grid-row: 1 / span 2; display: flex; gap: var(--space-1); align-items: center; } + +/* Formula editor */ +.ul-formula { padding: var(--space-3); background: var(--bg-field); border: 1px solid var(--line-control); border-radius: var(--radius-sm); font: 400 14px/20px var(--font-mono); color: var(--ink); min-height: 64px; } +.ul-formula.is-focus { border-color: var(--cyan); box-shadow: var(--focus-ring); } +.ul-tok-ch { color: var(--cyan); } .ul-tok-num { color: var(--gold); } .ul-tok-op { color: var(--ink-muted); } .ul-tok-fn { color: var(--violet); } .ul-tok-shift { color: var(--orange); } +.ul-chips { display: flex; flex-wrap: wrap; gap: var(--space-1); align-items: center; } +.ul-chip { height: 22px; padding: 0 7px; border: 1px solid var(--line-control); border-radius: var(--radius-sm); background: var(--bg-raised); color: var(--ink-muted); font: 400 12px/16px var(--font-mono); cursor: pointer; } +.ul-chip:hover { color: var(--ink); background: var(--bg-hover); } +.ul-stats { display: flex; gap: var(--space-4); font: 400 12px/16px var(--font-mono); color: var(--ink-subtle); } +.ul-stats b { color: var(--ink); font-weight: 500; } + +/* Window */ +.ul-window { width: 460px; background: var(--bg-raised); border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-window); overflow: hidden; } +.ul-window__head { display: flex; align-items: center; gap: var(--space-2); height: 40px; padding: 0 var(--space-2) 0 var(--space-3); border-bottom: 1px solid var(--line-subtle); } +.ul-window__title { flex: 1; font: 600 15px/20px var(--font-display); color: var(--ink); } +.ul-window__body { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); } +.ul-window__foot { display: flex; justify-content: flex-end; gap: var(--space-2); padding: var(--space-3) var(--space-4); border-top: 1px solid var(--line-subtle); } + +/* Plot */ +.ul-plot { position: relative; background: var(--plot-bg); border: 1px solid var(--line); border-radius: var(--radius-sm); } +.ul-plot svg { display: block; width: 100%; } +.ul-plot .grid { stroke: var(--plot-grid); stroke-width: 1; } +.ul-plot .grid-major { stroke: var(--plot-grid-major); stroke-width: 1; } +.ul-plot .tick { fill: var(--plot-axis); font: 400 11px var(--font-mono); } +.ul-plot .series { fill: none; stroke-width: 1.5; stroke-linejoin: round; filter: drop-shadow(var(--bloom-series)); } +.ul-plot .series.is-dashed { stroke-dasharray: 6 4; } +.ul-plot .floor { stroke: var(--plot-floor); stroke-width: 1; } +.ul-plot .endlabel { display: none; font: 500 11px var(--font-ui); } +[data-theme="cvd"] .ul-plot .endlabel, [data-theme="hc"] .ul-plot .endlabel { display: inline; } +[data-theme="cvd"] .ul-legend, [data-theme="hc"] .ul-legend { left: 10px; right: auto; } +.ul-plot .cursor { stroke: var(--plot-cursor); stroke-width: 1.5; } +.ul-plot .sel { fill: var(--plot-selection); } +.ul-legend { position: absolute; top: 10px; right: 10px; padding: 6px 10px; background: var(--plot-legend-bg); border: 1px solid var(--line); border-radius: var(--radius-sm); display: grid; grid-template-columns: auto auto auto; gap: 2px 8px; align-items: center; font: 400 12px/16px var(--font-mono); font-variant-numeric: tabular-nums; } +.ul-legend .n { color: var(--ink-muted); font-family: var(--font-ui); } +.ul-legend .v { color: var(--ink); text-align: right; } +.ul-legend .cur { width: 8px; height: 8px; border-radius: 50%; background: var(--plot-cursor); justify-self: center; } + +/* Heatmap */ +.ul-heat { display: grid; gap: 2px; font: 500 12px/1 var(--font-mono); font-variant-numeric: tabular-nums; } +.ul-heat__cell { position: relative; height: 34px; display: grid; place-items: center; border-radius: var(--radius-xs); } +.ul-heat__cell .ul-count { position: absolute; top: 2px; right: 2px; } +.ul-heat__cell.is-empty { background: repeating-linear-gradient(135deg, var(--cell-empty) 0 5px, var(--bg-base) 5px 7px); } +.ul-heat__cell.is-low { box-shadow: inset 0 0 0 1px var(--bg-void); } +.ul-heat__cell.is-sel { box-shadow: 0 0 0 2px var(--bg-void), 0 0 0 4px var(--cyan); z-index: 1; } +.ul-heat__axis { display: grid; place-items: center; color: var(--plot-axis); font: 400 11px/1 var(--font-mono); } +.ul-heat__cell .v { padding: 1px 4px; border-radius: var(--radius-xs); background: var(--cell-plate); } +.s0{background:var(--seq-0);color:var(--on-seq-0)}.s1{background:var(--seq-1);color:var(--on-seq-1)}.s2{background:var(--seq-2);color:var(--on-seq-2)}.s3{background:var(--seq-3);color:var(--on-seq-3)}.s4{background:var(--seq-4);color:var(--on-seq-4)}.s5{background:var(--seq-5);color:var(--on-seq-5)}.s6{background:var(--seq-6);color:var(--on-seq-6)} +.d0{background:var(--div-0);color:var(--on-div-0)}.d1{background:var(--div-1);color:var(--on-div-1)}.d2{background:var(--div-2);color:var(--on-div-2)}.d3{background:var(--div-3);color:var(--on-div-3)}.d4{background:var(--div-4);color:var(--on-div-4)} + +/* Color scale */ +.ul-scale { display: flex; align-items: center; gap: var(--space-2); font: 400 12px/16px var(--font-mono); color: var(--ink-muted); } +.ul-scale__bar { height: 10px; width: 220px; border-radius: var(--radius-xs); } +.ul-scale__bar--seq { background: linear-gradient(90deg, var(--seq-0), var(--seq-1), var(--seq-2), var(--seq-3), var(--seq-4), var(--seq-5), var(--seq-6)); } +.ul-scale__bar--div { background: linear-gradient(90deg, var(--div-0), var(--div-1), var(--div-2), var(--div-3), var(--div-4)); } +.ul-scale__ticks { display: flex; justify-content: space-between; width: 220px; font: 400 11px/14px var(--font-mono); color: var(--ink-subtle); margin-top: 3px; } + +/* Playback */ +.ul-playback { background: var(--bg-panel); border-top: 1px solid var(--line); padding: var(--space-2) var(--space-4) var(--space-3); } +.ul-playback__row { display: flex; align-items: center; gap: var(--space-3); height: 36px; } +.ul-playback__sep { width: 1px; height: 20px; background: var(--line); } +.ul-playback__time { font: 500 15px/20px var(--font-mono); color: var(--cyan); font-variant-numeric: tabular-nums; } +.ul-playback__rec { font: 400 12px/16px var(--font-mono); color: var(--ink-muted); } +.ul-playback__rec b { color: var(--ink); font-weight: 500; } +.ul-transport { width: 32px; height: 32px; display: grid; place-items: center; border-radius: var(--radius-md); border: 1px solid var(--line-control); background: var(--bg-raised); color: var(--ink); cursor: pointer; } +.ul-transport--play { background: var(--cyan); border-color: var(--cyan); color: var(--on-cyan); } +.ul-scrub { display: grid; grid-template-columns: auto 1fr auto; gap: var(--space-3); align-items: center; font: 400 11px/14px var(--font-mono); color: var(--ink-subtle); } +.ul-scrub__track { position: relative; height: 6px; border-radius: var(--radius-pill); background: var(--bg-field); box-shadow: inset 0 0 0 1px var(--line); } +.ul-scrub__fill { position: absolute; inset: 0 auto 0 0; border-radius: var(--radius-pill); background: var(--cyan); } +.ul-scrub__thumb { position: absolute; top: 50%; width: 4px; height: 18px; margin: -9px 0 0 -2px; border-radius: 2px; background: var(--ink); box-shadow: 0 0 0 2px var(--bg-panel); } + +/* Drop zone */ +.ul-drop { position: relative; overflow: hidden; display: flex; flex-direction: column; align-items: center; gap: var(--space-3); padding: var(--space-8) var(--space-6) var(--space-6); border: 1px dashed var(--line-control); border-radius: var(--radius-lg); background: var(--bg-panel); text-align: center; } +.ul-drop.is-over { border: 1px solid var(--cyan); background: var(--cyan-soft); } +.ul-drop__grid { position: absolute; left: 0; right: 0; bottom: 0; width: 100%; height: 55%; pointer-events: none; } +.ul-drop__grid line { stroke: var(--grid-floor); stroke-width: 1; } +.ul-drop__ring { position: relative; width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; color: var(--cyan); box-shadow: inset 0 0 0 1.5px var(--cyan); } +.ul-drop__ring .ul-icon { width: 20px; height: 20px; } +.ul-drop > *:not(.ul-drop__grid) { position: relative; } +.ul-drop__title { font: italic 700 20px/24px var(--font-display); color: var(--ink); } +.ul-drop__formats { font: 400 11px/16px var(--font-mono); color: var(--ink-subtle); letter-spacing: .02em; max-width: 300px; } + +/* Ultra intensity control */ +.ul-intensity { width: 420px; display: flex; flex-direction: column; gap: var(--space-2); } +.ul-intensity__head { display: flex; justify-content: space-between; align-items: baseline; } +.ul-intensity__name { font: italic 700 15px/20px var(--font-display); color: var(--cyan); } +.ul-intensity__track { position: relative; height: 6px; border-radius: var(--radius-pill); background: linear-gradient(90deg, var(--line-control), var(--redline-0) 45%, var(--redline-2) 75%, var(--redline-4)); } +.ul-intensity__thumb { position: absolute; top: 50%; width: 16px; height: 16px; margin: -8px 0 0 -8px; border-radius: var(--radius-pill); background: var(--ink); box-shadow: 0 0 0 2px var(--bg-panel), var(--glow-cyan); } +.ul-intensity__stops { position: relative; height: 16px; font: 400 11px/14px var(--font-mono); color: var(--ink-subtle); } +.ul-intensity__stops span { position: absolute; transform: translateX(-50%); white-space: nowrap; } +.ul-intensity__stops span:first-child { transform: none; } .ul-intensity__stops span:last-child { transform: translateX(-100%); } +.ul-intensity__stops span.is-on { color: var(--ink); } + +/* Export dialog + sheet */ +.ul-radio { display: flex; gap: var(--space-2); } +.ul-radio__opt { flex: 1; display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-2); border: 1px solid var(--line-control); border-radius: var(--radius-md); background: var(--bg-field); cursor: pointer; font-size: 13px; } +.ul-radio__opt.is-on { border-color: var(--cyan); box-shadow: inset 0 0 0 1px var(--cyan); } +.ul-radio__opt small { color: var(--ink-subtle); font-size: 12px; } +.ul-thumb { height: 64px; border-radius: var(--radius-sm); border: 1px solid var(--line); position: relative; overflow: hidden; } +.ul-sheet { width: 560px; padding: 28px 32px; background: var(--bg-base); color: var(--ink); border: 1px solid var(--line); } +.ul-sheet__head { display: flex; justify-content: space-between; align-items: flex-end; padding-bottom: var(--space-3); } +.ul-sheet__title { font: italic 700 22px/26px var(--font-display); } +.ul-sheet__meta { font: 400 11px/16px var(--font-mono); color: var(--ink-subtle); text-align: right; } +.ul-sheet__foot { display: flex; justify-content: space-between; margin-top: var(--space-3); font: 400 10px/14px var(--font-mono); color: var(--ink-subtle); } + +/* Website */ +.ul-site { background: var(--bg-void); color: var(--ink); } +.ul-hero { position: relative; overflow: hidden; padding: 64px 48px 120px; } +.ul-hero__floor { position: absolute; left: 0; right: 0; bottom: 0; width: 100%; height: 120px; } +.ul-hero__floor line { stroke: var(--grid-floor); stroke-width: 1; } +.ul-hero__sun { position: absolute; right: 64px; top: 40px; } +.ul-hero__eyebrow { font: 600 12px/16px var(--font-display); letter-spacing: .12em; text-transform: uppercase; color: var(--cyan); } +.ul-hero__title { position: relative; margin: 12px 0 12px; max-width: 520px; font: italic 800 56px/54px var(--font-display); letter-spacing: -.01em; } +.ul-hero__title em { font-style: inherit; color: var(--magenta); } +.ul-hero__lede { position: relative; max-width: 460px; color: var(--ink-muted); font-size: 16px; line-height: 24px; margin: 0 0 24px; } +.ul-hero__cta { position: relative; display: flex; gap: var(--space-3); align-items: center; } +.ul-btn--lg { height: 44px; padding: 0 20px; font-size: 15px; } +.ul-hex { clip-path: polygon(14px 0, calc(100% - 14px) 0, 100% 14px, 100% calc(100% - 14px), calc(100% - 14px) 100%, 14px 100%, 0 calc(100% - 14px), 0 14px); } +.ul-feature { position: relative; padding: var(--space-6); background: var(--line); } +.ul-feature::before { content: ""; position: absolute; inset: 1px; background: var(--bg-raised); clip-path: inherit; } +.ul-feature > * { position: relative; } +.ul-feature__icon { width: 40px; height: 40px; display: grid; place-items: center; color: var(--cyan); background: var(--cyan-soft); border-radius: var(--radius-md); margin-bottom: var(--space-3); } +.ul-feature__icon .ul-icon { width: 22px; height: 22px; } +.ul-feature h3 { margin: 0 0 6px; font: 600 18px/24px var(--font-display); } +.ul-feature p { margin: 0; color: var(--ink-muted); font-size: 14px; line-height: 21px; } +.ul-dl { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-3); } +.ul-dl__card { padding: var(--space-4); background: var(--bg-raised); border: 1px solid var(--line); border-radius: var(--radius-lg); display: flex; flex-direction: column; gap: var(--space-2); } +.ul-dl__card.is-you { border-color: var(--cyan); box-shadow: inset 0 2px 0 var(--cyan); } +.ul-dl__os { font: 600 16px/20px var(--font-display); } +.ul-dl__meta { font: 400 12px/16px var(--font-mono); color: var(--ink-subtle); } +.ul-release { padding: var(--space-4) var(--space-6); background: var(--bg-raised); border: 1px solid var(--line); border-radius: var(--radius-lg); } +.ul-release__head { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-3); } +.ul-release__ver { font: italic 700 22px/26px var(--font-display); } +.ul-release ul { margin: 0; padding-left: 18px; color: var(--ink-muted); font-size: 14px; line-height: 22px; } +.ul-release ul b { color: var(--ink); font-weight: 600; } diff --git a/design/fonts/ttf/jetbrains-mono-latin-400-normal.ttf b/design/fonts/ttf/jetbrains-mono-latin-400-normal.ttf new file mode 100644 index 00000000..c339215a Binary files /dev/null and b/design/fonts/ttf/jetbrains-mono-latin-400-normal.ttf differ diff --git a/design/fonts/ttf/jetbrains-mono-latin-500-normal.ttf b/design/fonts/ttf/jetbrains-mono-latin-500-normal.ttf new file mode 100644 index 00000000..c149f4d5 Binary files /dev/null and b/design/fonts/ttf/jetbrains-mono-latin-500-normal.ttf differ diff --git a/design/fonts/ttf/outfit-latin-400-normal.ttf b/design/fonts/ttf/outfit-latin-400-normal.ttf new file mode 100644 index 00000000..c76807b8 Binary files /dev/null and b/design/fonts/ttf/outfit-latin-400-normal.ttf differ diff --git a/design/fonts/ttf/outfit-latin-500-normal.ttf b/design/fonts/ttf/outfit-latin-500-normal.ttf new file mode 100644 index 00000000..0eeb346f Binary files /dev/null and b/design/fonts/ttf/outfit-latin-500-normal.ttf differ diff --git a/design/fonts/ttf/outfit-latin-600-normal.ttf b/design/fonts/ttf/outfit-latin-600-normal.ttf new file mode 100644 index 00000000..e7a726fe Binary files /dev/null and b/design/fonts/ttf/outfit-latin-600-normal.ttf differ diff --git a/design/fonts/ttf/saira-latin-600-normal.ttf b/design/fonts/ttf/saira-latin-600-normal.ttf new file mode 100644 index 00000000..4d1e66fb Binary files /dev/null and b/design/fonts/ttf/saira-latin-600-normal.ttf differ diff --git a/design/fonts/ttf/saira-latin-700-italic.ttf b/design/fonts/ttf/saira-latin-700-italic.ttf new file mode 100644 index 00000000..6a33c24a Binary files /dev/null and b/design/fonts/ttf/saira-latin-700-italic.ttf differ diff --git a/design/fonts/ttf/saira-latin-800-italic.ttf b/design/fonts/ttf/saira-latin-800-italic.ttf new file mode 100644 index 00000000..d52fa06f Binary files /dev/null and b/design/fonts/ttf/saira-latin-800-italic.ttf differ diff --git a/design/fonts/woff2/jetbrains-mono-latin-400-normal.woff2 b/design/fonts/woff2/jetbrains-mono-latin-400-normal.woff2 new file mode 100644 index 00000000..c3f046fc Binary files /dev/null and b/design/fonts/woff2/jetbrains-mono-latin-400-normal.woff2 differ diff --git a/design/fonts/woff2/jetbrains-mono-latin-500-normal.woff2 b/design/fonts/woff2/jetbrains-mono-latin-500-normal.woff2 new file mode 100644 index 00000000..f2ca96c3 Binary files /dev/null and b/design/fonts/woff2/jetbrains-mono-latin-500-normal.woff2 differ diff --git a/design/fonts/woff2/outfit-latin-400-normal.woff2 b/design/fonts/woff2/outfit-latin-400-normal.woff2 new file mode 100644 index 00000000..6cfe639b Binary files /dev/null and b/design/fonts/woff2/outfit-latin-400-normal.woff2 differ diff --git a/design/fonts/woff2/outfit-latin-500-normal.woff2 b/design/fonts/woff2/outfit-latin-500-normal.woff2 new file mode 100644 index 00000000..f6fc14e0 Binary files /dev/null and b/design/fonts/woff2/outfit-latin-500-normal.woff2 differ diff --git a/design/fonts/woff2/outfit-latin-600-normal.woff2 b/design/fonts/woff2/outfit-latin-600-normal.woff2 new file mode 100644 index 00000000..a1e93de6 Binary files /dev/null and b/design/fonts/woff2/outfit-latin-600-normal.woff2 differ diff --git a/design/fonts/woff2/saira-latin-600-normal.woff2 b/design/fonts/woff2/saira-latin-600-normal.woff2 new file mode 100644 index 00000000..df6e61af Binary files /dev/null and b/design/fonts/woff2/saira-latin-600-normal.woff2 differ diff --git a/design/fonts/woff2/saira-latin-700-italic.woff2 b/design/fonts/woff2/saira-latin-700-italic.woff2 new file mode 100644 index 00000000..ba3edf3f Binary files /dev/null and b/design/fonts/woff2/saira-latin-700-italic.woff2 differ diff --git a/design/fonts/woff2/saira-latin-800-italic.woff2 b/design/fonts/woff2/saira-latin-800-italic.woff2 new file mode 100644 index 00000000..ccac41a8 Binary files /dev/null and b/design/fonts/woff2/saira-latin-800-italic.woff2 differ diff --git a/design/logos/README.md b/design/logos/README.md new file mode 100644 index 00000000..8e07e705 --- /dev/null +++ b/design/logos/README.md @@ -0,0 +1,12 @@ +# Logos + +All marks are flattened SVG (wordmark outlined from Saira ExtraBold Italic), so they render anywhere without the font. + +- `ultralog-lockup.svg`: mark + wordmark for dark grounds ("Ultra" `#f3f0ff`, "Log" magenta → orange → gold). Website nav, splash, Graphite exports. +- `ultralog-lockup-paper.svg`: for white grounds ("Ultra" `#15121f`, "Log" `#b8136a` → `#b5530f`). Paper exports, documents. +- `ultralog-lockup-mono-ink.svg`: single ink `#15121f`. One-colour print. +- `ultralog-mark.svg`: full-colour mark on its own dark fill. Favicon, avatar, About. +- `ultralog-mark-mono-ink.svg`: single ink `#15121f`, no fill. Light grounds, embossing. +- `ultralog-mark-mono-white.svg`: single ink `#f3f0ff`, no fill. Dark photos, engraving. +- `ultralog-app-icon.svg`, `ultralog-app-icon-1024.png`: app icon master (1024, rounded tile radius 228). Generate .icns / .ico from the PNG. +- `ultralog-badge.png`: the original neon badge, transparent background. Merch and legacy; not the primary logo. Dark grounds only. diff --git a/design/logos/hero-sunset-dash.png b/design/logos/hero-sunset-dash.png new file mode 100644 index 00000000..8f54cc31 Binary files /dev/null and b/design/logos/hero-sunset-dash.png differ diff --git a/design/logos/ultralog-app-icon-1024.png b/design/logos/ultralog-app-icon-1024.png new file mode 100644 index 00000000..dc10aa1a Binary files /dev/null and b/design/logos/ultralog-app-icon-1024.png differ diff --git a/design/logos/ultralog-app-icon.svg b/design/logos/ultralog-app-icon.svg new file mode 100644 index 00000000..e8cf761f --- /dev/null +++ b/design/logos/ultralog-app-icon.svg @@ -0,0 +1,8 @@ +UltraLog app icon + + + + + + + diff --git a/design/logos/ultralog-badge-original.png b/design/logos/ultralog-badge-original.png new file mode 100644 index 00000000..daf48d2b Binary files /dev/null and b/design/logos/ultralog-badge-original.png differ diff --git a/design/logos/ultralog-lockup-mono-ink.svg b/design/logos/ultralog-lockup-mono-ink.svg new file mode 100644 index 00000000..30c64c9f --- /dev/null +++ b/design/logos/ultralog-lockup-mono-ink.svg @@ -0,0 +1,2 @@ +UltraLog + diff --git a/design/logos/ultralog-lockup-paper.svg b/design/logos/ultralog-lockup-paper.svg new file mode 100644 index 00000000..b80e822b --- /dev/null +++ b/design/logos/ultralog-lockup-paper.svg @@ -0,0 +1,4 @@ +UltraLog + + + diff --git a/design/logos/ultralog-lockup.svg b/design/logos/ultralog-lockup.svg new file mode 100644 index 00000000..44c219e5 --- /dev/null +++ b/design/logos/ultralog-lockup.svg @@ -0,0 +1,4 @@ +UltraLog + + + diff --git a/design/logos/ultralog-mark-mono-ink.svg b/design/logos/ultralog-mark-mono-ink.svg new file mode 100644 index 00000000..ee7b19bb --- /dev/null +++ b/design/logos/ultralog-mark-mono-ink.svg @@ -0,0 +1 @@ +UltraLog mark diff --git a/design/logos/ultralog-mark-mono-white.svg b/design/logos/ultralog-mark-mono-white.svg new file mode 100644 index 00000000..0ccb34d1 --- /dev/null +++ b/design/logos/ultralog-mark-mono-white.svg @@ -0,0 +1 @@ +UltraLog mark diff --git a/design/logos/ultralog-mark.svg b/design/logos/ultralog-mark.svg new file mode 100644 index 00000000..4e991945 --- /dev/null +++ b/design/logos/ultralog-mark.svg @@ -0,0 +1,3 @@ +UltraLog mark + + diff --git a/design/reference/ActivityRail-night.png b/design/reference/ActivityRail-night.png new file mode 100644 index 00000000..7d08b611 Binary files /dev/null and b/design/reference/ActivityRail-night.png differ diff --git a/design/reference/Badge-night.png b/design/reference/Badge-night.png new file mode 100644 index 00000000..7f378ab1 Binary files /dev/null and b/design/reference/Badge-night.png differ diff --git a/design/reference/Button-night.png b/design/reference/Button-night.png new file mode 100644 index 00000000..e9a657b0 Binary files /dev/null and b/design/reference/Button-night.png differ diff --git a/design/reference/ChannelCard-night.png b/design/reference/ChannelCard-night.png new file mode 100644 index 00000000..387eb659 Binary files /dev/null and b/design/reference/ChannelCard-night.png differ diff --git a/design/reference/ChannelList-night.png b/design/reference/ChannelList-night.png new file mode 100644 index 00000000..b1782a04 Binary files /dev/null and b/design/reference/ChannelList-night.png differ diff --git a/design/reference/Checkbox-night.png b/design/reference/Checkbox-night.png new file mode 100644 index 00000000..7c8aecc9 Binary files /dev/null and b/design/reference/Checkbox-night.png differ diff --git a/design/reference/ColorScale-night.png b/design/reference/ColorScale-night.png new file mode 100644 index 00000000..5c4af12a Binary files /dev/null and b/design/reference/ColorScale-night.png differ diff --git a/design/reference/Cover-night.png b/design/reference/Cover-night.png new file mode 100644 index 00000000..683a4be8 Binary files /dev/null and b/design/reference/Cover-night.png differ diff --git a/design/reference/Cover-paper.png b/design/reference/Cover-paper.png new file mode 100644 index 00000000..7685c4ad Binary files /dev/null and b/design/reference/Cover-paper.png differ diff --git a/design/reference/DownloadCard-night.png b/design/reference/DownloadCard-night.png new file mode 100644 index 00000000..849c443f Binary files /dev/null and b/design/reference/DownloadCard-night.png differ diff --git a/design/reference/DropZone-night.png b/design/reference/DropZone-night.png new file mode 100644 index 00000000..a0795818 Binary files /dev/null and b/design/reference/DropZone-night.png differ diff --git a/design/reference/ExportDialog-night.png b/design/reference/ExportDialog-night.png new file mode 100644 index 00000000..821634eb Binary files /dev/null and b/design/reference/ExportDialog-night.png differ diff --git a/design/reference/ExportSheet-night.png b/design/reference/ExportSheet-night.png new file mode 100644 index 00000000..81721c75 Binary files /dev/null and b/design/reference/ExportSheet-night.png differ diff --git a/design/reference/FeatureCard-night.png b/design/reference/FeatureCard-night.png new file mode 100644 index 00000000..096da121 Binary files /dev/null and b/design/reference/FeatureCard-night.png differ diff --git a/design/reference/FileTabs-night.png b/design/reference/FileTabs-night.png new file mode 100644 index 00000000..8c86d0ab Binary files /dev/null and b/design/reference/FileTabs-night.png differ diff --git a/design/reference/FormulaEditor-night.png b/design/reference/FormulaEditor-night.png new file mode 100644 index 00000000..5c713d7b Binary files /dev/null and b/design/reference/FormulaEditor-night.png differ diff --git a/design/reference/HeatmapTable-cvd.png b/design/reference/HeatmapTable-cvd.png new file mode 100644 index 00000000..61743bac Binary files /dev/null and b/design/reference/HeatmapTable-cvd.png differ diff --git a/design/reference/HeatmapTable-hc.png b/design/reference/HeatmapTable-hc.png new file mode 100644 index 00000000..3829b248 Binary files /dev/null and b/design/reference/HeatmapTable-hc.png differ diff --git a/design/reference/HeatmapTable-night.png b/design/reference/HeatmapTable-night.png new file mode 100644 index 00000000..7425a321 Binary files /dev/null and b/design/reference/HeatmapTable-night.png differ diff --git a/design/reference/HeatmapTable-stealth.png b/design/reference/HeatmapTable-stealth.png new file mode 100644 index 00000000..e73c5bf1 Binary files /dev/null and b/design/reference/HeatmapTable-stealth.png differ diff --git a/design/reference/HeatmapTable-ultra.png b/design/reference/HeatmapTable-ultra.png new file mode 100644 index 00000000..219822a2 Binary files /dev/null and b/design/reference/HeatmapTable-ultra.png differ diff --git a/design/reference/IntensityControl-cvd.png b/design/reference/IntensityControl-cvd.png new file mode 100644 index 00000000..a0fd70a5 Binary files /dev/null and b/design/reference/IntensityControl-cvd.png differ diff --git a/design/reference/IntensityControl-hc.png b/design/reference/IntensityControl-hc.png new file mode 100644 index 00000000..45411926 Binary files /dev/null and b/design/reference/IntensityControl-hc.png differ diff --git a/design/reference/IntensityControl-night.png b/design/reference/IntensityControl-night.png new file mode 100644 index 00000000..ba53dc12 Binary files /dev/null and b/design/reference/IntensityControl-night.png differ diff --git a/design/reference/IntensityControl-stealth.png b/design/reference/IntensityControl-stealth.png new file mode 100644 index 00000000..9aaa401b Binary files /dev/null and b/design/reference/IntensityControl-stealth.png differ diff --git a/design/reference/IntensityControl-ultra.png b/design/reference/IntensityControl-ultra.png new file mode 100644 index 00000000..42761523 Binary files /dev/null and b/design/reference/IntensityControl-ultra.png differ diff --git a/design/reference/LibraryItem-night.png b/design/reference/LibraryItem-night.png new file mode 100644 index 00000000..bb02d5d5 Binary files /dev/null and b/design/reference/LibraryItem-night.png differ diff --git a/design/reference/ModeNav-night.png b/design/reference/ModeNav-night.png new file mode 100644 index 00000000..f620d009 Binary files /dev/null and b/design/reference/ModeNav-night.png differ diff --git a/design/reference/Notice-night.png b/design/reference/Notice-night.png new file mode 100644 index 00000000..c2353717 Binary files /dev/null and b/design/reference/Notice-night.png differ diff --git a/design/reference/PlaybackBar-night.png b/design/reference/PlaybackBar-night.png new file mode 100644 index 00000000..93656c4b Binary files /dev/null and b/design/reference/PlaybackBar-night.png differ diff --git a/design/reference/Plot-cvd.png b/design/reference/Plot-cvd.png new file mode 100644 index 00000000..70cb6ab8 Binary files /dev/null and b/design/reference/Plot-cvd.png differ diff --git a/design/reference/Plot-hc.png b/design/reference/Plot-hc.png new file mode 100644 index 00000000..480ccde8 Binary files /dev/null and b/design/reference/Plot-hc.png differ diff --git a/design/reference/Plot-night.png b/design/reference/Plot-night.png new file mode 100644 index 00000000..75513743 Binary files /dev/null and b/design/reference/Plot-night.png differ diff --git a/design/reference/Plot-stealth.png b/design/reference/Plot-stealth.png new file mode 100644 index 00000000..d8986916 Binary files /dev/null and b/design/reference/Plot-stealth.png differ diff --git a/design/reference/Plot-ultra.png b/design/reference/Plot-ultra.png new file mode 100644 index 00000000..a9e0fa9b Binary files /dev/null and b/design/reference/Plot-ultra.png differ diff --git a/design/reference/ReleaseCard-night.png b/design/reference/ReleaseCard-night.png new file mode 100644 index 00000000..41b4aca8 Binary files /dev/null and b/design/reference/ReleaseCard-night.png differ diff --git a/design/reference/Segmented-night.png b/design/reference/Segmented-night.png new file mode 100644 index 00000000..ac6d8908 Binary files /dev/null and b/design/reference/Segmented-night.png differ diff --git a/design/reference/SiteHero-night.png b/design/reference/SiteHero-night.png new file mode 100644 index 00000000..63f6e0af Binary files /dev/null and b/design/reference/SiteHero-night.png differ diff --git a/design/reference/Slider-night.png b/design/reference/Slider-night.png new file mode 100644 index 00000000..d2583fbf Binary files /dev/null and b/design/reference/Slider-night.png differ diff --git a/design/reference/TextField-night.png b/design/reference/TextField-night.png new file mode 100644 index 00000000..d06340f8 Binary files /dev/null and b/design/reference/TextField-night.png differ diff --git a/design/reference/Window-night.png b/design/reference/Window-night.png new file mode 100644 index 00000000..c8517ac0 Binary files /dev/null and b/design/reference/Window-night.png differ diff --git a/design/rust/theme.rs b/design/rust/theme.rs new file mode 100644 index 00000000..f1e090b1 --- /dev/null +++ b/design/rust/theme.rs @@ -0,0 +1,191 @@ +//! UltraLog theme runtime: builds the live `Tokens` from the user's settings and maps them onto egui. +//! Written against egui 0.31. Field names in `visuals()` may differ slightly in other versions. +use crate::ultralog_theme::{self as ut, Effects, Tokens}; +use egui::{Color32, CornerRadius, Shadow, Stroke, Visuals}; + +/// User-facing settings that decide the theme. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ThemeSettings { + /// Ultra intensity, 0..=100. 0 Stealth, 35 Night Drive (default), 70 Neon, 100 Ultra. + pub intensity: f32, + pub color_blind: bool, + pub high_contrast: bool, +} +impl Default for ThemeSettings { + fn default() -> Self { Self { intensity: 35.0, color_blind: false, high_contrast: false } } +} + +/// Export themes ignore every app setting. +pub enum ExportTheme { Paper, Graphite } +pub fn export_tokens(t: ExportTheme) -> Tokens { match t { ExportTheme::Paper => ut::PAPER, ExportTheme::Graphite => ut::GRAPHITE } } + +/// The live token table for the app. +pub fn tokens_at(s: ThemeSettings) -> Tokens { + if s.high_contrast { return ut::HC; } + let i = s.intensity.clamp(0.0, 100.0); + let mut t = if i <= 35.0 { lerp_tokens(&ut::STEALTH, &ut::NIGHT, i / 35.0) } else { lerp_tokens(&ut::NIGHT, &ut::ULTRA, (i - 35.0) / 65.0) }; + if s.color_blind { + let c = ut::CVD; + // Data + status come from the color-blind table; text is raised to its AAA values. + copy_data(&mut t, &c); + t.ink_muted = c.ink_muted; t.ink_subtle = c.ink_subtle; t.line_control = c.line_control; t.magenta = c.magenta; + t.fx.bloom = 0.0; t.fx.plot_floor = false; t.plot_floor = Color32::TRANSPARENT; + } + t +} + +fn copy_data(t: &mut Tokens, c: &Tokens) { + t.success = c.success; t.success_soft = c.success_soft; t.warning = c.warning; t.warning_soft = c.warning_soft; + t.danger = c.danger; t.danger_soft = c.danger_soft; t.info = c.info; + t.series_1 = c.series_1; t.series_2 = c.series_2; t.series_3 = c.series_3; t.series_4 = c.series_4; t.series_5 = c.series_5; + t.series_6 = c.series_6; t.series_7 = c.series_7; t.series_8 = c.series_8; t.series_9 = c.series_9; t.series_10 = c.series_10; + t.seq_0 = c.seq_0; t.seq_1 = c.seq_1; t.seq_2 = c.seq_2; t.seq_3 = c.seq_3; t.seq_4 = c.seq_4; t.seq_5 = c.seq_5; t.seq_6 = c.seq_6; + t.on_seq_0 = c.on_seq_0; t.on_seq_1 = c.on_seq_1; t.on_seq_2 = c.on_seq_2; t.on_seq_3 = c.on_seq_3; t.on_seq_4 = c.on_seq_4; t.on_seq_5 = c.on_seq_5; t.on_seq_6 = c.on_seq_6; + t.div_0 = c.div_0; t.div_1 = c.div_1; t.div_2 = c.div_2; t.div_3 = c.div_3; t.div_4 = c.div_4; + t.on_div_0 = c.on_div_0; t.on_div_1 = c.on_div_1; t.on_div_2 = c.on_div_2; t.on_div_3 = c.on_div_3; t.on_div_4 = c.on_div_4; + t.cell_plate = c.cell_plate; t.plot_cursor = c.plot_cursor; t.plot_selection = c.plot_selection; +} + +/// Series colour by selection index (0-based). Returns (colour, dashed). +pub fn series(t: &Tokens, idx: usize, color_blind_like: bool) -> (Color32, bool) { + let s = [t.series_1, t.series_2, t.series_3, t.series_4, t.series_5, t.series_6, t.series_7, t.series_8, t.series_9, t.series_10]; + (s[idx % 10], color_blind_like && idx >= 8) +} + +/// Sequential colormap sample, v in 0..=1, interpolated in OKLab between the 7 stops. +pub fn seq(t: &Tokens, v: f32) -> Color32 { + let s = [t.seq_0, t.seq_1, t.seq_2, t.seq_3, t.seq_4, t.seq_5, t.seq_6]; + let x = v.clamp(0.0, 1.0) * 6.0; let i = (x.floor() as usize).min(5); + lerp_oklab(s[i], s[i + 1], x - i as f32) +} + +pub fn visuals(t: &Tokens) -> Visuals { + let mut v = Visuals::dark(); + v.dark_mode = true; + v.panel_fill = t.bg_panel; + v.window_fill = t.bg_raised; + v.extreme_bg_color = t.bg_field; + v.faint_bg_color = t.bg_base; + v.code_bg_color = t.bg_field; + v.hyperlink_color = t.cyan; + v.warn_fg_color = t.warning; + v.error_fg_color = t.danger; + v.selection.bg_fill = t.cyan_soft; + v.selection.stroke = Stroke::new(1.0, t.cyan); + v.window_stroke = Stroke::new(1.0, t.line); + v.window_corner_radius = CornerRadius::same(ut::radius::RADIUS_LG); + v.menu_corner_radius = CornerRadius::same(ut::radius::RADIUS_MD); + v.window_shadow = Shadow { offset: [0, 18], blur: 48, spread: 0, color: Color32::from_black_alpha(150) }; + v.popup_shadow = Shadow { offset: [0, 8], blur: 24, spread: 0, color: Color32::from_black_alpha(120) }; + + let w = &mut v.widgets; + w.noninteractive.bg_fill = t.bg_panel; + w.noninteractive.weak_bg_fill = t.bg_panel; + w.noninteractive.bg_stroke = Stroke::new(1.0, t.line); + w.noninteractive.fg_stroke = Stroke::new(1.0, t.ink_muted); + w.inactive.bg_fill = t.bg_field; + w.inactive.weak_bg_fill = t.bg_raised; + w.inactive.bg_stroke = Stroke::new(1.0, t.line_control); + w.inactive.fg_stroke = Stroke::new(1.0, t.ink); + w.hovered.bg_fill = t.bg_hover; + w.hovered.weak_bg_fill = t.bg_hover; + w.hovered.bg_stroke = Stroke::new(1.0, t.line_control); + w.hovered.fg_stroke = Stroke::new(1.0, t.ink); + w.active.bg_fill = t.bg_active; + w.active.weak_bg_fill = t.bg_active; + w.active.bg_stroke = Stroke::new(1.0, t.cyan); + w.active.fg_stroke = Stroke::new(1.0, t.ink); + w.open.bg_fill = t.bg_active; + w.open.weak_bg_fill = t.bg_active; + w.open.bg_stroke = Stroke::new(1.0, t.line_control); + w.open.fg_stroke = Stroke::new(1.0, t.ink); + for s in [&mut w.noninteractive, &mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] { + s.corner_radius = CornerRadius::same(ut::radius::RADIUS_MD); + } + v +} + +/// Glow behind the primary button / active tab. Returns None when intensity turns glows off. +pub fn glow(t: &Tokens, color: Color32) -> Option { + if t.fx.glow_blur <= 0.0 { return None; } + let [r, g, b, _] = color.to_array(); + Some(Shadow { offset: [0, 0], blur: t.fx.glow_blur as u8, spread: 1, color: Color32::from_rgba_unmultiplied(r, g, b, (t.fx.glow_alpha * 255.0) as u8) }) +} + +// ---------- interpolation ---------- +fn lerp_tokens(a: &Tokens, b: &Tokens, k: f32) -> Tokens { + let k = k.clamp(0.0, 1.0); + macro_rules! mix { ($($f:ident),*) => { Tokens { $($f: lerp_oklab(a.$f, b.$f, k),)* fx: lerp_fx(a.fx, b.fx, k) } } } + mix!(bg_void, bg_base, bg_panel, bg_raised, bg_field, bg_hover, bg_active, line_subtle, line, line_control, ink, ink_muted, ink_subtle, ink_disabled, + cyan, cyan_soft, on_cyan, magenta, magenta_soft, on_magenta, violet, orange, gold, redline_0, redline_1, redline_2, redline_3, redline_4, grid_floor, + success, success_soft, warning, warning_soft, danger, danger_soft, info, + series_1, series_2, series_3, series_4, series_5, series_6, series_7, series_8, series_9, series_10, + plot_bg, plot_grid, plot_grid_major, plot_axis, plot_cursor, plot_selection, plot_legend_bg, plot_floor, + seq_0, seq_1, seq_2, seq_3, seq_4, seq_5, seq_6, on_seq_0, on_seq_1, on_seq_2, on_seq_3, on_seq_4, on_seq_5, on_seq_6, + div_0, div_1, div_2, div_3, div_4, on_div_0, on_div_1, on_div_2, on_div_3, on_div_4, cell_empty, cell_plate) +} +fn lerp_fx(a: Effects, b: Effects, k: f32) -> Effects { + let l = |x: f32, y: f32| x + (y - x) * k; + Effects { glow_blur: l(a.glow_blur, b.glow_blur), glow_alpha: l(a.glow_alpha, b.glow_alpha), bloom: l(a.bloom, b.bloom), + redline_gradient: if k < 0.5 { a.redline_gradient } else { b.redline_gradient }, + plot_floor: k >= 0.5 && b.plot_floor || k < 0.5 && a.plot_floor } +} + +pub fn lerp_oklab(a: Color32, b: Color32, k: f32) -> Color32 { + if a == b { return a; } + let (la, aa) = (to_oklab(a), a.a() as f32); let (lb, ab) = (to_oklab(b), b.a() as f32); + let m = [la[0] + (lb[0] - la[0]) * k, la[1] + (lb[1] - la[1]) * k, la[2] + (lb[2] - la[2]) * k]; + from_oklab(m, (aa + (ab - aa) * k).round() as u8) +} +fn lin(c: u8) -> f32 { let c = c as f32 / 255.0; if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } } +fn srgb(c: f32) -> u8 { let c = c.clamp(0.0, 1.0); let v = if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }; (v * 255.0).round() as u8 } +fn to_oklab(c: Color32) -> [f32; 3] { + // Work on unmultiplied RGB. + let [r, g, b, _] = c.to_srgba_unmultiplied(); + let (r, g, b) = (lin(r), lin(g), lin(b)); + let l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b).cbrt(); + let m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b).cbrt(); + let s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b).cbrt(); + [0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s] +} +fn from_oklab(c: [f32; 3], a: u8) -> Color32 { + let l = (c[0] + 0.3963377774 * c[1] + 0.2158037573 * c[2]).powi(3); + let m = (c[0] - 0.1055613458 * c[1] - 0.0638541728 * c[2]).powi(3); + let s = (c[0] - 0.0894841775 * c[1] - 1.2914855480 * c[2]).powi(3); + let r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + let g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + let b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; + Color32::from_rgba_unmultiplied(srgb(r), srgb(g), srgb(b), a) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] fn anchors_are_exact() { + let n = tokens_at(ThemeSettings::default()); + assert_eq!(n.bg_panel, ut::NIGHT.bg_panel); + assert_eq!(tokens_at(ThemeSettings { intensity: 0.0, ..Default::default() }).bg_panel, ut::STEALTH.bg_panel); + assert_eq!(tokens_at(ThemeSettings { intensity: 100.0, ..Default::default() }).bg_panel, ut::ULTRA.bg_panel); + } + fn lum(c: Color32) -> f32 { let [r, g, b, _] = c.to_srgba_unmultiplied(); 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) } + fn ratio(a: Color32, b: Color32) -> f32 { let (x, y) = (lum(a), lum(b)); (x.max(y) + 0.05) / (x.min(y) + 0.05) } + /// Every intensity value keeps text at AA (AAA with Color blind mode). + #[test] fn text_contrast_holds_across_the_slider() { + for cb in [false, true] { + let min = if cb { 7.0 } else { 4.5 }; + for i in 0..=100 { + let t = tokens_at(ThemeSettings { intensity: i as f32, color_blind: cb, high_contrast: false }); + for ink in [t.ink, t.ink_muted, t.ink_subtle] { + for bg in [t.bg_void, t.bg_base, t.bg_panel, t.bg_raised, t.bg_field, t.bg_hover] { + assert!(ratio(ink, bg) >= min, "intensity {i} cb {cb}: {:.2}", ratio(ink, bg)); + } + } + } + } + } + #[test] fn data_colors_ignore_intensity() { + for i in [0.0, 50.0, 100.0] { + assert_eq!(tokens_at(ThemeSettings { intensity: i, ..Default::default() }).series_3, ut::NIGHT.series_3); + } + } +} diff --git a/design/rust/ultralog_theme.rs b/design/rust/ultralog_theme.rs new file mode 100644 index 00000000..a29d70d0 --- /dev/null +++ b/design/rust/ultralog_theme.rs @@ -0,0 +1,729 @@ +//! UltraLog design tokens — generated from tokens.json. Do not edit by hand. +//! Seven anchor themes. The live theme is built by `crate::theme::tokens_at` (see HANDOFF-APP.md). +#![allow(dead_code)] +use egui::Color32; + +#[derive(Clone, Copy, Debug)] +pub struct Effects { + /// Blur radius of glow-cyan / glow-magenta (px). 0 = no glow. + pub glow_blur: f32, + /// Alpha of the glow colour. + pub glow_alpha: f32, + /// Width of the translucent bloom drawn under series lines (px). 0 = off. + pub bloom: f32, + /// Draw the sunset redline as a gradient (false = flat line-control hairline). + pub redline_gradient: bool, + /// Draw the perspective floor inside plots. + pub plot_floor: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct Tokens { + pub bg_void: Color32, + pub bg_base: Color32, + pub bg_panel: Color32, + pub bg_raised: Color32, + pub bg_field: Color32, + pub bg_hover: Color32, + pub bg_active: Color32, + pub line_subtle: Color32, + pub line: Color32, + pub line_control: Color32, + pub ink: Color32, + pub ink_muted: Color32, + pub ink_subtle: Color32, + pub ink_disabled: Color32, + pub cyan: Color32, + pub cyan_soft: Color32, + pub on_cyan: Color32, + pub magenta: Color32, + pub magenta_soft: Color32, + pub on_magenta: Color32, + pub violet: Color32, + pub orange: Color32, + pub gold: Color32, + pub redline_0: Color32, + pub redline_1: Color32, + pub redline_2: Color32, + pub redline_3: Color32, + pub redline_4: Color32, + pub grid_floor: Color32, + pub success: Color32, + pub success_soft: Color32, + pub warning: Color32, + pub warning_soft: Color32, + pub danger: Color32, + pub danger_soft: Color32, + pub info: Color32, + pub series_1: Color32, + pub series_2: Color32, + pub series_3: Color32, + pub series_4: Color32, + pub series_5: Color32, + pub series_6: Color32, + pub series_7: Color32, + pub series_8: Color32, + pub series_9: Color32, + pub series_10: Color32, + pub plot_bg: Color32, + pub plot_grid: Color32, + pub plot_grid_major: Color32, + pub plot_axis: Color32, + pub plot_cursor: Color32, + pub plot_selection: Color32, + pub plot_legend_bg: Color32, + pub plot_floor: Color32, + pub seq_0: Color32, + pub seq_1: Color32, + pub seq_2: Color32, + pub seq_3: Color32, + pub seq_4: Color32, + pub seq_5: Color32, + pub seq_6: Color32, + pub on_seq_0: Color32, + pub on_seq_1: Color32, + pub on_seq_2: Color32, + pub on_seq_3: Color32, + pub on_seq_4: Color32, + pub on_seq_5: Color32, + pub on_seq_6: Color32, + pub div_0: Color32, + pub div_1: Color32, + pub div_2: Color32, + pub div_3: Color32, + pub div_4: Color32, + pub on_div_0: Color32, + pub on_div_1: Color32, + pub on_div_2: Color32, + pub on_div_3: Color32, + pub on_div_4: Color32, + pub cell_empty: Color32, + pub cell_plate: Color32, + pub fx: Effects, +} + +pub const NIGHT: Tokens = Tokens { + bg_void: Color32::from_rgb(8, 7, 15), + bg_base: Color32::from_rgb(15, 13, 26), + bg_panel: Color32::from_rgb(22, 19, 39), + bg_raised: Color32::from_rgb(30, 26, 51), + bg_field: Color32::from_rgb(12, 11, 22), + bg_hover: Color32::from_rgb(39, 34, 65), + bg_active: Color32::from_rgb(50, 43, 85), + line_subtle: Color32::from_rgb(35, 31, 58), + line: Color32::from_rgb(47, 42, 75), + line_control: Color32::from_rgb(111, 103, 153), + ink: Color32::from_rgb(243, 240, 255), + ink_muted: Color32::from_rgb(179, 171, 214), + ink_subtle: Color32::from_rgb(143, 136, 179), + ink_disabled: Color32::from_rgb(90, 84, 120), + cyan: Color32::from_rgb(45, 226, 255), + cyan_soft: Color32::from_rgb(13, 47, 59), + on_cyan: Color32::from_rgb(4, 19, 26), + magenta: Color32::from_rgb(255, 92, 173), + magenta_soft: Color32::from_rgb(58, 16, 48), + on_magenta: Color32::from_rgb(26, 4, 16), + violet: Color32::from_rgb(166, 138, 255), + orange: Color32::from_rgb(255, 140, 66), + gold: Color32::from_rgb(255, 210, 63), + redline_0: Color32::from_rgb(45, 226, 255), + redline_1: Color32::from_rgb(166, 138, 255), + redline_2: Color32::from_rgb(255, 92, 173), + redline_3: Color32::from_rgb(255, 140, 66), + redline_4: Color32::from_rgb(255, 210, 63), + grid_floor: Color32::from_rgb(47, 42, 75), + success: Color32::from_rgb(62, 224, 143), + success_soft: Color32::from_rgb(15, 51, 36), + warning: Color32::from_rgb(255, 197, 61), + warning_soft: Color32::from_rgb(58, 44, 8), + danger: Color32::from_rgb(255, 92, 108), + danger_soft: Color32::from_rgb(61, 15, 23), + info: Color32::from_rgb(45, 226, 255), + series_1: Color32::from_rgb(255, 92, 173), + series_2: Color32::from_rgb(255, 210, 63), + series_3: Color32::from_rgb(91, 168, 255), + series_4: Color32::from_rgb(163, 230, 90), + series_5: Color32::from_rgb(255, 140, 66), + series_6: Color32::from_rgb(166, 138, 255), + series_7: Color32::from_rgb(62, 224, 192), + series_8: Color32::from_rgb(233, 216, 176), + series_9: Color32::from_rgb(255, 123, 123), + series_10: Color32::from_rgb(201, 196, 220), + plot_bg: Color32::from_rgb(8, 7, 15), + plot_grid: Color32::from_rgb(26, 23, 48), + plot_grid_major: Color32::from_rgb(44, 39, 71), + plot_axis: Color32::from_rgb(143, 136, 179), + plot_cursor: Color32::from_rgb(45, 226, 255), + plot_selection: Color32::from_rgba_premultiplied(6, 32, 36, 36), + plot_legend_bg: Color32::from_rgba_premultiplied(20, 17, 35, 230), + plot_floor: Color32::from_rgba_premultiplied(0, 0, 0, 0), + seq_0: Color32::from_rgb(42, 29, 92), + seq_1: Color32::from_rgb(79, 33, 131), + seq_2: Color32::from_rgb(139, 42, 155), + seq_3: Color32::from_rgb(224, 71, 154), + seq_4: Color32::from_rgb(255, 99, 99), + seq_5: Color32::from_rgb(255, 157, 66), + seq_6: Color32::from_rgb(255, 226, 90), + on_seq_0: Color32::from_rgb(243, 240, 255), + on_seq_1: Color32::from_rgb(243, 240, 255), + on_seq_2: Color32::from_rgb(243, 240, 255), + on_seq_3: Color32::from_rgb(12, 11, 22), + on_seq_4: Color32::from_rgb(12, 11, 22), + on_seq_5: Color32::from_rgb(12, 11, 22), + on_seq_6: Color32::from_rgb(12, 11, 22), + div_0: Color32::from_rgb(54, 200, 255), + div_1: Color32::from_rgb(30, 106, 143), + div_2: Color32::from_rgb(43, 38, 64), + div_3: Color32::from_rgb(154, 74, 44), + div_4: Color32::from_rgb(255, 122, 61), + on_div_0: Color32::from_rgb(12, 11, 22), + on_div_1: Color32::from_rgb(243, 240, 255), + on_div_2: Color32::from_rgb(243, 240, 255), + on_div_3: Color32::from_rgb(243, 240, 255), + on_div_4: Color32::from_rgb(12, 11, 22), + cell_empty: Color32::from_rgb(22, 19, 39), + cell_plate: Color32::from_rgba_premultiplied(0, 0, 0, 0), + fx: Effects { glow_blur: 16.0, glow_alpha: 0.35, bloom: 0.0, redline_gradient: true, plot_floor: false }, +}; + +pub const STEALTH: Tokens = Tokens { + bg_void: Color32::from_rgb(10, 10, 12), + bg_base: Color32::from_rgb(17, 17, 19), + bg_panel: Color32::from_rgb(23, 23, 26), + bg_raised: Color32::from_rgb(30, 30, 34), + bg_field: Color32::from_rgb(13, 13, 15), + bg_hover: Color32::from_rgb(38, 38, 43), + bg_active: Color32::from_rgb(48, 48, 54), + line_subtle: Color32::from_rgb(34, 34, 38), + line: Color32::from_rgb(44, 44, 49), + line_control: Color32::from_rgb(108, 108, 118), + ink: Color32::from_rgb(242, 242, 245), + ink_muted: Color32::from_rgb(180, 180, 189), + ink_subtle: Color32::from_rgb(143, 143, 153), + ink_disabled: Color32::from_rgb(86, 86, 94), + cyan: Color32::from_rgb(92, 200, 220), + cyan_soft: Color32::from_rgb(19, 42, 48), + on_cyan: Color32::from_rgb(6, 20, 23), + magenta: Color32::from_rgb(255, 92, 173), + magenta_soft: Color32::from_rgb(58, 16, 48), + on_magenta: Color32::from_rgb(26, 4, 16), + violet: Color32::from_rgb(166, 138, 255), + orange: Color32::from_rgb(255, 140, 66), + gold: Color32::from_rgb(255, 210, 63), + redline_0: Color32::from_rgb(108, 108, 118), + redline_1: Color32::from_rgb(108, 108, 118), + redline_2: Color32::from_rgb(108, 108, 118), + redline_3: Color32::from_rgb(108, 108, 118), + redline_4: Color32::from_rgb(108, 108, 118), + grid_floor: Color32::from_rgb(34, 34, 38), + success: Color32::from_rgb(62, 224, 143), + success_soft: Color32::from_rgb(15, 51, 36), + warning: Color32::from_rgb(255, 197, 61), + warning_soft: Color32::from_rgb(58, 44, 8), + danger: Color32::from_rgb(255, 92, 108), + danger_soft: Color32::from_rgb(61, 15, 23), + info: Color32::from_rgb(92, 200, 220), + series_1: Color32::from_rgb(255, 92, 173), + series_2: Color32::from_rgb(255, 210, 63), + series_3: Color32::from_rgb(91, 168, 255), + series_4: Color32::from_rgb(163, 230, 90), + series_5: Color32::from_rgb(255, 140, 66), + series_6: Color32::from_rgb(166, 138, 255), + series_7: Color32::from_rgb(62, 224, 192), + series_8: Color32::from_rgb(233, 216, 176), + series_9: Color32::from_rgb(255, 123, 123), + series_10: Color32::from_rgb(201, 196, 220), + plot_bg: Color32::from_rgb(10, 10, 12), + plot_grid: Color32::from_rgb(24, 24, 28), + plot_grid_major: Color32::from_rgb(42, 42, 48), + plot_axis: Color32::from_rgb(143, 143, 153), + plot_cursor: Color32::from_rgb(92, 200, 220), + plot_selection: Color32::from_rgba_premultiplied(12, 27, 29, 34), + plot_legend_bg: Color32::from_rgba_premultiplied(21, 21, 23, 230), + plot_floor: Color32::from_rgba_premultiplied(0, 0, 0, 0), + seq_0: Color32::from_rgb(42, 29, 92), + seq_1: Color32::from_rgb(79, 33, 131), + seq_2: Color32::from_rgb(139, 42, 155), + seq_3: Color32::from_rgb(224, 71, 154), + seq_4: Color32::from_rgb(255, 99, 99), + seq_5: Color32::from_rgb(255, 157, 66), + seq_6: Color32::from_rgb(255, 226, 90), + on_seq_0: Color32::from_rgb(242, 242, 245), + on_seq_1: Color32::from_rgb(242, 242, 245), + on_seq_2: Color32::from_rgb(242, 242, 245), + on_seq_3: Color32::from_rgb(13, 13, 15), + on_seq_4: Color32::from_rgb(13, 13, 15), + on_seq_5: Color32::from_rgb(13, 13, 15), + on_seq_6: Color32::from_rgb(13, 13, 15), + div_0: Color32::from_rgb(54, 200, 255), + div_1: Color32::from_rgb(30, 106, 143), + div_2: Color32::from_rgb(41, 41, 47), + div_3: Color32::from_rgb(154, 74, 44), + div_4: Color32::from_rgb(255, 122, 61), + on_div_0: Color32::from_rgb(13, 13, 15), + on_div_1: Color32::from_rgb(242, 242, 245), + on_div_2: Color32::from_rgb(242, 242, 245), + on_div_3: Color32::from_rgb(242, 242, 245), + on_div_4: Color32::from_rgb(13, 13, 15), + cell_empty: Color32::from_rgb(23, 23, 26), + cell_plate: Color32::from_rgba_premultiplied(0, 0, 0, 0), + fx: Effects { glow_blur: 0.0, glow_alpha: 0.0, bloom: 0.0, redline_gradient: false, plot_floor: false }, +}; + +pub const ULTRA: Tokens = Tokens { + bg_void: Color32::from_rgb(6, 2, 14), + bg_base: Color32::from_rgb(14, 6, 32), + bg_panel: Color32::from_rgb(22, 10, 46), + bg_raised: Color32::from_rgb(32, 16, 61), + bg_field: Color32::from_rgb(10, 4, 24), + bg_hover: Color32::from_rgb(43, 22, 80), + bg_active: Color32::from_rgb(55, 29, 99), + line_subtle: Color32::from_rgb(42, 21, 72), + line: Color32::from_rgb(62, 32, 104), + line_control: Color32::from_rgb(142, 104, 200), + ink: Color32::from_rgb(251, 244, 255), + ink_muted: Color32::from_rgb(205, 182, 239), + ink_subtle: Color32::from_rgb(169, 144, 214), + ink_disabled: Color32::from_rgb(94, 70, 136), + cyan: Color32::from_rgb(61, 240, 255), + cyan_soft: Color32::from_rgb(11, 50, 67), + on_cyan: Color32::from_rgb(4, 19, 26), + magenta: Color32::from_rgb(255, 61, 165), + magenta_soft: Color32::from_rgb(58, 16, 48), + on_magenta: Color32::from_rgb(26, 4, 16), + violet: Color32::from_rgb(180, 140, 255), + orange: Color32::from_rgb(255, 140, 66), + gold: Color32::from_rgb(255, 210, 63), + redline_0: Color32::from_rgb(61, 240, 255), + redline_1: Color32::from_rgb(180, 140, 255), + redline_2: Color32::from_rgb(255, 61, 165), + redline_3: Color32::from_rgb(255, 140, 66), + redline_4: Color32::from_rgb(255, 210, 63), + grid_floor: Color32::from_rgba_premultiplied(115, 41, 78, 115), + success: Color32::from_rgb(62, 224, 143), + success_soft: Color32::from_rgb(15, 51, 36), + warning: Color32::from_rgb(255, 197, 61), + warning_soft: Color32::from_rgb(58, 44, 8), + danger: Color32::from_rgb(255, 92, 108), + danger_soft: Color32::from_rgb(61, 15, 23), + info: Color32::from_rgb(61, 240, 255), + series_1: Color32::from_rgb(255, 92, 173), + series_2: Color32::from_rgb(255, 210, 63), + series_3: Color32::from_rgb(91, 168, 255), + series_4: Color32::from_rgb(163, 230, 90), + series_5: Color32::from_rgb(255, 140, 66), + series_6: Color32::from_rgb(166, 138, 255), + series_7: Color32::from_rgb(62, 224, 192), + series_8: Color32::from_rgb(233, 216, 176), + series_9: Color32::from_rgb(255, 123, 123), + series_10: Color32::from_rgb(201, 196, 220), + plot_bg: Color32::from_rgb(6, 2, 14), + plot_grid: Color32::from_rgb(29, 13, 54), + plot_grid_major: Color32::from_rgb(59, 26, 98), + plot_axis: Color32::from_rgb(169, 144, 214), + plot_cursor: Color32::from_rgb(61, 240, 255), + plot_selection: Color32::from_rgba_premultiplied(11, 43, 46, 46), + plot_legend_bg: Color32::from_rgba_premultiplied(20, 9, 41, 230), + plot_floor: Color32::from_rgba_premultiplied(56, 13, 36, 56), + seq_0: Color32::from_rgb(42, 29, 92), + seq_1: Color32::from_rgb(79, 33, 131), + seq_2: Color32::from_rgb(139, 42, 155), + seq_3: Color32::from_rgb(224, 71, 154), + seq_4: Color32::from_rgb(255, 99, 99), + seq_5: Color32::from_rgb(255, 157, 66), + seq_6: Color32::from_rgb(255, 226, 90), + on_seq_0: Color32::from_rgb(251, 244, 255), + on_seq_1: Color32::from_rgb(251, 244, 255), + on_seq_2: Color32::from_rgb(251, 244, 255), + on_seq_3: Color32::from_rgb(10, 4, 24), + on_seq_4: Color32::from_rgb(10, 4, 24), + on_seq_5: Color32::from_rgb(10, 4, 24), + on_seq_6: Color32::from_rgb(10, 4, 24), + div_0: Color32::from_rgb(54, 200, 255), + div_1: Color32::from_rgb(30, 106, 143), + div_2: Color32::from_rgb(42, 24, 70), + div_3: Color32::from_rgb(154, 74, 44), + div_4: Color32::from_rgb(255, 122, 61), + on_div_0: Color32::from_rgb(10, 4, 24), + on_div_1: Color32::from_rgb(251, 244, 255), + on_div_2: Color32::from_rgb(251, 244, 255), + on_div_3: Color32::from_rgb(251, 244, 255), + on_div_4: Color32::from_rgb(10, 4, 24), + cell_empty: Color32::from_rgb(22, 10, 46), + cell_plate: Color32::from_rgba_premultiplied(0, 0, 0, 0), + fx: Effects { glow_blur: 22.0, glow_alpha: 0.6, bloom: 4.0, redline_gradient: true, plot_floor: true }, +}; + +pub const CVD: Tokens = Tokens { + bg_void: Color32::from_rgb(8, 7, 15), + bg_base: Color32::from_rgb(15, 13, 26), + bg_panel: Color32::from_rgb(22, 19, 39), + bg_raised: Color32::from_rgb(30, 26, 51), + bg_field: Color32::from_rgb(12, 11, 22), + bg_hover: Color32::from_rgb(39, 34, 65), + bg_active: Color32::from_rgb(50, 43, 85), + line_subtle: Color32::from_rgb(35, 31, 58), + line: Color32::from_rgb(47, 42, 75), + line_control: Color32::from_rgb(141, 134, 184), + ink: Color32::from_rgb(243, 240, 255), + ink_muted: Color32::from_rgb(205, 199, 234), + ink_subtle: Color32::from_rgb(188, 181, 222), + ink_disabled: Color32::from_rgb(90, 84, 120), + cyan: Color32::from_rgb(45, 226, 255), + cyan_soft: Color32::from_rgb(13, 47, 59), + on_cyan: Color32::from_rgb(4, 19, 26), + magenta: Color32::from_rgb(255, 148, 201), + magenta_soft: Color32::from_rgb(58, 16, 48), + on_magenta: Color32::from_rgb(26, 4, 16), + violet: Color32::from_rgb(166, 138, 255), + orange: Color32::from_rgb(255, 140, 66), + gold: Color32::from_rgb(255, 210, 63), + redline_0: Color32::from_rgb(45, 226, 255), + redline_1: Color32::from_rgb(166, 138, 255), + redline_2: Color32::from_rgb(255, 148, 201), + redline_3: Color32::from_rgb(255, 140, 66), + redline_4: Color32::from_rgb(255, 210, 63), + grid_floor: Color32::from_rgb(47, 42, 75), + success: Color32::from_rgb(124, 200, 255), + success_soft: Color32::from_rgb(11, 40, 64), + warning: Color32::from_rgb(245, 232, 90), + warning_soft: Color32::from_rgb(47, 43, 6), + danger: Color32::from_rgb(255, 162, 94), + danger_soft: Color32::from_rgb(58, 29, 5), + info: Color32::from_rgb(212, 201, 255), + series_1: Color32::from_rgb(86, 180, 233), + series_2: Color32::from_rgb(230, 159, 0), + series_3: Color32::from_rgb(31, 191, 143), + series_4: Color32::from_rgb(240, 228, 66), + series_5: Color32::from_rgb(47, 134, 208), + series_6: Color32::from_rgb(232, 105, 42), + series_7: Color32::from_rgb(217, 133, 184), + series_8: Color32::from_rgb(143, 138, 158), + series_9: Color32::from_rgb(86, 180, 233), + series_10: Color32::from_rgb(230, 159, 0), + plot_bg: Color32::from_rgb(8, 7, 15), + plot_grid: Color32::from_rgb(26, 23, 48), + plot_grid_major: Color32::from_rgb(44, 39, 71), + plot_axis: Color32::from_rgb(188, 181, 222), + plot_cursor: Color32::from_rgb(243, 240, 255), + plot_selection: Color32::from_rgba_premultiplied(30, 29, 31, 31), + plot_legend_bg: Color32::from_rgba_premultiplied(20, 17, 35, 230), + plot_floor: Color32::from_rgba_premultiplied(0, 0, 0, 0), + seq_0: Color32::from_rgb(18, 58, 115), + seq_1: Color32::from_rgb(52, 80, 127), + seq_2: Color32::from_rgb(94, 102, 118), + seq_3: Color32::from_rgb(131, 127, 119), + seq_4: Color32::from_rgb(169, 158, 114), + seq_5: Color32::from_rgb(209, 189, 94), + seq_6: Color32::from_rgb(253, 231, 74), + on_seq_0: Color32::from_rgb(243, 240, 255), + on_seq_1: Color32::from_rgb(243, 240, 255), + on_seq_2: Color32::from_rgb(243, 240, 255), + on_seq_3: Color32::from_rgb(243, 240, 255), + on_seq_4: Color32::from_rgb(243, 240, 255), + on_seq_5: Color32::from_rgb(243, 240, 255), + on_seq_6: Color32::from_rgb(243, 240, 255), + div_0: Color32::from_rgb(74, 163, 239), + div_1: Color32::from_rgb(38, 90, 140), + div_2: Color32::from_rgb(51, 49, 63), + div_3: Color32::from_rgb(138, 92, 20), + div_4: Color32::from_rgb(230, 159, 0), + on_div_0: Color32::from_rgb(243, 240, 255), + on_div_1: Color32::from_rgb(243, 240, 255), + on_div_2: Color32::from_rgb(243, 240, 255), + on_div_3: Color32::from_rgb(243, 240, 255), + on_div_4: Color32::from_rgb(243, 240, 255), + cell_empty: Color32::from_rgb(22, 19, 39), + cell_plate: Color32::from_rgba_premultiplied(11, 10, 19, 224), + fx: Effects { glow_blur: 16.0, glow_alpha: 0.35, bloom: 0.0, redline_gradient: true, plot_floor: false }, +}; + +pub const HC: Tokens = Tokens { + bg_void: Color32::from_rgb(0, 0, 0), + bg_base: Color32::from_rgb(0, 0, 0), + bg_panel: Color32::from_rgb(11, 11, 15), + bg_raised: Color32::from_rgb(18, 18, 24), + bg_field: Color32::from_rgb(0, 0, 0), + bg_hover: Color32::from_rgb(36, 36, 44), + bg_active: Color32::from_rgb(51, 51, 61), + line_subtle: Color32::from_rgb(92, 92, 102), + line: Color32::from_rgb(154, 154, 166), + line_control: Color32::from_rgb(208, 208, 218), + ink: Color32::from_rgb(255, 255, 255), + ink_muted: Color32::from_rgb(230, 227, 242), + ink_subtle: Color32::from_rgb(210, 206, 226), + ink_disabled: Color32::from_rgb(138, 134, 150), + cyan: Color32::from_rgb(61, 232, 255), + cyan_soft: Color32::from_rgb(0, 50, 61), + on_cyan: Color32::from_rgb(0, 0, 0), + magenta: Color32::from_rgb(255, 133, 198), + magenta_soft: Color32::from_rgb(61, 10, 40), + on_magenta: Color32::from_rgb(0, 0, 0), + violet: Color32::from_rgb(196, 176, 255), + orange: Color32::from_rgb(255, 172, 112), + gold: Color32::from_rgb(255, 224, 102), + redline_0: Color32::from_rgb(61, 232, 255), + redline_1: Color32::from_rgb(196, 176, 255), + redline_2: Color32::from_rgb(255, 133, 198), + redline_3: Color32::from_rgb(255, 172, 112), + redline_4: Color32::from_rgb(255, 224, 102), + grid_floor: Color32::from_rgb(92, 92, 102), + success: Color32::from_rgb(124, 200, 255), + success_soft: Color32::from_rgb(0, 42, 69), + warning: Color32::from_rgb(255, 241, 118), + warning_soft: Color32::from_rgb(46, 41, 0), + danger: Color32::from_rgb(255, 162, 94), + danger_soft: Color32::from_rgb(58, 26, 0), + info: Color32::from_rgb(224, 214, 255), + series_1: Color32::from_rgb(124, 200, 255), + series_2: Color32::from_rgb(255, 179, 26), + series_3: Color32::from_rgb(61, 219, 168), + series_4: Color32::from_rgb(255, 241, 118), + series_5: Color32::from_rgb(90, 166, 255), + series_6: Color32::from_rgb(255, 138, 74), + series_7: Color32::from_rgb(240, 160, 208), + series_8: Color32::from_rgb(196, 196, 208), + series_9: Color32::from_rgb(124, 200, 255), + series_10: Color32::from_rgb(255, 179, 26), + plot_bg: Color32::from_rgb(0, 0, 0), + plot_grid: Color32::from_rgb(26, 26, 32), + plot_grid_major: Color32::from_rgb(74, 74, 85), + plot_axis: Color32::from_rgb(210, 206, 226), + plot_cursor: Color32::from_rgb(255, 255, 255), + plot_selection: Color32::from_rgba_premultiplied(38, 38, 38, 38), + plot_legend_bg: Color32::from_rgba_premultiplied(0, 0, 0, 242), + plot_floor: Color32::from_rgba_premultiplied(0, 0, 0, 0), + seq_0: Color32::from_rgb(18, 58, 115), + seq_1: Color32::from_rgb(52, 80, 127), + seq_2: Color32::from_rgb(94, 102, 118), + seq_3: Color32::from_rgb(131, 127, 119), + seq_4: Color32::from_rgb(169, 158, 114), + seq_5: Color32::from_rgb(209, 189, 94), + seq_6: Color32::from_rgb(253, 231, 74), + on_seq_0: Color32::from_rgb(255, 255, 255), + on_seq_1: Color32::from_rgb(255, 255, 255), + on_seq_2: Color32::from_rgb(255, 255, 255), + on_seq_3: Color32::from_rgb(255, 255, 255), + on_seq_4: Color32::from_rgb(255, 255, 255), + on_seq_5: Color32::from_rgb(255, 255, 255), + on_seq_6: Color32::from_rgb(255, 255, 255), + div_0: Color32::from_rgb(74, 163, 239), + div_1: Color32::from_rgb(38, 90, 140), + div_2: Color32::from_rgb(44, 44, 52), + div_3: Color32::from_rgb(138, 92, 20), + div_4: Color32::from_rgb(230, 159, 0), + on_div_0: Color32::from_rgb(255, 255, 255), + on_div_1: Color32::from_rgb(255, 255, 255), + on_div_2: Color32::from_rgb(255, 255, 255), + on_div_3: Color32::from_rgb(255, 255, 255), + on_div_4: Color32::from_rgb(255, 255, 255), + cell_empty: Color32::from_rgb(11, 11, 15), + cell_plate: Color32::from_rgba_premultiplied(0, 0, 0, 235), + fx: Effects { glow_blur: 0.0, glow_alpha: 0.0, bloom: 0.0, redline_gradient: false, plot_floor: false }, +}; + +pub const PAPER: Tokens = Tokens { + bg_void: Color32::from_rgb(255, 255, 255), + bg_base: Color32::from_rgb(255, 255, 255), + bg_panel: Color32::from_rgb(245, 244, 249), + bg_raised: Color32::from_rgb(255, 255, 255), + bg_field: Color32::from_rgb(255, 255, 255), + bg_hover: Color32::from_rgb(236, 235, 243), + bg_active: Color32::from_rgb(226, 223, 238), + line_subtle: Color32::from_rgb(236, 235, 242), + line: Color32::from_rgb(214, 211, 226), + line_control: Color32::from_rgb(134, 128, 153), + ink: Color32::from_rgb(21, 18, 31), + ink_muted: Color32::from_rgb(71, 66, 89), + ink_subtle: Color32::from_rgb(95, 89, 117), + ink_disabled: Color32::from_rgb(172, 167, 189), + cyan: Color32::from_rgb(0, 107, 128), + cyan_soft: Color32::from_rgb(225, 246, 250), + on_cyan: Color32::from_rgb(255, 255, 255), + magenta: Color32::from_rgb(184, 19, 106), + magenta_soft: Color32::from_rgb(251, 230, 241), + on_magenta: Color32::from_rgb(255, 255, 255), + violet: Color32::from_rgb(95, 69, 201), + orange: Color32::from_rgb(181, 83, 15), + gold: Color32::from_rgb(140, 100, 0), + redline_0: Color32::from_rgb(0, 107, 128), + redline_1: Color32::from_rgb(95, 69, 201), + redline_2: Color32::from_rgb(184, 19, 106), + redline_3: Color32::from_rgb(181, 83, 15), + redline_4: Color32::from_rgb(140, 100, 0), + grid_floor: Color32::from_rgb(214, 211, 226), + success: Color32::from_rgb(11, 106, 168), + success_soft: Color32::from_rgb(227, 241, 251), + warning: Color32::from_rgb(115, 92, 0), + warning_soft: Color32::from_rgb(251, 244, 214), + danger: Color32::from_rgb(176, 63, 0), + danger_soft: Color32::from_rgb(253, 235, 224), + info: Color32::from_rgb(95, 69, 201), + series_1: Color32::from_rgb(42, 131, 189), + series_2: Color32::from_rgb(181, 111, 0), + series_3: Color32::from_rgb(0, 132, 95), + series_4: Color32::from_rgb(133, 120, 0), + series_5: Color32::from_rgb(0, 96, 158), + series_6: Color32::from_rgb(192, 78, 11), + series_7: Color32::from_rgb(168, 72, 127), + series_8: Color32::from_rgb(95, 95, 110), + series_9: Color32::from_rgb(42, 131, 189), + series_10: Color32::from_rgb(181, 111, 0), + plot_bg: Color32::from_rgb(255, 255, 255), + plot_grid: Color32::from_rgb(240, 239, 245), + plot_grid_major: Color32::from_rgb(217, 214, 227), + plot_axis: Color32::from_rgb(95, 89, 117), + plot_cursor: Color32::from_rgb(21, 18, 31), + plot_selection: Color32::from_rgba_premultiplied(2, 1, 2, 20), + plot_legend_bg: Color32::from_rgba_premultiplied(240, 240, 240, 240), + plot_floor: Color32::from_rgba_premultiplied(0, 0, 0, 0), + seq_0: Color32::from_rgb(251, 239, 176), + seq_1: Color32::from_rgb(227, 207, 110), + seq_2: Color32::from_rgb(185, 168, 95), + seq_3: Color32::from_rgb(115, 110, 93), + seq_4: Color32::from_rgb(94, 102, 118), + seq_5: Color32::from_rgb(52, 80, 127), + seq_6: Color32::from_rgb(18, 58, 115), + on_seq_0: Color32::from_rgb(21, 18, 31), + on_seq_1: Color32::from_rgb(21, 18, 31), + on_seq_2: Color32::from_rgb(21, 18, 31), + on_seq_3: Color32::from_rgb(255, 255, 255), + on_seq_4: Color32::from_rgb(255, 255, 255), + on_seq_5: Color32::from_rgb(255, 255, 255), + on_seq_6: Color32::from_rgb(255, 255, 255), + div_0: Color32::from_rgb(31, 99, 168), + div_1: Color32::from_rgb(156, 195, 234), + div_2: Color32::from_rgb(241, 240, 245), + div_3: Color32::from_rgb(240, 194, 122), + div_4: Color32::from_rgb(168, 90, 0), + on_div_0: Color32::from_rgb(255, 255, 255), + on_div_1: Color32::from_rgb(21, 18, 31), + on_div_2: Color32::from_rgb(21, 18, 31), + on_div_3: Color32::from_rgb(21, 18, 31), + on_div_4: Color32::from_rgb(255, 255, 255), + cell_empty: Color32::from_rgb(233, 231, 240), + cell_plate: Color32::from_rgba_premultiplied(0, 0, 0, 0), + fx: Effects { glow_blur: 0.0, glow_alpha: 0.0, bloom: 0.0, redline_gradient: true, plot_floor: false }, +}; + +pub const GRAPHITE: Tokens = Tokens { + bg_void: Color32::from_rgb(36, 34, 44), + bg_base: Color32::from_rgb(42, 40, 51), + bg_panel: Color32::from_rgb(48, 46, 58), + bg_raised: Color32::from_rgb(54, 52, 65), + bg_field: Color32::from_rgb(36, 34, 44), + bg_hover: Color32::from_rgb(61, 58, 73), + bg_active: Color32::from_rgb(69, 66, 90), + line_subtle: Color32::from_rgb(58, 56, 70), + line: Color32::from_rgb(74, 71, 88), + line_control: Color32::from_rgb(143, 139, 166), + ink: Color32::from_rgb(246, 244, 255), + ink_muted: Color32::from_rgb(207, 202, 226), + ink_subtle: Color32::from_rgb(178, 173, 200), + ink_disabled: Color32::from_rgb(108, 104, 128), + cyan: Color32::from_rgb(45, 226, 255), + cyan_soft: Color32::from_rgb(13, 47, 59), + on_cyan: Color32::from_rgb(4, 19, 26), + magenta: Color32::from_rgb(255, 148, 201), + magenta_soft: Color32::from_rgb(58, 16, 48), + on_magenta: Color32::from_rgb(26, 4, 16), + violet: Color32::from_rgb(166, 138, 255), + orange: Color32::from_rgb(255, 140, 66), + gold: Color32::from_rgb(255, 210, 63), + redline_0: Color32::from_rgb(45, 226, 255), + redline_1: Color32::from_rgb(166, 138, 255), + redline_2: Color32::from_rgb(255, 148, 201), + redline_3: Color32::from_rgb(255, 140, 66), + redline_4: Color32::from_rgb(255, 210, 63), + grid_floor: Color32::from_rgb(74, 71, 88), + success: Color32::from_rgb(124, 200, 255), + success_soft: Color32::from_rgb(29, 52, 70), + warning: Color32::from_rgb(245, 232, 90), + warning_soft: Color32::from_rgb(59, 56, 32), + danger: Color32::from_rgb(255, 162, 94), + danger_soft: Color32::from_rgb(69, 48, 31), + info: Color32::from_rgb(212, 201, 255), + series_1: Color32::from_rgb(86, 180, 233), + series_2: Color32::from_rgb(230, 159, 0), + series_3: Color32::from_rgb(31, 191, 143), + series_4: Color32::from_rgb(240, 228, 66), + series_5: Color32::from_rgb(47, 134, 208), + series_6: Color32::from_rgb(232, 105, 42), + series_7: Color32::from_rgb(217, 133, 184), + series_8: Color32::from_rgb(143, 138, 158), + series_9: Color32::from_rgb(86, 180, 233), + series_10: Color32::from_rgb(230, 159, 0), + plot_bg: Color32::from_rgb(36, 34, 44), + plot_grid: Color32::from_rgb(47, 45, 56), + plot_grid_major: Color32::from_rgb(69, 66, 90), + plot_axis: Color32::from_rgb(178, 173, 200), + plot_cursor: Color32::from_rgb(246, 244, 255), + plot_selection: Color32::from_rgba_premultiplied(30, 29, 31, 31), + plot_legend_bg: Color32::from_rgba_premultiplied(45, 43, 55, 240), + plot_floor: Color32::from_rgba_premultiplied(0, 0, 0, 0), + seq_0: Color32::from_rgb(18, 58, 115), + seq_1: Color32::from_rgb(52, 80, 127), + seq_2: Color32::from_rgb(94, 102, 118), + seq_3: Color32::from_rgb(131, 127, 119), + seq_4: Color32::from_rgb(169, 158, 114), + seq_5: Color32::from_rgb(209, 189, 94), + seq_6: Color32::from_rgb(253, 231, 74), + on_seq_0: Color32::from_rgb(246, 244, 255), + on_seq_1: Color32::from_rgb(246, 244, 255), + on_seq_2: Color32::from_rgb(246, 244, 255), + on_seq_3: Color32::from_rgb(246, 244, 255), + on_seq_4: Color32::from_rgb(246, 244, 255), + on_seq_5: Color32::from_rgb(246, 244, 255), + on_seq_6: Color32::from_rgb(246, 244, 255), + div_0: Color32::from_rgb(74, 163, 239), + div_1: Color32::from_rgb(38, 90, 140), + div_2: Color32::from_rgb(58, 56, 70), + div_3: Color32::from_rgb(138, 92, 20), + div_4: Color32::from_rgb(230, 159, 0), + on_div_0: Color32::from_rgb(246, 244, 255), + on_div_1: Color32::from_rgb(246, 244, 255), + on_div_2: Color32::from_rgb(246, 244, 255), + on_div_3: Color32::from_rgb(246, 244, 255), + on_div_4: Color32::from_rgb(246, 244, 255), + cell_empty: Color32::from_rgb(48, 46, 58), + cell_plate: Color32::from_rgba_premultiplied(32, 30, 39, 224), + fx: Effects { glow_blur: 0.0, glow_alpha: 0.0, bloom: 0.0, redline_gradient: true, plot_floor: false }, +}; + +pub mod space { + pub const SPACE_1: f32 = 4.0; + pub const SPACE_2: f32 = 8.0; + pub const SPACE_3: f32 = 12.0; + pub const SPACE_4: f32 = 16.0; + pub const SPACE_6: f32 = 24.0; + pub const SPACE_8: f32 = 32.0; + pub const SPACE_12: f32 = 48.0; +} +pub mod radius { + pub const RADIUS_XS: u8 = 2; + pub const RADIUS_SM: u8 = 4; + pub const RADIUS_MD: u8 = 6; + pub const RADIUS_LG: u8 = 10; + pub const RADIUS_PILL: u8 = 255; +} +pub mod stroke { + pub const STROKE_HAIRLINE: f32 = 1.0; + pub const STROKE_INDICATOR: f32 = 2.0; + pub const STROKE_SERIES: f32 = 1.5; + pub const STROKE_CURSOR: f32 = 1.5; +} + +/// Type scale: (family, size px, line height px). Families: "display" = Saira, "ui" = Outfit, "mono" = JetBrains Mono. +pub mod text { + pub const DISPLAY: (&str, f32, f32) = ("display", 48.0, 48.0); + pub const HEADLINE: (&str, f32, f32) = ("display", 28.0, 32.0); + pub const TITLE: (&str, f32, f32) = ("display", 18.0, 24.0); + pub const OVERLINE: (&str, f32, f32) = ("display", 11.0, 14.0); + pub const HEADING: (&str, f32, f32) = ("ui", 15.0, 20.0); + pub const BODY: (&str, f32, f32) = ("ui", 14.0, 20.0); + pub const LABEL: (&str, f32, f32) = ("ui", 13.0, 18.0); + pub const CAPTION: (&str, f32, f32) = ("ui", 12.0, 16.0); + pub const DATA_LG: (&str, f32, f32) = ("mono", 18.0, 22.0); + pub const DATA: (&str, f32, f32) = ("mono", 13.0, 18.0); + pub const DATA_SM: (&str, f32, f32) = ("mono", 11.0, 14.0); +} diff --git a/design/tokens/tokens.css b/design/tokens/tokens.css new file mode 100644 index 00000000..f2fd0a9f --- /dev/null +++ b/design/tokens/tokens.css @@ -0,0 +1,486 @@ +/* UltraLog — generated from tokens.json. Do not edit by hand. */ +@font-face { font-family: "Saira"; src: url("../fonts/woff2/saira-latin-600-normal.woff2") format("woff2"); font-weight: 600; font-style: normal; font-display: swap; } +@font-face { font-family: "Saira"; src: url("../fonts/woff2/saira-latin-700-italic.woff2") format("woff2"); font-weight: 700; font-style: italic; font-display: swap; } +@font-face { font-family: "Saira"; src: url("../fonts/woff2/saira-latin-800-italic.woff2") format("woff2"); font-weight: 800; font-style: italic; font-display: swap; } +@font-face { font-family: "Outfit"; src: url("../fonts/woff2/outfit-latin-400-normal.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; } +@font-face { font-family: "Outfit"; src: url("../fonts/woff2/outfit-latin-500-normal.woff2") format("woff2"); font-weight: 500; font-style: normal; font-display: swap; } +@font-face { font-family: "Outfit"; src: url("../fonts/woff2/outfit-latin-600-normal.woff2") format("woff2"); font-weight: 600; font-style: normal; font-display: swap; } +@font-face { font-family: "JetBrains Mono"; src: url("../fonts/woff2/jetbrains-mono-latin-400-normal.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; } +@font-face { font-family: "JetBrains Mono"; src: url("../fonts/woff2/jetbrains-mono-latin-500-normal.woff2") format("woff2"); font-weight: 500; font-style: normal; font-display: swap; } +:root, [data-theme="night"] { + --bg-void: #08070f; + --bg-base: #0f0d1a; + --bg-panel: #161327; + --bg-raised: #1e1a33; + --bg-field: #0c0b16; + --bg-hover: #272241; + --bg-active: #322b55; + --line-subtle: #231f3a; + --line: #2f2a4b; + --line-control: #6f6799; + --ink: #f3f0ff; + --ink-muted: #b3abd6; + --ink-subtle: #8f88b3; + --ink-disabled: #5a5478; + --cyan: #2de2ff; + --cyan-soft: #0d2f3b; + --on-cyan: #04131a; + --magenta: #ff5cad; + --magenta-soft: #3a1030; + --on-magenta: #1a0410; + --violet: #a68aff; + --orange: #ff8c42; + --gold: #ffd23f; + --redline-0: var(--cyan); + --redline-1: var(--violet); + --redline-2: var(--magenta); + --redline-3: var(--orange); + --redline-4: var(--gold); + --grid-floor: var(--line); + --success: #3ee08f; + --success-soft: #0f3324; + --warning: #ffc53d; + --warning-soft: #3a2c08; + --danger: #ff5c6c; + --danger-soft: #3d0f17; + --info: var(--cyan); + --series-1: #ff5cad; + --series-2: #ffd23f; + --series-3: #5ba8ff; + --series-4: #a3e65a; + --series-5: #ff8c42; + --series-6: #a68aff; + --series-7: #3ee0c0; + --series-8: #e9d8b0; + --series-9: #ff7b7b; + --series-10: #c9c4dc; + --plot-bg: var(--bg-void); + --plot-grid: #1a1730; + --plot-grid-major: #2c2747; + --plot-axis: var(--ink-subtle); + --plot-cursor: var(--cyan); + --plot-selection: #2de2ff24; + --plot-legend-bg: #161327e6; + --plot-floor: #00000000; + --seq-0: #2a1d5c; + --seq-1: #4f2183; + --seq-2: #8b2a9b; + --seq-3: #e0479a; + --seq-4: #ff6363; + --seq-5: #ff9d42; + --seq-6: #ffe25a; + --on-seq-0: var(--ink); + --on-seq-1: var(--ink); + --on-seq-2: var(--ink); + --on-seq-3: var(--bg-field); + --on-seq-4: var(--bg-field); + --on-seq-5: var(--bg-field); + --on-seq-6: var(--bg-field); + --div-0: #36c8ff; + --div-1: #1e6a8f; + --div-2: #2b2640; + --div-3: #9a4a2c; + --div-4: #ff7a3d; + --on-div-0: var(--bg-field); + --on-div-1: var(--ink); + --on-div-2: var(--ink); + --on-div-3: var(--ink); + --on-div-4: var(--bg-field); + --cell-empty: var(--bg-panel); + --cell-plate: #00000000; + --shadow-window: 0 18px 48px rgba(0,0,0,0.6), 0 2px 6px rgba(0,0,0,0.4); + --focus-ring: 0 0 0 2px #08070f, 0 0 0 4px #2de2ff; + --glow-cyan: 0 0 0 1px #2de2ff, 0 0 16px rgba(45,226,255,0.35); + --glow-magenta: 0 0 0 1px #ff5cad, 0 0 16px rgba(255,92,173,0.35); + --bloom-series: 0 0 0px; +} +[data-theme="stealth"] { + --bg-void: #0a0a0c; + --bg-base: #111113; + --bg-panel: #17171a; + --bg-raised: #1e1e22; + --bg-field: #0d0d0f; + --bg-hover: #26262b; + --bg-active: #303036; + --line-subtle: #222226; + --line: #2c2c31; + --line-control: #6c6c76; + --ink: #f2f2f5; + --ink-muted: #b4b4bd; + --ink-subtle: #8f8f99; + --ink-disabled: #56565e; + --cyan: #5cc8dc; + --cyan-soft: #132a30; + --on-cyan: #061417; + --redline-0: var(--line-control); + --redline-1: var(--line-control); + --redline-2: var(--line-control); + --redline-3: var(--line-control); + --redline-4: var(--line-control); + --grid-floor: var(--line-subtle); + --info: var(--cyan); + --plot-grid: #18181c; + --plot-grid-major: #2a2a30; + --plot-cursor: var(--cyan); + --plot-selection: #5cc8dc22; + --plot-legend-bg: #17171ae6; + --on-seq-3: var(--bg-field); + --on-seq-4: var(--bg-field); + --on-seq-5: var(--bg-field); + --on-seq-6: var(--bg-field); + --div-2: #29292f; + --on-div-0: var(--bg-field); + --on-div-4: var(--bg-field); + --cell-empty: var(--bg-panel); + --focus-ring: 0 0 0 2px #0a0a0c, 0 0 0 4px #5cc8dc; + --glow-cyan: none; + --glow-magenta: none; +} +[data-theme="ultra"] { + --bg-void: #06020e; + --bg-base: #0e0620; + --bg-panel: #160a2e; + --bg-raised: #20103d; + --bg-field: #0a0418; + --bg-hover: #2b1650; + --bg-active: #371d63; + --line-subtle: #2a1548; + --line: #3e2068; + --line-control: #8e68c8; + --ink: #fbf4ff; + --ink-muted: #cdb6ef; + --ink-subtle: #a990d6; + --ink-disabled: #5e4688; + --cyan: #3df0ff; + --cyan-soft: #0b3243; + --magenta: #ff3da5; + --violet: #b48cff; + --redline-0: var(--cyan); + --redline-1: var(--violet); + --redline-2: var(--magenta); + --redline-3: var(--orange); + --redline-4: var(--gold); + --grid-floor: #ff5cad73; + --info: var(--cyan); + --plot-grid: #1d0d36; + --plot-grid-major: #3b1a62; + --plot-cursor: var(--cyan); + --plot-selection: #3df0ff2e; + --plot-legend-bg: #160a2ee6; + --plot-floor: #ff3da538; + --on-seq-3: var(--bg-field); + --on-seq-4: var(--bg-field); + --on-seq-5: var(--bg-field); + --on-seq-6: var(--bg-field); + --div-2: #2a1846; + --on-div-0: var(--bg-field); + --on-div-4: var(--bg-field); + --cell-empty: var(--bg-panel); + --focus-ring: 0 0 0 2px #06020e, 0 0 0 4px #3df0ff, 0 0 12px rgba(61,240,255,0.5); + --glow-cyan: 0 0 0 1px #3df0ff, 0 0 22px rgba(61,240,255,0.6), 0 0 48px rgba(61,240,255,0.25); + --glow-magenta: 0 0 0 1px #ff3da5, 0 0 22px rgba(255,61,165,0.6), 0 0 48px rgba(255,61,165,0.25); + --bloom-series: 0 0 4px; +} +[data-theme="cvd"] { + --line-control: #8d86b8; + --ink-muted: #cdc7ea; + --ink-subtle: #bcb5de; + --magenta: #ff94c9; + --redline-0: var(--cyan); + --redline-1: var(--violet); + --redline-2: var(--magenta); + --redline-3: var(--orange); + --redline-4: var(--gold); + --grid-floor: var(--line); + --success: #7cc8ff; + --success-soft: #0b2840; + --warning: #f5e85a; + --warning-soft: #2f2b06; + --danger: #ffa25e; + --danger-soft: #3a1d05; + --info: #d4c9ff; + --series-1: #56b4e9; + --series-2: #e69f00; + --series-3: #1fbf8f; + --series-4: #f0e442; + --series-5: #2f86d0; + --series-6: #e8692a; + --series-7: #d985b8; + --series-8: #8f8a9e; + --series-9: var(--series-1); + --series-10: var(--series-2); + --plot-cursor: var(--ink); + --plot-selection: #f3f0ff1f; + --seq-0: #123a73; + --seq-1: #34507f; + --seq-2: #5e6676; + --seq-3: #837f77; + --seq-4: #a99e72; + --seq-5: #d1bd5e; + --seq-6: #fde74a; + --on-seq-3: var(--ink); + --on-seq-4: var(--ink); + --on-seq-5: var(--ink); + --on-seq-6: var(--ink); + --div-0: #4aa3ef; + --div-1: #265a8c; + --div-2: #33313f; + --div-3: #8a5c14; + --div-4: #e69f00; + --on-div-0: var(--ink); + --on-div-4: var(--ink); + --cell-empty: var(--bg-panel); + --cell-plate: #0c0b16e0; +} +[data-theme="hc"] { + --bg-void: #000000; + --bg-base: #000000; + --bg-panel: #0b0b0f; + --bg-raised: #121218; + --bg-field: #000000; + --bg-hover: #24242c; + --bg-active: #33333d; + --line-subtle: #5c5c66; + --line: #9a9aa6; + --line-control: #d0d0da; + --ink: #ffffff; + --ink-muted: #e6e3f2; + --ink-subtle: #d2cee2; + --ink-disabled: #8a8696; + --cyan: #3de8ff; + --cyan-soft: #00323d; + --on-cyan: #000000; + --magenta: #ff85c6; + --magenta-soft: #3d0a28; + --on-magenta: #000000; + --violet: #c4b0ff; + --orange: #ffac70; + --gold: #ffe066; + --redline-0: var(--cyan); + --redline-1: var(--violet); + --redline-2: var(--magenta); + --redline-3: var(--orange); + --redline-4: var(--gold); + --grid-floor: var(--line-subtle); + --success: #7cc8ff; + --success-soft: #002a45; + --warning: #fff176; + --warning-soft: #2e2900; + --danger: #ffa25e; + --danger-soft: #3a1a00; + --info: #e0d6ff; + --series-1: #7cc8ff; + --series-2: #ffb31a; + --series-3: #3ddba8; + --series-4: #fff176; + --series-5: #5aa6ff; + --series-6: #ff8a4a; + --series-7: #f0a0d0; + --series-8: #c4c4d0; + --series-9: var(--series-1); + --series-10: var(--series-2); + --plot-grid: #1a1a20; + --plot-grid-major: #4a4a55; + --plot-cursor: var(--ink); + --plot-selection: #ffffff26; + --plot-legend-bg: #000000f2; + --seq-0: #123a73; + --seq-1: #34507f; + --seq-2: #5e6676; + --seq-3: #837f77; + --seq-4: #a99e72; + --seq-5: #d1bd5e; + --seq-6: #fde74a; + --on-seq-3: var(--ink); + --on-seq-4: var(--ink); + --on-seq-5: var(--ink); + --on-seq-6: var(--ink); + --div-0: #4aa3ef; + --div-1: #265a8c; + --div-2: #2c2c34; + --div-3: #8a5c14; + --div-4: #e69f00; + --on-div-0: var(--ink); + --on-div-4: var(--ink); + --cell-empty: var(--bg-panel); + --cell-plate: #000000eb; + --focus-ring: 0 0 0 2px #000000, 0 0 0 5px #3de8ff; + --glow-cyan: none; + --glow-magenta: none; +} +[data-theme="paper"] { + --bg-void: #ffffff; + --bg-base: #ffffff; + --bg-panel: #f5f4f9; + --bg-raised: #ffffff; + --bg-field: #ffffff; + --bg-hover: #ecebf3; + --bg-active: #e2dfee; + --line-subtle: #ecebf2; + --line: #d6d3e2; + --line-control: #868099; + --ink: #15121f; + --ink-muted: #474259; + --ink-subtle: #5f5975; + --ink-disabled: #aca7bd; + --cyan: #006b80; + --cyan-soft: #e1f6fa; + --on-cyan: #ffffff; + --magenta: #b8136a; + --magenta-soft: #fbe6f1; + --on-magenta: #ffffff; + --violet: #5f45c9; + --orange: #b5530f; + --gold: #8c6400; + --redline-0: var(--cyan); + --redline-1: var(--violet); + --redline-2: var(--magenta); + --redline-3: var(--orange); + --redline-4: var(--gold); + --grid-floor: var(--line); + --success: #0b6aa8; + --success-soft: #e3f1fb; + --warning: #735c00; + --warning-soft: #fbf4d6; + --danger: #b03f00; + --danger-soft: #fdebe0; + --info: var(--violet); + --series-1: #2a83bd; + --series-2: #b56f00; + --series-3: #00845f; + --series-4: #857800; + --series-5: #00609e; + --series-6: #c04e0b; + --series-7: #a8487f; + --series-8: #5f5f6e; + --series-9: var(--series-1); + --series-10: var(--series-2); + --plot-grid: #f0eff5; + --plot-grid-major: #d9d6e3; + --plot-cursor: var(--ink); + --plot-selection: #15121f14; + --plot-legend-bg: #fffffff0; + --seq-0: #fbefb0; + --seq-1: #e3cf6e; + --seq-2: #b9a85f; + --seq-3: #736e5d; + --seq-4: #5e6676; + --seq-5: #34507f; + --seq-6: #123a73; + --on-seq-3: #ffffff; + --on-seq-4: #ffffff; + --on-seq-5: #ffffff; + --on-seq-6: #ffffff; + --div-0: #1f63a8; + --div-1: #9cc3ea; + --div-2: #f1f0f5; + --div-3: #f0c27a; + --div-4: #a85a00; + --on-div-0: #ffffff; + --on-div-4: #ffffff; + --cell-empty: #e9e7f0; + --shadow-window: 0 8px 24px rgba(21,18,31,0.12), 0 1px 3px rgba(21,18,31,0.12); + --focus-ring: 0 0 0 2px #ffffff, 0 0 0 4px #006b80; + --glow-cyan: none; + --glow-magenta: none; +} +[data-theme="graphite"] { + --bg-void: #24222c; + --bg-base: #2a2833; + --bg-panel: #302e3a; + --bg-raised: #363441; + --bg-field: #24222c; + --bg-hover: #3d3a49; + --bg-active: #45425a; + --line-subtle: #3a3846; + --line: #4a4758; + --line-control: #8f8ba6; + --ink: #f6f4ff; + --ink-muted: #cfcae2; + --ink-subtle: #b2adc8; + --ink-disabled: #6c6880; + --magenta: #ff94c9; + --redline-0: var(--cyan); + --redline-1: var(--violet); + --redline-2: var(--magenta); + --redline-3: var(--orange); + --redline-4: var(--gold); + --grid-floor: var(--line); + --success: #7cc8ff; + --success-soft: #1d3446; + --warning: #f5e85a; + --warning-soft: #3b3820; + --danger: #ffa25e; + --danger-soft: #45301f; + --info: #d4c9ff; + --series-1: #56b4e9; + --series-2: #e69f00; + --series-3: #1fbf8f; + --series-4: #f0e442; + --series-5: #2f86d0; + --series-6: #e8692a; + --series-7: #d985b8; + --series-8: #8f8a9e; + --series-9: var(--series-1); + --series-10: var(--series-2); + --plot-grid: #2f2d38; + --plot-grid-major: #45425a; + --plot-cursor: var(--ink); + --plot-selection: #f3f0ff1f; + --plot-legend-bg: #302e3af0; + --seq-0: #123a73; + --seq-1: #34507f; + --seq-2: #5e6676; + --seq-3: #837f77; + --seq-4: #a99e72; + --seq-5: #d1bd5e; + --seq-6: #fde74a; + --on-seq-3: var(--ink); + --on-seq-4: var(--ink); + --on-seq-5: var(--ink); + --on-seq-6: var(--ink); + --div-0: #4aa3ef; + --div-1: #265a8c; + --div-2: #3a3846; + --div-3: #8a5c14; + --div-4: #e69f00; + --on-div-0: var(--ink); + --on-div-4: var(--ink); + --cell-empty: var(--bg-panel); + --cell-plate: #24222ce0; + --shadow-window: 0 12px 32px rgba(0,0,0,0.35); + --glow-cyan: none; + --glow-magenta: none; +} +:root { + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --space-8: 32px; + --space-12: 48px; + --radius-xs: 2px; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 10px; + --radius-pill: 999px; + --stroke-hairline: 1px; + --stroke-indicator: 2px; + --stroke-series: 1.5px; + --stroke-cursor: 1.5px; + --font-display: Saira, "Arial Narrow", system-ui, sans-serif; + --font-ui: Outfit, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} +.display { font-family: var(--font-display); font-size: 48px; line-height: 48px; font-weight: 800; font-style: italic; letter-spacing: -0.01em; } +.headline { font-family: var(--font-display); font-size: 28px; line-height: 32px; font-weight: 700; font-style: italic; } +.title { font-family: var(--font-display); font-size: 18px; line-height: 24px; font-weight: 600; } +.overline { font-family: var(--font-display); font-size: 11px; line-height: 14px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; } +.heading { font-family: var(--font-ui); font-size: 15px; line-height: 20px; font-weight: 600; } +.body { font-family: var(--font-ui); font-size: 14px; line-height: 20px; font-weight: 400; } +.label { font-family: var(--font-ui); font-size: 13px; line-height: 18px; font-weight: 500; } +.caption { font-family: var(--font-ui); font-size: 12px; line-height: 16px; font-weight: 400; } +.data-lg { font-family: var(--font-mono); font-size: 18px; line-height: 22px; font-weight: 500; font-variant-numeric: tabular-nums; } +.data { font-family: var(--font-mono); font-size: 13px; line-height: 18px; font-weight: 400; font-variant-numeric: tabular-nums; } +.data-sm { font-family: var(--font-mono); font-size: 11px; line-height: 14px; font-weight: 400; font-variant-numeric: tabular-nums; } diff --git a/design/tokens/tokens.json b/design/tokens/tokens.json new file mode 100644 index 00000000..ac14f40d --- /dev/null +++ b/design/tokens/tokens.json @@ -0,0 +1,1184 @@ +{ + "name": "UltraLog", + "version": 1, + "color": { + "themes": [ + { + "id": "night", + "name": "Night Drive" + }, + { + "id": "stealth", + "name": "Stealth" + }, + { + "id": "ultra", + "name": "Ultra" + }, + { + "id": "cvd", + "name": "Color-blind safe" + }, + { + "id": "hc", + "name": "High contrast" + }, + { + "id": "paper", + "name": "Export · Paper" + }, + { + "id": "graphite", + "name": "Export · Graphite" + } + ], + "tokens": [ + { + "name": "bg-void", + "value": { + "night": "#08070f", + "stealth": "#0a0a0c", + "ultra": "#06020e", + "hc": "#000000", + "paper": "#ffffff", + "graphite": "#24222c" + }, + "usage": "The darkest ground: plot canvases, the title/menu bar, heatmap gutters. Data reads best on it." + }, + { + "name": "bg-base", + "value": { + "night": "#0f0d1a", + "stealth": "#111113", + "ultra": "#0e0620", + "hc": "#000000", + "paper": "#ffffff", + "graphite": "#2a2833" + }, + "usage": "Main content area behind plots, tables and the Selected Channels strip." + }, + { + "name": "bg-panel", + "value": { + "night": "#161327", + "stealth": "#17171a", + "ultra": "#160a2e", + "hc": "#0b0b0f", + "paper": "#f5f4f9", + "graphite": "#302e3a" + }, + "usage": "Sidebars (Files, Properties, Tools, Settings), the activity rail, mode nav and playback bar." + }, + { + "name": "bg-raised", + "value": { + "night": "#1e1a33", + "stealth": "#1e1e22", + "ultra": "#20103d", + "hc": "#121218", + "paper": "#ffffff", + "graphite": "#363441" + }, + "usage": "Channel cards, floating windows (Analysis Tools, Computed Channels), menus and popovers." + }, + { + "name": "bg-field", + "value": { + "night": "#0c0b16", + "stealth": "#0d0d0f", + "ultra": "#0a0418", + "hc": "#000000", + "paper": "#ffffff", + "graphite": "#24222c" + }, + "usage": "Input wells: text fields, selects, number fields, the formula editor, search." + }, + { + "name": "bg-hover", + "value": { + "night": "#272241", + "stealth": "#26262b", + "ultra": "#2b1650", + "hc": "#24242c", + "paper": "#ecebf3", + "graphite": "#3d3a49" + }, + "usage": "Hover fill for list rows, tabs, icon buttons and secondary buttons." + }, + { + "name": "bg-active", + "value": { + "night": "#322b55", + "stealth": "#303036", + "ultra": "#371d63", + "hc": "#33333d", + "paper": "#e2dfee", + "graphite": "#45425a" + }, + "usage": "Selected neutral row (a checked channel in the list), pressed secondary button." + }, + { + "name": "line-subtle", + "value": { + "night": "#231f3a", + "stealth": "#222226", + "ultra": "#2a1548", + "hc": "#5c5c66", + "paper": "#ecebf2", + "graphite": "#3a3846" + }, + "usage": "Hairline dividers between sections inside a panel. Decorative only, never the only edge of a control." + }, + { + "name": "line", + "value": { + "night": "#2f2a4b", + "stealth": "#2c2c31", + "ultra": "#3e2068", + "hc": "#9a9aa6", + "paper": "#d6d3e2", + "graphite": "#4a4758" + }, + "usage": "Borders of panels, cards, windows and file tabs." + }, + { + "name": "line-control", + "value": { + "night": "#6f6799", + "stealth": "#6c6c76", + "ultra": "#8e68c8", + "cvd": "#8d86b8", + "hc": "#d0d0da", + "paper": "#868099", + "graphite": "#8f8ba6" + }, + "usage": "Borders of inputs, checkboxes, segmented controls and secondary buttons. 3:1+ on bg-panel, bg-raised and bg-field in every theme." + }, + { + "name": "ink", + "value": { + "night": "#f3f0ff", + "stealth": "#f2f2f5", + "ultra": "#fbf4ff", + "hc": "#ffffff", + "paper": "#15121f", + "graphite": "#f6f4ff" + }, + "usage": "Primary text and icons on every bg-* token (15:1+; 7:1+ in Color-blind safe and High contrast)." + }, + { + "name": "ink-muted", + "value": { + "night": "#b3abd6", + "stealth": "#b4b4bd", + "ultra": "#cdb6ef", + "cvd": "#cdc7ea", + "hc": "#e6e3f2", + "paper": "#474259", + "graphite": "#cfcae2" + }, + "usage": "Labels, secondary text, inactive tabs, units. 7:1+ on every bg-* in every app theme." + }, + { + "name": "ink-subtle", + "value": { + "night": "#8f88b3", + "stealth": "#8f8f99", + "ultra": "#a990d6", + "cvd": "#bcb5de", + "hc": "#d2cee2", + "paper": "#5f5975", + "graphite": "#b2adc8" + }, + "usage": "Help text, axis ticks, placeholders, timestamps. 4.5:1+ on every bg-*; raised to 7:1+ in Color-blind safe and High contrast." + }, + { + "name": "ink-disabled", + "value": { + "night": "#5a5478", + "stealth": "#56565e", + "ultra": "#5e4688", + "hc": "#8a8696", + "paper": "#aca7bd", + "graphite": "#6c6880" + }, + "usage": "Disabled labels and icons only (exempt from contrast); never for information." + }, + { + "name": "cyan", + "value": { + "night": "#2de2ff", + "stealth": "#5cc8dc", + "ultra": "#3df0ff", + "hc": "#3de8ff", + "paper": "#006b80" + }, + "usage": "The primary accent: primary buttons, active tab and rail item, focus ring, the playhead cursor, links, selected heatmap cell. Stealth tones it down; Ultra pushes it hotter. As text on every bg-* and cyan-soft." + }, + { + "name": "cyan-soft", + "value": { + "night": "#0d2f3b", + "stealth": "#132a30", + "ultra": "#0b3243", + "hc": "#00323d", + "paper": "#e1f6fa" + }, + "usage": "Tinted fill behind cyan text: active segment, selected rail item, info notices." + }, + { + "name": "on-cyan", + "value": { + "night": "#04131a", + "stealth": "#061417", + "hc": "#000000", + "paper": "#ffffff" + }, + "usage": "Text and icons on a cyan fill (primary button label). 12:1." + }, + { + "name": "magenta", + "value": { + "night": "#ff5cad", + "ultra": "#ff3da5", + "cvd": "#ff94c9", + "hc": "#ff85c6", + "paper": "#b8136a", + "graphite": "#ff94c9" + }, + "usage": "Brand secondary, the 'Log' of the wordmark: computed-channel ƒ marks, brand highlights, the sunset signature. As text on every bg-* token and magenta-soft." + }, + { + "name": "magenta-soft", + "value": { + "night": "#3a1030", + "hc": "#3d0a28", + "paper": "#fbe6f1" + }, + "usage": "Tinted fill behind magenta text (a computed channel's badge)." + }, + { + "name": "on-magenta", + "value": { + "night": "#1a0410", + "hc": "#000000", + "paper": "#ffffff" + }, + "usage": "Text on a magenta fill (brand buttons on the website). 6.9:1." + }, + { + "name": "violet", + "value": { + "night": "#a68aff", + "ultra": "#b48cff", + "hc": "#c4b0ff", + "paper": "#5f45c9" + }, + "usage": "Sunset signature stop and syntax colour for functions in the formula editor. Not an interactive colour." + }, + { + "name": "orange", + "value": { + "night": "#ff8c42", + "hc": "#ffac70", + "paper": "#b5530f" + }, + "usage": "Sunset signature stop; syntax colour for time shifts (@-0.1s, [-1])." + }, + { + "name": "gold", + "value": { + "night": "#ffd23f", + "hc": "#ffe066", + "paper": "#8c6400" + }, + "usage": "Sunset signature stop; favourite stars; syntax colour for numbers in formulas." + }, + { + "name": "redline-0", + "value": { + "night": "{cyan}", + "stealth": "{line-control}" + }, + "usage": "Sunset signature stop 0 (cyan). The 2px gradient on window tops, the welcome screen and export headers. Stealth flattens every stop to line-control." + }, + { + "name": "redline-1", + "value": { + "night": "{violet}", + "stealth": "{line-control}" + }, + "usage": "Sunset signature stop 1 (violet)." + }, + { + "name": "redline-2", + "value": { + "night": "{magenta}", + "stealth": "{line-control}" + }, + "usage": "Sunset signature stop 2 (magenta)." + }, + { + "name": "redline-3", + "value": { + "night": "{orange}", + "stealth": "{line-control}" + }, + "usage": "Sunset signature stop 3 (orange)." + }, + { + "name": "redline-4", + "value": { + "night": "{gold}", + "stealth": "{line-control}" + }, + "usage": "Sunset signature stop 4 (gold)." + }, + { + "name": "grid-floor", + "value": { + "night": "{line}", + "stealth": "{line-subtle}", + "ultra": "#ff5cad73", + "hc": "{line-subtle}" + }, + "usage": "The perspective-grid floor drawn on the drop zone, splash and website hero. Faint at Stealth, hot magenta at Ultra." + }, + { + "name": "success", + "value": { + "night": "#3ee08f", + "cvd": "#7cc8ff", + "hc": "#7cc8ff", + "paper": "#0b6aa8", + "graphite": "#7cc8ff" + }, + "usage": "Valid formula, high-confidence result, completed export. Always with a check icon or the word. Color-blind safe, High contrast and exports move it to blue, off the red-green axis." + }, + { + "name": "success-soft", + "value": { + "night": "#0f3324", + "cvd": "#0b2840", + "hc": "#002a45", + "paper": "#e3f1fb", + "graphite": "#1d3446" + }, + "usage": "Fill behind success text in notices and badges." + }, + { + "name": "warning", + "value": { + "night": "#ffc53d", + "cvd": "#f5e85a", + "hc": "#fff176", + "paper": "#735c00", + "graphite": "#f5e85a" + }, + "usage": "Missing optional channel, medium confidence, unsteady samples. Always with the triangle icon." + }, + { + "name": "warning-soft", + "value": { + "night": "#3a2c08", + "cvd": "#2f2b06", + "hc": "#2e2900", + "paper": "#fbf4d6", + "graphite": "#3b3820" + }, + "usage": "Fill behind warning text." + }, + { + "name": "danger", + "value": { + "night": "#ff5c6c", + "cvd": "#ffa25e", + "hc": "#ffa25e", + "paper": "#b03f00", + "graphite": "#ffa25e" + }, + "usage": "Invalid formula, parse failure, destructive action. Always with the octagon icon or the word. Color-blind safe, High contrast and exports move it to vermillion-orange." + }, + { + "name": "danger-soft", + "value": { + "night": "#3d0f17", + "cvd": "#3a1d05", + "hc": "#3a1a00", + "paper": "#fdebe0", + "graphite": "#45301f" + }, + "usage": "Fill behind danger text." + }, + { + "name": "info", + "value": { + "night": "{cyan}", + "cvd": "#d4c9ff", + "hc": "#e0d6ff", + "paper": "{violet}", + "graphite": "#d4c9ff" + }, + "usage": "Neutral notices and tips. Lavender/violet in Color-blind safe, High contrast and exports so it never reads as success." + }, + { + "name": "series-1", + "value": { + "night": "#ff5cad", + "cvd": "#56b4e9", + "hc": "#7cc8ff", + "paper": "#2a83bd", + "graphite": "#56b4e9" + }, + "usage": "First selected channel (line, legend swatch, channel card strip). Series colours never change with intensity. Color-blind safe, High contrast and both export themes use the Okabe-Ito set (5:1+ on plot-bg; 3:1+ on paper)." + }, + { + "name": "series-2", + "value": { + "night": "#ffd23f", + "cvd": "#e69f00", + "hc": "#ffb31a", + "paper": "#b56f00", + "graphite": "#e69f00" + }, + "usage": "Second selected channel." + }, + { + "name": "series-3", + "value": { + "night": "#5ba8ff", + "cvd": "#1fbf8f", + "hc": "#3ddba8", + "paper": "#00845f", + "graphite": "#1fbf8f" + }, + "usage": "Third selected channel." + }, + { + "name": "series-4", + "value": { + "night": "#a3e65a", + "cvd": "#f0e442", + "hc": "#fff176", + "paper": "#857800", + "graphite": "#f0e442" + }, + "usage": "Fourth selected channel." + }, + { + "name": "series-5", + "value": { + "night": "#ff8c42", + "cvd": "#2f86d0", + "hc": "#5aa6ff", + "paper": "#00609e", + "graphite": "#2f86d0" + }, + "usage": "Fifth selected channel." + }, + { + "name": "series-6", + "value": { + "night": "#a68aff", + "cvd": "#e8692a", + "hc": "#ff8a4a", + "paper": "#c04e0b", + "graphite": "#e8692a" + }, + "usage": "Sixth selected channel." + }, + { + "name": "series-7", + "value": { + "night": "#3ee0c0", + "cvd": "#d985b8", + "hc": "#f0a0d0", + "paper": "#a8487f", + "graphite": "#d985b8" + }, + "usage": "Seventh selected channel." + }, + { + "name": "series-8", + "value": { + "night": "#e9d8b0", + "cvd": "#8f8a9e", + "hc": "#c4c4d0", + "paper": "#5f5f6e", + "graphite": "#8f8a9e" + }, + "usage": "Eighth selected channel." + }, + { + "name": "series-9", + "value": { + "night": "#ff7b7b", + "cvd": "{series-1}", + "hc": "{series-1}", + "paper": "{series-1}", + "graphite": "{series-1}" + }, + "usage": "Ninth channel. In Color-blind safe, High contrast and exports it repeats series-1 and MUST be drawn dashed (6/4)." + }, + { + "name": "series-10", + "value": { + "night": "#c9c4dc", + "cvd": "{series-2}", + "hc": "{series-2}", + "paper": "{series-2}", + "graphite": "{series-2}" + }, + "usage": "Tenth channel. In Color-blind safe, High contrast and exports it repeats series-2 and MUST be drawn dashed (6/4)." + }, + { + "name": "plot-bg", + "value": "{bg-void}", + "usage": "Plot, scatter and track-map canvas." + }, + { + "name": "plot-grid", + "value": { + "night": "#1a1730", + "stealth": "#18181c", + "ultra": "#1d0d36", + "hc": "#1a1a20", + "paper": "#f0eff5", + "graphite": "#2f2d38" + }, + "usage": "Minor grid lines on plot-bg." + }, + { + "name": "plot-grid-major", + "value": { + "night": "#2c2747", + "stealth": "#2a2a30", + "ultra": "#3b1a62", + "hc": "#4a4a55", + "paper": "#d9d6e3", + "graphite": "#45425a" + }, + "usage": "Major grid lines and the plot frame on plot-bg." + }, + { + "name": "plot-axis", + "value": "{ink-subtle}", + "usage": "Tick labels and axis titles on plot-bg." + }, + { + "name": "plot-cursor", + "value": { + "night": "{cyan}", + "cvd": "{ink}", + "hc": "{ink}", + "paper": "{ink}", + "graphite": "{ink}" + }, + "usage": "The playhead line and its legend dot. Never a series colour: cyan in Night Drive, Stealth and Ultra; ink (white or black) where series-1 is blue." + }, + { + "name": "plot-selection", + "value": { + "night": "#2de2ff24", + "stealth": "#5cc8dc22", + "ultra": "#3df0ff2e", + "cvd": "#f3f0ff1f", + "hc": "#ffffff26", + "paper": "#15121f14", + "graphite": "#f3f0ff1f" + }, + "usage": "Drag-to-zoom and lap-range fill on plot-bg." + }, + { + "name": "plot-legend-bg", + "value": { + "night": "#161327e6", + "stealth": "#17171ae6", + "ultra": "#160a2ee6", + "hc": "#000000f2", + "paper": "#fffffff0", + "graphite": "#302e3af0" + }, + "usage": "Floating legend/readout box over plot-bg (bg-panel at 90%)." + }, + { + "name": "plot-floor", + "value": { + "night": "#00000000", + "ultra": "#ff3da538" + }, + "usage": "Perspective-grid floor inside plot canvases. Transparent everywhere except Ultra. Never in Color-blind safe, High contrast or exports." + }, + { + "name": "seq-0", + "value": { + "night": "#2a1d5c", + "cvd": "#123a73", + "hc": "#123a73", + "paper": "#fbefb0", + "graphite": "#123a73" + }, + "usage": "Sequential colormap stop 0 (lowest). Night Drive, Stealth and Ultra use Sunset (indigo → gold). Color-blind safe, High contrast and Graphite use a cividis-style blue → yellow; Paper reverses it so low values cost the least ink." + }, + { + "name": "seq-1", + "value": { + "night": "#4f2183", + "cvd": "#34507f", + "hc": "#34507f", + "paper": "#e3cf6e", + "graphite": "#34507f" + }, + "usage": "Sequential stop 1." + }, + { + "name": "seq-2", + "value": { + "night": "#8b2a9b", + "cvd": "#5e6676", + "hc": "#5e6676", + "paper": "#b9a85f", + "graphite": "#5e6676" + }, + "usage": "Sequential stop 2." + }, + { + "name": "seq-3", + "value": { + "night": "#e0479a", + "cvd": "#837f77", + "hc": "#837f77", + "paper": "#736e5d", + "graphite": "#837f77" + }, + "usage": "Sequential stop 3." + }, + { + "name": "seq-4", + "value": { + "night": "#ff6363", + "cvd": "#a99e72", + "hc": "#a99e72", + "paper": "#5e6676", + "graphite": "#a99e72" + }, + "usage": "Sequential stop 4." + }, + { + "name": "seq-5", + "value": { + "night": "#ff9d42", + "cvd": "#d1bd5e", + "hc": "#d1bd5e", + "paper": "#34507f", + "graphite": "#d1bd5e" + }, + "usage": "Sequential stop 5." + }, + { + "name": "seq-6", + "value": { + "night": "#ffe25a", + "cvd": "#fde74a", + "hc": "#fde74a", + "paper": "#123a73", + "graphite": "#fde74a" + }, + "usage": "Sequential stop 6 (highest)." + }, + { + "name": "on-seq-0", + "value": "{ink}", + "usage": "Value text on seq-0 (on top of cell-plate where the plate is visible). 4.5:1+, 7:1+ in Color-blind safe and High contrast." + }, + { + "name": "on-seq-1", + "value": "{ink}", + "usage": "Value text on seq-1 (on top of cell-plate where the plate is visible). 4.5:1+, 7:1+ in Color-blind safe and High contrast." + }, + { + "name": "on-seq-2", + "value": "{ink}", + "usage": "Value text on seq-2 (on top of cell-plate where the plate is visible). 4.5:1+, 7:1+ in Color-blind safe and High contrast." + }, + { + "name": "on-seq-3", + "value": { + "night": "{bg-field}", + "cvd": "{ink}", + "hc": "{ink}", + "paper": "#ffffff", + "graphite": "{ink}" + }, + "usage": "Value text on seq-3 (on top of cell-plate where the plate is visible). 4.5:1+, 7:1+ in Color-blind safe and High contrast." + }, + { + "name": "on-seq-4", + "value": { + "night": "{bg-field}", + "cvd": "{ink}", + "hc": "{ink}", + "paper": "#ffffff", + "graphite": "{ink}" + }, + "usage": "Value text on seq-4 (on top of cell-plate where the plate is visible). 4.5:1+, 7:1+ in Color-blind safe and High contrast." + }, + { + "name": "on-seq-5", + "value": { + "night": "{bg-field}", + "cvd": "{ink}", + "hc": "{ink}", + "paper": "#ffffff", + "graphite": "{ink}" + }, + "usage": "Value text on seq-5 (on top of cell-plate where the plate is visible). 4.5:1+, 7:1+ in Color-blind safe and High contrast." + }, + { + "name": "on-seq-6", + "value": { + "night": "{bg-field}", + "cvd": "{ink}", + "hc": "{ink}", + "paper": "#ffffff", + "graphite": "{ink}" + }, + "usage": "Value text on seq-6 (on top of cell-plate where the plate is visible). 4.5:1+, 7:1+ in Color-blind safe and High contrast." + }, + { + "name": "div-0", + "value": { + "night": "#36c8ff", + "cvd": "#4aa3ef", + "hc": "#4aa3ef", + "paper": "#1f63a8", + "graphite": "#4aa3ef" + }, + "usage": "Diverging map, rich end (AFR below target, negative correction). Blue in every theme." + }, + { + "name": "div-1", + "value": { + "night": "#1e6a8f", + "cvd": "#265a8c", + "hc": "#265a8c", + "paper": "#9cc3ea", + "graphite": "#265a8c" + }, + "usage": "Diverging, slightly rich." + }, + { + "name": "div-2", + "value": { + "night": "#2b2640", + "stealth": "#29292f", + "ultra": "#2a1846", + "cvd": "#33313f", + "hc": "#2c2c34", + "paper": "#f1f0f5", + "graphite": "#3a3846" + }, + "usage": "Diverging midpoint: on target / zero correction." + }, + { + "name": "div-3", + "value": { + "night": "#9a4a2c", + "cvd": "#8a5c14", + "hc": "#8a5c14", + "paper": "#f0c27a", + "graphite": "#8a5c14" + }, + "usage": "Diverging, slightly lean." + }, + { + "name": "div-4", + "value": { + "night": "#ff7a3d", + "cvd": "#e69f00", + "hc": "#e69f00", + "paper": "#a85a00", + "graphite": "#e69f00" + }, + "usage": "Diverging, lean end (AFR above target, positive correction). Orange in every theme." + }, + { + "name": "on-div-0", + "value": { + "night": "{bg-field}", + "cvd": "{ink}", + "hc": "{ink}", + "paper": "#ffffff", + "graphite": "{ink}" + }, + "usage": "Value text on div-0 (on top of cell-plate where the plate is visible)." + }, + { + "name": "on-div-1", + "value": "{ink}", + "usage": "Value text on div-1 (on top of cell-plate where the plate is visible)." + }, + { + "name": "on-div-2", + "value": "{ink}", + "usage": "Value text on div-2 (on top of cell-plate where the plate is visible)." + }, + { + "name": "on-div-3", + "value": "{ink}", + "usage": "Value text on div-3 (on top of cell-plate where the plate is visible)." + }, + { + "name": "on-div-4", + "value": { + "night": "{bg-field}", + "cvd": "{ink}", + "hc": "{ink}", + "paper": "#ffffff", + "graphite": "{ink}" + }, + "usage": "Value text on div-4 (on top of cell-plate where the plate is visible)." + }, + { + "name": "cell-empty", + "value": { + "night": "{bg-panel}", + "paper": "#e9e7f0" + }, + "usage": "Heatmap/table cells with no samples. Draw with the 45° hatch so empty never reads as a low value." + }, + { + "name": "cell-plate", + "value": { + "night": "#00000000", + "cvd": "#0c0b16e0", + "hc": "#000000eb", + "graphite": "#24222ce0" + }, + "usage": "A solid plate behind each cell value. Transparent in Night Drive, Stealth, Ultra and Paper; visible in Color-blind safe, High contrast and Graphite so every value reaches 7:1 whatever the cell colour." + } + ] + }, + "type": { + "fonts": [ + { + "family": "Saira", + "file": "fonts/saira-latin-600-normal.woff2", + "weight": "600", + "style": "normal" + }, + { + "family": "Saira", + "file": "fonts/saira-latin-700-italic.woff2", + "weight": "700", + "style": "italic" + }, + { + "family": "Saira", + "file": "fonts/saira-latin-800-italic.woff2", + "weight": "800", + "style": "italic" + }, + { + "family": "Outfit", + "file": "fonts/outfit-latin-400-normal.woff2", + "weight": "400", + "style": "normal" + }, + { + "family": "Outfit", + "file": "fonts/outfit-latin-500-normal.woff2", + "weight": "500", + "style": "normal" + }, + { + "family": "Outfit", + "file": "fonts/outfit-latin-600-normal.woff2", + "weight": "600", + "style": "normal" + }, + { + "family": "JetBrains Mono", + "file": "fonts/jetbrains-mono-latin-400-normal.woff2", + "weight": "400", + "style": "normal" + }, + { + "family": "JetBrains Mono", + "file": "fonts/jetbrains-mono-latin-500-normal.woff2", + "weight": "500", + "style": "normal" + } + ], + "families": { + "display": "Saira, \"Arial Narrow\", system-ui, sans-serif", + "ui": "Outfit, system-ui, -apple-system, \"Segoe UI\", sans-serif", + "mono": "\"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, Consolas, monospace" + }, + "groups": [ + { + "name": "Brand", + "family": "display", + "note": "Saira carries the motorsport voice: italic heavy for brand moments, upright semibold for titles and the mode nav.", + "styles": [ + { + "name": "display", + "fontSize": "48px", + "lineHeight": "48px", + "fontWeight": 800, + "fontStyle": "italic", + "letterSpacing": "-0.01em", + "sample": "Unlock your performance data", + "usage": "Website hero, splash and welcome screen only. One per view." + }, + { + "name": "headline", + "fontSize": "28px", + "lineHeight": "32px", + "fontWeight": 700, + "fontStyle": "italic", + "sample": "Lambda Delay Table", + "usage": "Website section titles, empty-state headlines, release notes." + }, + { + "name": "title", + "fontSize": "18px", + "lineHeight": "24px", + "fontWeight": 600, + "sample": "Selected Channels", + "usage": "Panel and window titles (Files, Properties, Analysis Tools)." + }, + { + "name": "overline", + "fontSize": "11px", + "lineHeight": "14px", + "fontWeight": 600, + "letterSpacing": "0.08em", + "sample": "MODE NAV · RESULTS · WITH DATA", + "usage": "Uppercase. Mode nav items, group headers in lists, table axis titles." + } + ] + }, + { + "name": "Interface", + "family": "ui", + "note": "Outfit stays the workhorse it already is in the app.", + "styles": [ + { + "name": "heading", + "fontSize": "15px", + "lineHeight": "20px", + "fontWeight": 600, + "sample": "Field Normalization", + "usage": "Collapsible section headers inside panels, card titles." + }, + { + "name": "body", + "fontSize": "14px", + "lineHeight": "20px", + "fontWeight": 400, + "sample": "Standardize channel names across ECU types.", + "usage": "Default UI text, list rows, descriptions." + }, + { + "name": "label", + "fontSize": "13px", + "lineHeight": "18px", + "fontWeight": 500, + "sample": "Engine speed", + "usage": "Form labels, button labels, tab labels, channel names." + }, + { + "name": "caption", + "fontSize": "12px", + "lineHeight": "16px", + "fontWeight": 400, + "sample": "Use accessible color palette (Wong's palette)", + "usage": "Help text under controls, metadata, notes. ink-subtle or ink-muted." + } + ] + }, + { + "name": "Data", + "family": "mono", + "note": "Every number a tuner compares is set in JetBrains Mono with tabular figures, so columns and changing readouts never jitter.", + "styles": [ + { + "name": "data-lg", + "fontSize": "18px", + "lineHeight": "22px", + "fontWeight": 500, + "sample": "6297.0 rpm", + "usage": "Channel card min/max, playback time, the big readout of a selected cell." + }, + { + "name": "data", + "fontSize": "13px", + "lineHeight": "18px", + "fontWeight": 400, + "sample": "13.40 % -36.50 kPa", + "usage": "Legend values, table cells, formulas, file stats." + }, + { + "name": "data-sm", + "fontSize": "11px", + "lineHeight": "14px", + "fontWeight": 400, + "sample": "500 1000 1500 2000", + "usage": "Axis ticks, cell event counts, supported-format lists." + } + ] + } + ] + }, + "spacing": { + "tokens": [ + { + "name": "space-1", + "value": "4px", + "usage": "Icon-to-label gap, badge padding, gap between segmented items." + }, + { + "name": "space-2", + "value": "8px", + "usage": "Gap between related controls, list-row vertical padding, input horizontal padding." + }, + { + "name": "space-3", + "value": "12px", + "usage": "Card padding, gap between channel cards." + }, + { + "name": "space-4", + "value": "16px", + "usage": "Panel padding, gap between form rows groups." + }, + { + "name": "space-6", + "value": "24px", + "usage": "Gap between panel sections, window body padding." + }, + { + "name": "space-8", + "value": "32px", + "usage": "Empty-state spacing, website block gap." + }, + { + "name": "space-12", + "value": "48px", + "usage": "Website section rhythm; activity rail width." + } + ] + }, + "radius": { + "tokens": [ + { + "name": "radius-xs", + "value": "2px", + "usage": "Heatmap cells, count badges, legend swatches." + }, + { + "name": "radius-sm", + "value": "4px", + "usage": "Inputs, checkboxes, segmented items, file tabs, chips." + }, + { + "name": "radius-md", + "value": "6px", + "usage": "Buttons, channel cards, list rows, rail buttons." + }, + { + "name": "radius-lg", + "value": "10px", + "usage": "Floating windows, drop zone, website cards." + }, + { + "name": "radius-pill", + "value": "999px", + "usage": "Slider track and thumb only." + } + ] + }, + "shadow": { + "note": "Elevation is mostly ground steps and borders. Glows and bloom are the dials the Ultra intensity setting turns.", + "tokens": [ + { + "name": "shadow-window", + "value": { + "night": "0 18px 48px rgba(0,0,0,0.6), 0 2px 6px rgba(0,0,0,0.4)", + "paper": "0 8px 24px rgba(21,18,31,0.12), 0 1px 3px rgba(21,18,31,0.12)", + "graphite": "0 12px 32px rgba(0,0,0,0.35)" + }, + "usage": "Floating windows, menus and popovers." + }, + { + "name": "focus-ring", + "value": { + "night": "0 0 0 2px #08070f, 0 0 0 4px #2de2ff", + "stealth": "0 0 0 2px #0a0a0c, 0 0 0 4px #5cc8dc", + "ultra": "0 0 0 2px #06020e, 0 0 0 4px #3df0ff, 0 0 12px rgba(61,240,255,0.5)", + "hc": "0 0 0 2px #000000, 0 0 0 5px #3de8ff", + "paper": "0 0 0 2px #ffffff, 0 0 0 4px #006b80" + }, + "usage": "Keyboard focus on every control: a ground-coloured gap then a solid cyan ring (5px in High contrast)." + }, + { + "name": "glow-cyan", + "value": { + "night": "0 0 0 1px #2de2ff, 0 0 16px rgba(45,226,255,0.35)", + "stealth": "none", + "ultra": "0 0 0 1px #3df0ff, 0 0 22px rgba(61,240,255,0.6), 0 0 48px rgba(61,240,255,0.25)", + "hc": "none", + "paper": "none", + "graphite": "none" + }, + "usage": "Hover on the primary button and the active mode-nav bar. Scales with Ultra intensity: none at Stealth, wide at Ultra." + }, + { + "name": "glow-magenta", + "value": { + "night": "0 0 0 1px #ff5cad, 0 0 16px rgba(255,92,173,0.35)", + "stealth": "none", + "ultra": "0 0 0 1px #ff3da5, 0 0 22px rgba(255,61,165,0.6), 0 0 48px rgba(255,61,165,0.25)", + "hc": "none", + "paper": "none", + "graphite": "none" + }, + "usage": "Brand moments on the website and splash. Scales with intensity." + }, + { + "name": "bloom-series", + "value": { + "night": "0 0 0px", + "ultra": "0 0 4px" + }, + "usage": "CSS drop-shadow arguments for series lines (colour = the line). 0 at Night Drive and below; 4px at Ultra. Always 0 in Color-blind safe, High contrast and exports." + } + ] + }, + "stroke": { + "note": "Line weights for chrome and data.", + "tokens": [ + { + "name": "stroke-hairline", + "value": "1px", + "usage": "Dividers, borders, grid lines." + }, + { + "name": "stroke-indicator", + "value": "2px", + "usage": "Active tab underline, rail indicator, selected cell outline, signature redline." + }, + { + "name": "stroke-series", + "value": "1.5px", + "usage": "Series lines in plots (2px for a hovered or soloed series)." + }, + { + "name": "stroke-cursor", + "value": "1.5px", + "usage": "The playhead cursor line." + } + ] + } +} \ No newline at end of file diff --git a/design/tokens/tokens.ts b/design/tokens/tokens.ts new file mode 100644 index 00000000..5a97c6f1 --- /dev/null +++ b/design/tokens/tokens.ts @@ -0,0 +1,660 @@ +// UltraLog — generated from tokens.json. Do not edit by hand. + +export const themeIds = ["night", "stealth", "ultra", "cvd", "hc", "paper", "graphite"] as const; + +export type ThemeId = typeof themeIds[number]; + +export type ColorToken = "bg-void" | "bg-base" | "bg-panel" | "bg-raised" | "bg-field" | "bg-hover" | "bg-active" | "line-subtle" | "line" | "line-control" | "ink" | "ink-muted" | "ink-subtle" | "ink-disabled" | "cyan" | "cyan-soft" | "on-cyan" | "magenta" | "magenta-soft" | "on-magenta" | "violet" | "orange" | "gold" | "redline-0" | "redline-1" | "redline-2" | "redline-3" | "redline-4" | "grid-floor" | "success" | "success-soft" | "warning" | "warning-soft" | "danger" | "danger-soft" | "info" | "series-1" | "series-2" | "series-3" | "series-4" | "series-5" | "series-6" | "series-7" | "series-8" | "series-9" | "series-10" | "plot-bg" | "plot-grid" | "plot-grid-major" | "plot-axis" | "plot-cursor" | "plot-selection" | "plot-legend-bg" | "plot-floor" | "seq-0" | "seq-1" | "seq-2" | "seq-3" | "seq-4" | "seq-5" | "seq-6" | "on-seq-0" | "on-seq-1" | "on-seq-2" | "on-seq-3" | "on-seq-4" | "on-seq-5" | "on-seq-6" | "div-0" | "div-1" | "div-2" | "div-3" | "div-4" | "on-div-0" | "on-div-1" | "on-div-2" | "on-div-3" | "on-div-4" | "cell-empty" | "cell-plate"; + +export const colors: Record> = { + "night": { + "bg-void": "#08070f", + "bg-base": "#0f0d1a", + "bg-panel": "#161327", + "bg-raised": "#1e1a33", + "bg-field": "#0c0b16", + "bg-hover": "#272241", + "bg-active": "#322b55", + "line-subtle": "#231f3a", + "line": "#2f2a4b", + "line-control": "#6f6799", + "ink": "#f3f0ff", + "ink-muted": "#b3abd6", + "ink-subtle": "#8f88b3", + "ink-disabled": "#5a5478", + "cyan": "#2de2ff", + "cyan-soft": "#0d2f3b", + "on-cyan": "#04131a", + "magenta": "#ff5cad", + "magenta-soft": "#3a1030", + "on-magenta": "#1a0410", + "violet": "#a68aff", + "orange": "#ff8c42", + "gold": "#ffd23f", + "redline-0": "#2de2ff", + "redline-1": "#a68aff", + "redline-2": "#ff5cad", + "redline-3": "#ff8c42", + "redline-4": "#ffd23f", + "grid-floor": "#2f2a4b", + "success": "#3ee08f", + "success-soft": "#0f3324", + "warning": "#ffc53d", + "warning-soft": "#3a2c08", + "danger": "#ff5c6c", + "danger-soft": "#3d0f17", + "info": "#2de2ff", + "series-1": "#ff5cad", + "series-2": "#ffd23f", + "series-3": "#5ba8ff", + "series-4": "#a3e65a", + "series-5": "#ff8c42", + "series-6": "#a68aff", + "series-7": "#3ee0c0", + "series-8": "#e9d8b0", + "series-9": "#ff7b7b", + "series-10": "#c9c4dc", + "plot-bg": "#08070f", + "plot-grid": "#1a1730", + "plot-grid-major": "#2c2747", + "plot-axis": "#8f88b3", + "plot-cursor": "#2de2ff", + "plot-selection": "#2de2ff24", + "plot-legend-bg": "#161327e6", + "plot-floor": "#00000000", + "seq-0": "#2a1d5c", + "seq-1": "#4f2183", + "seq-2": "#8b2a9b", + "seq-3": "#e0479a", + "seq-4": "#ff6363", + "seq-5": "#ff9d42", + "seq-6": "#ffe25a", + "on-seq-0": "#f3f0ff", + "on-seq-1": "#f3f0ff", + "on-seq-2": "#f3f0ff", + "on-seq-3": "#0c0b16", + "on-seq-4": "#0c0b16", + "on-seq-5": "#0c0b16", + "on-seq-6": "#0c0b16", + "div-0": "#36c8ff", + "div-1": "#1e6a8f", + "div-2": "#2b2640", + "div-3": "#9a4a2c", + "div-4": "#ff7a3d", + "on-div-0": "#0c0b16", + "on-div-1": "#f3f0ff", + "on-div-2": "#f3f0ff", + "on-div-3": "#f3f0ff", + "on-div-4": "#0c0b16", + "cell-empty": "#161327", + "cell-plate": "#00000000" + }, + "stealth": { + "bg-void": "#0a0a0c", + "bg-base": "#111113", + "bg-panel": "#17171a", + "bg-raised": "#1e1e22", + "bg-field": "#0d0d0f", + "bg-hover": "#26262b", + "bg-active": "#303036", + "line-subtle": "#222226", + "line": "#2c2c31", + "line-control": "#6c6c76", + "ink": "#f2f2f5", + "ink-muted": "#b4b4bd", + "ink-subtle": "#8f8f99", + "ink-disabled": "#56565e", + "cyan": "#5cc8dc", + "cyan-soft": "#132a30", + "on-cyan": "#061417", + "magenta": "#ff5cad", + "magenta-soft": "#3a1030", + "on-magenta": "#1a0410", + "violet": "#a68aff", + "orange": "#ff8c42", + "gold": "#ffd23f", + "redline-0": "#6c6c76", + "redline-1": "#6c6c76", + "redline-2": "#6c6c76", + "redline-3": "#6c6c76", + "redline-4": "#6c6c76", + "grid-floor": "#222226", + "success": "#3ee08f", + "success-soft": "#0f3324", + "warning": "#ffc53d", + "warning-soft": "#3a2c08", + "danger": "#ff5c6c", + "danger-soft": "#3d0f17", + "info": "#5cc8dc", + "series-1": "#ff5cad", + "series-2": "#ffd23f", + "series-3": "#5ba8ff", + "series-4": "#a3e65a", + "series-5": "#ff8c42", + "series-6": "#a68aff", + "series-7": "#3ee0c0", + "series-8": "#e9d8b0", + "series-9": "#ff7b7b", + "series-10": "#c9c4dc", + "plot-bg": "#0a0a0c", + "plot-grid": "#18181c", + "plot-grid-major": "#2a2a30", + "plot-axis": "#8f8f99", + "plot-cursor": "#5cc8dc", + "plot-selection": "#5cc8dc22", + "plot-legend-bg": "#17171ae6", + "plot-floor": "#00000000", + "seq-0": "#2a1d5c", + "seq-1": "#4f2183", + "seq-2": "#8b2a9b", + "seq-3": "#e0479a", + "seq-4": "#ff6363", + "seq-5": "#ff9d42", + "seq-6": "#ffe25a", + "on-seq-0": "#f2f2f5", + "on-seq-1": "#f2f2f5", + "on-seq-2": "#f2f2f5", + "on-seq-3": "#0d0d0f", + "on-seq-4": "#0d0d0f", + "on-seq-5": "#0d0d0f", + "on-seq-6": "#0d0d0f", + "div-0": "#36c8ff", + "div-1": "#1e6a8f", + "div-2": "#29292f", + "div-3": "#9a4a2c", + "div-4": "#ff7a3d", + "on-div-0": "#0d0d0f", + "on-div-1": "#f2f2f5", + "on-div-2": "#f2f2f5", + "on-div-3": "#f2f2f5", + "on-div-4": "#0d0d0f", + "cell-empty": "#17171a", + "cell-plate": "#00000000" + }, + "ultra": { + "bg-void": "#06020e", + "bg-base": "#0e0620", + "bg-panel": "#160a2e", + "bg-raised": "#20103d", + "bg-field": "#0a0418", + "bg-hover": "#2b1650", + "bg-active": "#371d63", + "line-subtle": "#2a1548", + "line": "#3e2068", + "line-control": "#8e68c8", + "ink": "#fbf4ff", + "ink-muted": "#cdb6ef", + "ink-subtle": "#a990d6", + "ink-disabled": "#5e4688", + "cyan": "#3df0ff", + "cyan-soft": "#0b3243", + "on-cyan": "#04131a", + "magenta": "#ff3da5", + "magenta-soft": "#3a1030", + "on-magenta": "#1a0410", + "violet": "#b48cff", + "orange": "#ff8c42", + "gold": "#ffd23f", + "redline-0": "#3df0ff", + "redline-1": "#b48cff", + "redline-2": "#ff3da5", + "redline-3": "#ff8c42", + "redline-4": "#ffd23f", + "grid-floor": "#ff5cad73", + "success": "#3ee08f", + "success-soft": "#0f3324", + "warning": "#ffc53d", + "warning-soft": "#3a2c08", + "danger": "#ff5c6c", + "danger-soft": "#3d0f17", + "info": "#3df0ff", + "series-1": "#ff5cad", + "series-2": "#ffd23f", + "series-3": "#5ba8ff", + "series-4": "#a3e65a", + "series-5": "#ff8c42", + "series-6": "#a68aff", + "series-7": "#3ee0c0", + "series-8": "#e9d8b0", + "series-9": "#ff7b7b", + "series-10": "#c9c4dc", + "plot-bg": "#06020e", + "plot-grid": "#1d0d36", + "plot-grid-major": "#3b1a62", + "plot-axis": "#a990d6", + "plot-cursor": "#3df0ff", + "plot-selection": "#3df0ff2e", + "plot-legend-bg": "#160a2ee6", + "plot-floor": "#ff3da538", + "seq-0": "#2a1d5c", + "seq-1": "#4f2183", + "seq-2": "#8b2a9b", + "seq-3": "#e0479a", + "seq-4": "#ff6363", + "seq-5": "#ff9d42", + "seq-6": "#ffe25a", + "on-seq-0": "#fbf4ff", + "on-seq-1": "#fbf4ff", + "on-seq-2": "#fbf4ff", + "on-seq-3": "#0a0418", + "on-seq-4": "#0a0418", + "on-seq-5": "#0a0418", + "on-seq-6": "#0a0418", + "div-0": "#36c8ff", + "div-1": "#1e6a8f", + "div-2": "#2a1846", + "div-3": "#9a4a2c", + "div-4": "#ff7a3d", + "on-div-0": "#0a0418", + "on-div-1": "#fbf4ff", + "on-div-2": "#fbf4ff", + "on-div-3": "#fbf4ff", + "on-div-4": "#0a0418", + "cell-empty": "#160a2e", + "cell-plate": "#00000000" + }, + "cvd": { + "bg-void": "#08070f", + "bg-base": "#0f0d1a", + "bg-panel": "#161327", + "bg-raised": "#1e1a33", + "bg-field": "#0c0b16", + "bg-hover": "#272241", + "bg-active": "#322b55", + "line-subtle": "#231f3a", + "line": "#2f2a4b", + "line-control": "#8d86b8", + "ink": "#f3f0ff", + "ink-muted": "#cdc7ea", + "ink-subtle": "#bcb5de", + "ink-disabled": "#5a5478", + "cyan": "#2de2ff", + "cyan-soft": "#0d2f3b", + "on-cyan": "#04131a", + "magenta": "#ff94c9", + "magenta-soft": "#3a1030", + "on-magenta": "#1a0410", + "violet": "#a68aff", + "orange": "#ff8c42", + "gold": "#ffd23f", + "redline-0": "#2de2ff", + "redline-1": "#a68aff", + "redline-2": "#ff94c9", + "redline-3": "#ff8c42", + "redline-4": "#ffd23f", + "grid-floor": "#2f2a4b", + "success": "#7cc8ff", + "success-soft": "#0b2840", + "warning": "#f5e85a", + "warning-soft": "#2f2b06", + "danger": "#ffa25e", + "danger-soft": "#3a1d05", + "info": "#d4c9ff", + "series-1": "#56b4e9", + "series-2": "#e69f00", + "series-3": "#1fbf8f", + "series-4": "#f0e442", + "series-5": "#2f86d0", + "series-6": "#e8692a", + "series-7": "#d985b8", + "series-8": "#8f8a9e", + "series-9": "#56b4e9", + "series-10": "#e69f00", + "plot-bg": "#08070f", + "plot-grid": "#1a1730", + "plot-grid-major": "#2c2747", + "plot-axis": "#bcb5de", + "plot-cursor": "#f3f0ff", + "plot-selection": "#f3f0ff1f", + "plot-legend-bg": "#161327e6", + "plot-floor": "#00000000", + "seq-0": "#123a73", + "seq-1": "#34507f", + "seq-2": "#5e6676", + "seq-3": "#837f77", + "seq-4": "#a99e72", + "seq-5": "#d1bd5e", + "seq-6": "#fde74a", + "on-seq-0": "#f3f0ff", + "on-seq-1": "#f3f0ff", + "on-seq-2": "#f3f0ff", + "on-seq-3": "#f3f0ff", + "on-seq-4": "#f3f0ff", + "on-seq-5": "#f3f0ff", + "on-seq-6": "#f3f0ff", + "div-0": "#4aa3ef", + "div-1": "#265a8c", + "div-2": "#33313f", + "div-3": "#8a5c14", + "div-4": "#e69f00", + "on-div-0": "#f3f0ff", + "on-div-1": "#f3f0ff", + "on-div-2": "#f3f0ff", + "on-div-3": "#f3f0ff", + "on-div-4": "#f3f0ff", + "cell-empty": "#161327", + "cell-plate": "#0c0b16e0" + }, + "hc": { + "bg-void": "#000000", + "bg-base": "#000000", + "bg-panel": "#0b0b0f", + "bg-raised": "#121218", + "bg-field": "#000000", + "bg-hover": "#24242c", + "bg-active": "#33333d", + "line-subtle": "#5c5c66", + "line": "#9a9aa6", + "line-control": "#d0d0da", + "ink": "#ffffff", + "ink-muted": "#e6e3f2", + "ink-subtle": "#d2cee2", + "ink-disabled": "#8a8696", + "cyan": "#3de8ff", + "cyan-soft": "#00323d", + "on-cyan": "#000000", + "magenta": "#ff85c6", + "magenta-soft": "#3d0a28", + "on-magenta": "#000000", + "violet": "#c4b0ff", + "orange": "#ffac70", + "gold": "#ffe066", + "redline-0": "#3de8ff", + "redline-1": "#c4b0ff", + "redline-2": "#ff85c6", + "redline-3": "#ffac70", + "redline-4": "#ffe066", + "grid-floor": "#5c5c66", + "success": "#7cc8ff", + "success-soft": "#002a45", + "warning": "#fff176", + "warning-soft": "#2e2900", + "danger": "#ffa25e", + "danger-soft": "#3a1a00", + "info": "#e0d6ff", + "series-1": "#7cc8ff", + "series-2": "#ffb31a", + "series-3": "#3ddba8", + "series-4": "#fff176", + "series-5": "#5aa6ff", + "series-6": "#ff8a4a", + "series-7": "#f0a0d0", + "series-8": "#c4c4d0", + "series-9": "#7cc8ff", + "series-10": "#ffb31a", + "plot-bg": "#000000", + "plot-grid": "#1a1a20", + "plot-grid-major": "#4a4a55", + "plot-axis": "#d2cee2", + "plot-cursor": "#ffffff", + "plot-selection": "#ffffff26", + "plot-legend-bg": "#000000f2", + "plot-floor": "#00000000", + "seq-0": "#123a73", + "seq-1": "#34507f", + "seq-2": "#5e6676", + "seq-3": "#837f77", + "seq-4": "#a99e72", + "seq-5": "#d1bd5e", + "seq-6": "#fde74a", + "on-seq-0": "#ffffff", + "on-seq-1": "#ffffff", + "on-seq-2": "#ffffff", + "on-seq-3": "#ffffff", + "on-seq-4": "#ffffff", + "on-seq-5": "#ffffff", + "on-seq-6": "#ffffff", + "div-0": "#4aa3ef", + "div-1": "#265a8c", + "div-2": "#2c2c34", + "div-3": "#8a5c14", + "div-4": "#e69f00", + "on-div-0": "#ffffff", + "on-div-1": "#ffffff", + "on-div-2": "#ffffff", + "on-div-3": "#ffffff", + "on-div-4": "#ffffff", + "cell-empty": "#0b0b0f", + "cell-plate": "#000000eb" + }, + "paper": { + "bg-void": "#ffffff", + "bg-base": "#ffffff", + "bg-panel": "#f5f4f9", + "bg-raised": "#ffffff", + "bg-field": "#ffffff", + "bg-hover": "#ecebf3", + "bg-active": "#e2dfee", + "line-subtle": "#ecebf2", + "line": "#d6d3e2", + "line-control": "#868099", + "ink": "#15121f", + "ink-muted": "#474259", + "ink-subtle": "#5f5975", + "ink-disabled": "#aca7bd", + "cyan": "#006b80", + "cyan-soft": "#e1f6fa", + "on-cyan": "#ffffff", + "magenta": "#b8136a", + "magenta-soft": "#fbe6f1", + "on-magenta": "#ffffff", + "violet": "#5f45c9", + "orange": "#b5530f", + "gold": "#8c6400", + "redline-0": "#006b80", + "redline-1": "#5f45c9", + "redline-2": "#b8136a", + "redline-3": "#b5530f", + "redline-4": "#8c6400", + "grid-floor": "#d6d3e2", + "success": "#0b6aa8", + "success-soft": "#e3f1fb", + "warning": "#735c00", + "warning-soft": "#fbf4d6", + "danger": "#b03f00", + "danger-soft": "#fdebe0", + "info": "#5f45c9", + "series-1": "#2a83bd", + "series-2": "#b56f00", + "series-3": "#00845f", + "series-4": "#857800", + "series-5": "#00609e", + "series-6": "#c04e0b", + "series-7": "#a8487f", + "series-8": "#5f5f6e", + "series-9": "#2a83bd", + "series-10": "#b56f00", + "plot-bg": "#ffffff", + "plot-grid": "#f0eff5", + "plot-grid-major": "#d9d6e3", + "plot-axis": "#5f5975", + "plot-cursor": "#15121f", + "plot-selection": "#15121f14", + "plot-legend-bg": "#fffffff0", + "plot-floor": "#00000000", + "seq-0": "#fbefb0", + "seq-1": "#e3cf6e", + "seq-2": "#b9a85f", + "seq-3": "#736e5d", + "seq-4": "#5e6676", + "seq-5": "#34507f", + "seq-6": "#123a73", + "on-seq-0": "#15121f", + "on-seq-1": "#15121f", + "on-seq-2": "#15121f", + "on-seq-3": "#ffffff", + "on-seq-4": "#ffffff", + "on-seq-5": "#ffffff", + "on-seq-6": "#ffffff", + "div-0": "#1f63a8", + "div-1": "#9cc3ea", + "div-2": "#f1f0f5", + "div-3": "#f0c27a", + "div-4": "#a85a00", + "on-div-0": "#ffffff", + "on-div-1": "#15121f", + "on-div-2": "#15121f", + "on-div-3": "#15121f", + "on-div-4": "#ffffff", + "cell-empty": "#e9e7f0", + "cell-plate": "#00000000" + }, + "graphite": { + "bg-void": "#24222c", + "bg-base": "#2a2833", + "bg-panel": "#302e3a", + "bg-raised": "#363441", + "bg-field": "#24222c", + "bg-hover": "#3d3a49", + "bg-active": "#45425a", + "line-subtle": "#3a3846", + "line": "#4a4758", + "line-control": "#8f8ba6", + "ink": "#f6f4ff", + "ink-muted": "#cfcae2", + "ink-subtle": "#b2adc8", + "ink-disabled": "#6c6880", + "cyan": "#2de2ff", + "cyan-soft": "#0d2f3b", + "on-cyan": "#04131a", + "magenta": "#ff94c9", + "magenta-soft": "#3a1030", + "on-magenta": "#1a0410", + "violet": "#a68aff", + "orange": "#ff8c42", + "gold": "#ffd23f", + "redline-0": "#2de2ff", + "redline-1": "#a68aff", + "redline-2": "#ff94c9", + "redline-3": "#ff8c42", + "redline-4": "#ffd23f", + "grid-floor": "#4a4758", + "success": "#7cc8ff", + "success-soft": "#1d3446", + "warning": "#f5e85a", + "warning-soft": "#3b3820", + "danger": "#ffa25e", + "danger-soft": "#45301f", + "info": "#d4c9ff", + "series-1": "#56b4e9", + "series-2": "#e69f00", + "series-3": "#1fbf8f", + "series-4": "#f0e442", + "series-5": "#2f86d0", + "series-6": "#e8692a", + "series-7": "#d985b8", + "series-8": "#8f8a9e", + "series-9": "#56b4e9", + "series-10": "#e69f00", + "plot-bg": "#24222c", + "plot-grid": "#2f2d38", + "plot-grid-major": "#45425a", + "plot-axis": "#b2adc8", + "plot-cursor": "#f6f4ff", + "plot-selection": "#f3f0ff1f", + "plot-legend-bg": "#302e3af0", + "plot-floor": "#00000000", + "seq-0": "#123a73", + "seq-1": "#34507f", + "seq-2": "#5e6676", + "seq-3": "#837f77", + "seq-4": "#a99e72", + "seq-5": "#d1bd5e", + "seq-6": "#fde74a", + "on-seq-0": "#f6f4ff", + "on-seq-1": "#f6f4ff", + "on-seq-2": "#f6f4ff", + "on-seq-3": "#f6f4ff", + "on-seq-4": "#f6f4ff", + "on-seq-5": "#f6f4ff", + "on-seq-6": "#f6f4ff", + "div-0": "#4aa3ef", + "div-1": "#265a8c", + "div-2": "#3a3846", + "div-3": "#8a5c14", + "div-4": "#e69f00", + "on-div-0": "#f6f4ff", + "on-div-1": "#f6f4ff", + "on-div-2": "#f6f4ff", + "on-div-3": "#f6f4ff", + "on-div-4": "#f6f4ff", + "cell-empty": "#302e3a", + "cell-plate": "#24222ce0" + } +}; + +export const shadows: Record> = { + "night": { + "shadow-window": "0 18px 48px rgba(0,0,0,0.6), 0 2px 6px rgba(0,0,0,0.4)", + "focus-ring": "0 0 0 2px #08070f, 0 0 0 4px #2de2ff", + "glow-cyan": "0 0 0 1px #2de2ff, 0 0 16px rgba(45,226,255,0.35)", + "glow-magenta": "0 0 0 1px #ff5cad, 0 0 16px rgba(255,92,173,0.35)", + "bloom-series": "0 0 0px" + }, + "stealth": { + "shadow-window": "0 18px 48px rgba(0,0,0,0.6), 0 2px 6px rgba(0,0,0,0.4)", + "focus-ring": "0 0 0 2px #0a0a0c, 0 0 0 4px #5cc8dc", + "glow-cyan": "none", + "glow-magenta": "none", + "bloom-series": "0 0 0px" + }, + "ultra": { + "shadow-window": "0 18px 48px rgba(0,0,0,0.6), 0 2px 6px rgba(0,0,0,0.4)", + "focus-ring": "0 0 0 2px #06020e, 0 0 0 4px #3df0ff, 0 0 12px rgba(61,240,255,0.5)", + "glow-cyan": "0 0 0 1px #3df0ff, 0 0 22px rgba(61,240,255,0.6), 0 0 48px rgba(61,240,255,0.25)", + "glow-magenta": "0 0 0 1px #ff3da5, 0 0 22px rgba(255,61,165,0.6), 0 0 48px rgba(255,61,165,0.25)", + "bloom-series": "0 0 4px" + }, + "cvd": { + "shadow-window": "0 18px 48px rgba(0,0,0,0.6), 0 2px 6px rgba(0,0,0,0.4)", + "focus-ring": "0 0 0 2px #08070f, 0 0 0 4px #2de2ff", + "glow-cyan": "0 0 0 1px #2de2ff, 0 0 16px rgba(45,226,255,0.35)", + "glow-magenta": "0 0 0 1px #ff5cad, 0 0 16px rgba(255,92,173,0.35)", + "bloom-series": "0 0 0px" + }, + "hc": { + "shadow-window": "0 18px 48px rgba(0,0,0,0.6), 0 2px 6px rgba(0,0,0,0.4)", + "focus-ring": "0 0 0 2px #000000, 0 0 0 5px #3de8ff", + "glow-cyan": "none", + "glow-magenta": "none", + "bloom-series": "0 0 0px" + }, + "paper": { + "shadow-window": "0 8px 24px rgba(21,18,31,0.12), 0 1px 3px rgba(21,18,31,0.12)", + "focus-ring": "0 0 0 2px #ffffff, 0 0 0 4px #006b80", + "glow-cyan": "none", + "glow-magenta": "none", + "bloom-series": "0 0 0px" + }, + "graphite": { + "shadow-window": "0 12px 32px rgba(0,0,0,0.35)", + "focus-ring": "0 0 0 2px #08070f, 0 0 0 4px #2de2ff", + "glow-cyan": "none", + "glow-magenta": "none", + "bloom-series": "0 0 0px" + } +}; + +export const spacing = { + "space-1": "4px", + "space-2": "8px", + "space-3": "12px", + "space-4": "16px", + "space-6": "24px", + "space-8": "32px", + "space-12": "48px" +} as const; + +export const radius = { + "radius-xs": "2px", + "radius-sm": "4px", + "radius-md": "6px", + "radius-lg": "10px", + "radius-pill": "999px" +} as const; + +export const fonts = { + "display": "Saira, \"Arial Narrow\", system-ui, sans-serif", + "ui": "Outfit, system-ui, -apple-system, \"Segoe UI\", sans-serif", + "mono": "\"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, Consolas, monospace" +} as const; diff --git a/design/tools/check_contrast.py b/design/tools/check_contrast.py new file mode 100644 index 00000000..837836e6 --- /dev/null +++ b/design/tools/check_contrast.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Verify every guaranteed colour pair in tokens.json, in every theme. + +Usage: python3 tools/check_contrast.py [path/to/tokens.json] +Exits 1 and lists failures if any pair drops below its ratio. Run it in CI +whenever tokens.json changes; fix the token, never relax the check. +""" +import json, sys + +PATH = sys.argv[1] if len(sys.argv) > 1 else 'tokens/tokens.json' +T = json.load(open(PATH)) +THEMES = [t['id'] for t in T['color']['themes']] +CT = {t['name']: t['value'] for t in T['color']['tokens']} +AAA_THEMES = {'cvd', 'hc'} + +def raw(n, th): + v = CT[n] + return v if isinstance(v, str) else v.get(th, v[THEMES[0]]) +def res(n, th): + v = raw(n, th) + while v.startswith('{'): + v = raw(v[1:-1], th) + return v +def rgba(h): + h = h.lstrip('#'); a = int(h[6:8], 16) / 255 if len(h) == 8 else 1.0 + return [int(h[i:i + 2], 16) for i in (0, 2, 4)], a +def blend(fg, bg): + (c, a), (b, _) = rgba(fg), rgba(bg) + return '#' + ''.join('%02x' % round(c[i] * a + b[i] * (1 - a)) for i in range(3)) +def lum(h): + c, _ = rgba(h) + f = lambda x: x / 12.92 if x <= .03928 else ((x + .055) / 1.055) ** 2.4 + r, g, b = [f(x / 255) for x in c] + return .2126 * r + .7152 * g + .0722 * b +def ratio(a, b): + x, y = sorted([lum(a), lum(b)], reverse=True) + return (x + .05) / (y + .05) + +GROUNDS = ['bg-void', 'bg-base', 'bg-panel', 'bg-raised', 'bg-field', 'bg-hover'] +fails = [] +def need(th, fg, bg, minimum, label): + r = ratio(fg, bg) + if r < minimum: + fails.append(f'{th:9} {label:40} {r:5.2f} < {minimum}') + +for th in THEMES: + R = lambda n: res(n, th) + text = 7 if th in AAA_THEMES else 4.5 + for ink in ['ink', 'ink-muted', 'ink-subtle']: + for g in GROUNDS: + need(th, R(ink), R(g), text, f'{ink} on {g}') + for s in ['success', 'warning', 'danger', 'info', 'cyan', 'magenta']: + soft = {'info': 'cyan-soft', 'cyan': 'cyan-soft', 'magenta': 'magenta-soft'}.get(s, s + '-soft') + for g in ['bg-panel', 'bg-raised', soft]: + need(th, R(s), R(g), text, f'{s} on {g}') + need(th, R('on-cyan'), R('cyan'), text, 'on-cyan on cyan') + for g in ['bg-panel', 'bg-raised', 'bg-field']: + need(th, R('line-control'), R(g), 3, f'line-control on {g}') + for i in range(1, 11): + need(th, R(f'series-{i}'), R('plot-bg'), 3, f'series-{i} on plot-bg') + need(th, R('plot-cursor'), R('plot-bg'), 3, 'plot-cursor on plot-bg') + plate = R('cell-plate') + for k, n in (('seq', 7), ('div', 5)): + for i in range(n): + fill = R(f'{k}-{i}') + under = blend(plate, fill) if rgba(plate)[1] > 0 else fill + need(th, R(f'on-{k}-{i}'), under, text, f'on-{k}-{i} on {k}-{i} (+plate)') + +if fails: + print('Contrast failures:'); print('\n'.join(fails)); sys.exit(1) +print(f'All pairs pass in {len(THEMES)} themes: {", ".join(THEMES)}') diff --git a/docs/plans/2026-10-23-night-drive-design-system.md b/docs/plans/2026-10-23-night-drive-design-system.md new file mode 100644 index 00000000..f4a6652a --- /dev/null +++ b/docs/plans/2026-10-23-night-drive-design-system.md @@ -0,0 +1,261 @@ +# Night Drive Design System — Desktop App Implementation Plan + +- **Date:** 2026-10-23 +- **Status:** Implemented 2026-10-24 — Phases 0.5-8 are on `release/3.0.0`. Verified against + `design/reference/` in Stealth, Night Drive, Ultra, Color blind and High contrast. +- **Branch:** `origin/release/3.0.0` (the handoff worktree's local branch is `night-drive-3.0.0`, + because `release/3.0.0` is checked out in another worktree). Ships in 3.0.0 with the six fixes + already on the branch. +- **Design source:** `design/` — brief `design/HANDOFF-APP.md`, brand book `design/design-system/README.md`, tokens `design/tokens/tokens.json` + +## Summary + +The handoff defines the Night Drive look: an indigo-black chrome, one cyan accent, a sunset +redline, three typefaces, and seven token themes composed from three settings (Ultra intensity, +Color blind mode, High contrast) plus two export themes (Paper, Graphite). The app today has +none of it: stock `Visuals::dark()`, panel fills hard-coded over it, about 450 hard-coded +colours, Turbo/jet colour maps and an olive/rust series palette. This plan maps the eight phases +of `HANDOFF-APP.md` onto real files, one commit (or a short run of commits) per phase, the app +building and running after each. + +## Phase 0 report — what exists today + +| Question | Finding | +| --- | --- | +| Versions | eframe/egui **0.36.1**, egui_plot **0.37.0**, edition 2024, MSRV 1.95 | +| Bundle runtime vs egui 0.36 | `design/rust/theme.rs` + `ultralog_theme.rs` compile **unchanged** on egui 0.36.1; the three tests pass. `clippy -D warnings` fails with 32 `excessive_precision` errors (OKLab constants), and both files fail `rustfmt --check` | +| Visuals | `cc.egui_ctx.set_visuals(egui::Visuals::dark())` in `UltraLogApp::new` (`app.rs:327`). `set_visuals` writes only the current theme slot and the app never pins `ThemePreference::Dark`, so an OS appearance change can swap in egui's light style. Central/side/activity panel frames hard-code fills over Visuals (`app.rs:2344-2365`). `menu.rs` overrides `text_styles` per menu | +| Fonts | `app.rs:295-324`: Outfit Regular as proportional default; an Outfit Bold `"Bold"` family is defined but never used. No monospace face (egui's Hack), no display face. Sizes are inline: 97 `scaled_font()` calls (the `FontScale` multiplier) feeding ~277 `FontId`/`.size()` sites | +| Non-Latin text | **No fallback fonts are loaded.** egui's defaults cover Latin, Greek and Cyrillic only, so `ja`, `zh-CN`, `hi`, `bn`, `ar`, `ur` strings render as missing-glyph boxes today. epaint 0.36 shapes with harfrust, so Devanagari/Bengali should render once a font is present; Arabic/Urdu still lack right-to-left layout | +| Settings | `UserSettings` (`settings.rs`), JSON, `version: 1`, `serde(default)` per field; `color_blind_mode: bool` exists and is toggled in `settings_panel.rs:114` and the legacy `sidebar.rs:270`. Three-place sync rule (struct, `UltraLogApp::new`, `eframe::App::save`) | +| Series colours | `CHART_COLORS` (olive/rust) and `COLORBLIND_COLORS` (Wong, slot 8 black) in `state.rs:46-72`. Read through `get_channel_color` (`app.rs:398`) and directly in `chart.rs:288`, `:659`, `export.rs:181`, `:318`, `:394`. `ipc/handler.rs:129` sends the colour as hex over MCP. New channels take the first free slot of 10 (`app.rs:1211`, `:1461`, `computed_channels_manager.rs:663`, `analysis_panel.rs:734`) — no recolouring, as the spec wants — but stacked mode allows 60 channels (`MAX_TOTAL_CHANNELS`) and everything past 10 falls back to slot 0 | +| Plot | `chart.rs`: egui_plot `Line`s, cursor `VLine` (`:309`, `:678`), grid via `grid_color_with_opacity` (`:1101`). egui_plot 0.37 has one grid colour, takes its background from `extreme_bg_color`, and its built-in `Legend` cannot show a Cursor row, live mono values or a custom fill | +| Heatmaps | Histogram: 11-stop **jet** (`histogram.rs:1728`); scatter density: jet (`scatter_plot.rs:472`); table generators: Viridis (`table_generator.rs:1028`), colour-coded confidence (`:134`); cell text via `get_aaa_text_color` (`histogram.rs:107`, also `table_generator.rs:1030`); track map: user picker **Viridis / Turbo** (`colormap.rs`, `track_map.rs:409`), its `color_cache` keyed by `ColorSignature` (`state.rs:753`), which holds the `Colormap` enum only | +| Exports | `export.rs` (chart, histogram, scatter → PNG via `image`, PDF via `printpdf` 0.12) with their own jet ramps (`:909`, `:949`); `table_export.rs` (tables, Viridis, `text_on()`). **PNG has no font rasterizer**; PDF text is built-in Helvetica (`~` becomes an arrow). Render functions are `UltraLogApp` methods; tests exist only in `table_export.rs` and assert Viridis values | +| Icons | Three hand-drawn icons in `icons.rs`; ~23 labels use Unicode glyphs. `egui-phosphor` **0.14** targets egui 0.36 | +| App icon | `assets/icons/{mac.png,windows.ico,windows.png,linux.png}`; `release.yml` builds the `.icns` from `mac.png`; `main.rs:53` loads `windows.png` as the runtime icon. There is no About window and no splash | +| Windows | 6 `egui::Window::new` sites | +| Font rasterizers in the tree | `skrifa`, `vello_cpu`, `harfrust` (via epaint) | +| Contrast tool | `python3 design/tools/check_contrast.py design/tokens/tokens.json` → all pairs pass in all 7 themes | + +## Architecture decisions + +1. **Module:** `src/ui/theme/mod.rs` (runtime, from `design/rust/theme.rs`), + `src/ui/theme/tokens.rs` (generated, from `design/rust/ultralog_theme.rs`, declared with + `#[rustfmt::skip] mod tokens;`), `src/ui/theme/codegen.rs` (generator library), + `src/ui/theme/paint.rs` (custom painters). Bundle files stay in `design/rust/` as reference. +2. **Generator:** `codegen::generate(json: &str) -> String` lives in the library; + `examples/gen_theme.rs` calls it and writes `tokens.rs`. A unit test runs `generate` on + `design/tokens/tokens.json` and fails if the committed file differs. No Python in the build. +3. **Live theme:** `UltraLogApp::tokens: Tokens`. Each frame the app derives + `(ThemeSettings, FontScale)` from its live fields; on any difference from the last applied + value, `apply_theme()` runs: `ctx.set_theme(ThemePreference::Dark)`, then + `ctx.set_style_of(Theme::Dark, style)` with `theme::visuals(&t)` **and** the text styles, and + stores a copy with `ctx.data_mut(|d| d.insert_temp(..))` so free functions and `DataWidget`s + read `theme::current(ctx)`. Deriving per frame covers every place a setting is toggled + without hooking each one. A `theme_generation: u64` counter increments on each apply. +4. **Type scale:** `ultralog_theme::text` onto `Style::text_styles` (body 14, button 13, small 12, + heading 15, monospace 13, plus `TextStyle::Name` for `title`, `overline`, `data_lg`, + `data_sm`), multiplied by `FontScale`. egui families have no weight axis, so each weight is a + family: `ui` (Outfit 400, = Proportional), `ui-medium` (500), `ui-semibold` (600), + `display` (Saira 600), `display-bold-italic` (700 i), `display-black-italic` (800 i), + Monospace (JetBrains Mono 400), `mono-medium` (500). Call sites move from + `FontId::proportional(scaled_font(n))` to `TextStyle`s, which removes most inline sizes and the + `menu.rs` overrides. +5. **Colour maps:** `colormap.rs` stays the single entry point but wraps `theme::seq` and a new + `theme::div`; the Viridis and Turbo LUTs are deleted. `ColorSignature` gains + `theme_generation`, so the track-map cache rebuilds when the palette changes (added to the + cache-invalidation contract in `CLAUDE.md`). +6. **Series:** `CHART_COLORS` / `COLORBLIND_COLORS` are deleted. `get_channel_color` returns + `theme::series(&tokens, idx, cvd_like) -> (Color32, dashed)` where `cvd_like` = Color blind + mode, High contrast or export. All call sites listed in Phase 0 switch, including the IPC hex + (same wire format, new values) and exports (export tokens). Past 10 channels: Decision 4. +7. **Persistence:** new `UserSettings` fields `ultra_intensity: u8` with + `#[serde(default = "default_ultra_intensity")]` returning **35** (a plain default of 0 would + turn every upgrading user to Stealth), `high_contrast: bool`, `export_theme`, + `export_format`. `color_blind_mode` keeps its name; `version` stays 1. A test loads a + 2.16-era settings file and asserts intensity 35. +8. **Custom painters:** the brief allows four (redline, glow, bloom, floor). This plan needs + three more — focus ring, plot legend overlay, major grid lines — see Decision 2. + +## Phases + +Each phase is one commit unless noted, pushed to `release/3.0.0`. `cargo fmt`, `cargo clippy +--all-targets -D warnings` and `cargo test` pass after every commit. New strings in any phase go +into all 15 locale files in that phase's commit (i18n-translator agent). + +### Phase 0.5 — Remove legacy modules + +Delete the superseded `sidebar.rs` (not wired into rendering; 12 colours, a second +`color_blind_mode` toggle) and the legacy `render_channel_selection` in `channels.rs`, so no sweep +touches dead code. Update the `CLAUDE.md` module list. + +### Phase 1 — Tokens and theme runtime + +- Add `src/ui/theme/` per decisions 1-3, `examples/gen_theme.rs`, the drift test, `theme::div`; + `#[allow(clippy::excessive_precision)]` on the OKLab constants. +- `apply_theme()` replaces `Visuals::dark()`; the hard-coded panel frames in `app.rs:2344-2365` + read tokens. Settings are not wired yet (fixed Night Drive · 35). +- CI: contrast checker step in `.github/workflows/ci.yml`. + +### Phase 1b — Colour sweep of files Phase 4 does not rewrite + +Mechanical, one commit per file group, no layout changes. Replace every hard-coded colour with +its semantic token in `app.rs`, `state.rs` (`ToastType` colours), `tool_properties_panel.rs`, +`tools_panel.rs`, `settings_panel.rs`, `analysis_panel.rs`, `menu.rs`, `normalization_editor.rs`, +`update_dialog.rs`, `data_panel.rs`. Skip the files that Phase 4 or 5 rewrite (`tool_switcher`, +`activity_bar`, `tab_bar`, `files_panel`, `toast`, `computed_channels_manager`, +`formula_editor`, `chart`, `channels`, `histogram`, `scatter_plot`, `table_generator`, +`track_map`, `timeline`): each of those gets one combined pass (colours + type + component) in +its phase. + +Gate for the whole plan, checked in Phase 8 and run as a CI grep: +`grep -rnE 'Color32::|Rgba\(|Rgb::new|\[u8; 3\]' src/` matches only `src/ui/theme/` and +`Color32::TRANSPARENT`. + +### Phase 2 — Settings + +- `settings_panel.rs` Display section: **IntensityControl** (slider 0-100, named stops, "Night + Drive · 35" label, caption), **Color blind mode** (now drives the `cvd` data palette and AAA + text), **High contrast** (new; disables the slider and says why). +- Three-place persistence per decision 7. Analytics events for the new toggles, matching + `track_colorblind_mode_toggled`. + +### Phase 3 — Fonts and type + +- `assets/fonts/`: the eight static TTFs from decision 4, full coverage, with each family's + `OFL.txt`. `scripts/fonts/build_static_fonts.sh` records how they were made + (`fonttools varLib.instancer` from google/fonts); outputs are committed. +- Register the families; drop the unused `"Bold"` family. +- Text styles per decision 4; convert the Phase 1b files' call sites. Phase 4/5 files convert in + their own pass. Numbers in tables, legends, channel cards and the playback bar use Monospace. +- Non-Latin fallback per Decision 1, after the brand faces in every family's list. + +### Phase 4 — Chrome components + +> **Status (2026-10-23): done** — commits `c1b7afb`…`a3cc9a3`, each checked against +> `design/reference/` in the running app. Left for later phases: the files Phase 5 rewrites +> (`chart`, `channels`, `histogram`, `scatter_plot`, `table_generator`, `track_map`, `timeline`) +> still use egui defaults, emoji and hard-coded colours; the generic egui `Slider` keeps its own +> look with a value field (only Ultra intensity has the custom track); `TextEdit` is the egui +> widget on `bg_field` with the Visuals border (no 28px height or `radius-sm` override). + +In the brief's order, each per `design/design-system/components//README.md` and +`design/reference/-night.png`: + +| Component | File | Work | +| --- | --- | --- | +| ModeNav | `tool_switcher.rs` | Saira overline, uppercase, cyan active bar + glow, focus ring | +| ActivityRail | `activity_bar.rs` | 20px Phosphor icons, active state, focus ring | +| FileTabs | `tab_bar.rs` | ECU source badge, middle-truncated names (keep the scrolling row) | +| Button variants | new `src/ui/components/button.rs` | primary (`cyan` + `on_cyan`), secondary, ghost, danger, icon | +| Segmented, TextField/select, Checkbox, Slider | Visuals + helpers in `src/ui/components/` | | +| Window | helper wrapping `egui::Window` (6 sites) | redline top edge, display-font title, Esc closes | +| Notice, Badge | `toast.rs` + `src/ui/components/` | icon + word for every status | +| DropZone | `files_panel.rs` | perspective floor | +| LibraryItem, FormulaEditor | `computed_channels_manager.rs`, `formula_editor.rs` | magenta ƒ, gold favourite; `TextEdit` layouter syntax colours | + +Also in this phase: + +- `egui-phosphor = "0.14"`; replace the Unicode glyph labels and the `icons.rs` drawings. +- Focus ring painter (Decision 2) on every interactive widget, 5px in High contrast. +- High contrast "outlined surfaces": panel and card frames get a `line` stroke when `hc`. +- Motion: `Style::animation_time` 0.12 s; windows 0.18 s; reduced-motion drops glow transitions. + +### Phase 5 — Data views + +> **Status (2026-10-24): done** — commits `ba1e20c`…`5dc361d` plus the i18n pass, each checked in +> UltraLogDev against `design/reference/` (Plot night/cvd/ultra, ChannelCard, ChannelList, +> HeatmapTable, ColorScale, PlaybackBar). Deviations: the major grid comes from egui_plot's own +> tiered fade on `plot_grid_major` instead of a separate painter; table measures pick the +> diverging map when their values straddle zero (no per-measure flag); the scatter density keeps +> its continuous cells without the hatch. The track map colour picker could not be exercised by +> hand (egui popups ignore synthetic clicks in that panel); a unit test covers the colour path. + +- Series per decision 6; dashed 9-10 with `LineStyle::Dashed { length: 6.0 }` (≈6/4); hover/solo + draws the hovered line at 2px and fades the others to 40 % (custom, since egui_plot's + `highlight` doubles width). +- `chart.rs`: plot background `plot_bg` (scope `extreme_bg_color` inside the plot `Ui`), + `plot_grid` via `grid_color` plus a painted `plot_grid_major` every fifth line, cursor `VLine` + in `plot_cursor`, custom floating legend (Cursor row first, live mono values, `plot_legend_bg`; + top-left plus direct end labels when `cvd_like`). Bloom copy: unnamed, `allow_hover(false)`, + hidden with its series. Floor by `fx.plot_floor`. +- `channels.rs` / `tool_properties_panel.rs`: swatch (line glyph, dashed when dashed) + 2px strip, + mono values. +- Heatmaps (`histogram.rs`, `scatter_plot.rs`, `table_generator.rs`): `seq`/`div`; + `get_aaa_text_color` replaced by `on_seq_N`/`on_div_N` on `cell_plate`; hatched `cell_empty`; + confidence by fill style; selected-cell ring with `bg_void` gap; ColorScale beside every map. + AFR/λ-vs-target and correction-% measures use `div`. +- `track_map.rs`: picker per Decision 5; 3px trace with 1px `bg_void` casing; start/finish + `ink` tick; position dot in `plot_cursor` with 2px `bg_void` ring. Remove the + `track_map.colormap.viridis/turbo` keys in all locales. +- `timeline.rs`: PlaybackBar — cyan play button, cyan mono readout, thin-space record counter. + +### Phase 6 — Exports + +- **Text first.** PDF: embed Outfit and JetBrains Mono with printpdf `ParsedFont` / + `add_font` (fixes `−`, thin space, λ and the `~` arrow). PNG: rasterise text with `skrifa` + + `vello_cpu`, already in the tree via epaint (direct dependencies pinned to epaint's versions). + This retires the "PNG has no text" note in `CLAUDE.md` and changes the table-export confidence + markers back to the spec's glyphs. +- Move the `export.rs` renderers from `UltraLogApp` methods to free functions taking data + + `Tokens`, so tests can assert they use export tokens regardless of in-app settings. +- **ExportDialog** (new window): PDF/PNG, Paper (default) / Graphite with live thumbnails, + cvd notice; remembers the last choice per format. Cmd/Ctrl+E keeps exporting PNG with the + remembered theme. +- `export.rs` and `table_export.rs` render with `theme::export_tokens(..)`; both jet ramps, + `text_on()` and the Viridis test expectations go. +- **ExportSheet** layout: redline, lockup, title, time range + counts, chart, legend/ColorScale, + footer (ultralog.co · version · date). Graphite PDFs keep a white margin; PNG at 2×. +- **Copy for paste:** the chart only, as a PNG on the clipboard (`arboard::ImageData`). +- Lockups: rasterise `design/logos/ultralog-lockup{,-paper}.svg` once to PNG with a script, + commit to `assets/brand/`, embed with `include_bytes!` — no `resvg` at runtime. + +### Phase 7 — App icon and brand assets + +- Regenerate `assets/icons/{mac.png,windows.ico,windows.png,linux.png}` from + `design/logos/ultralog-app-icon-1024.png` (paths unchanged; `release.yml` and `main.rs` need no + change). +- About window: new (Decision 6). Splash: out of scope (eframe opens fast enough). + +### Phase 8 — Verify and document + +- `cargo test`, `cargo clippy --all-targets -D warnings`, contrast checker, colour gate grep. +- Screenshots of each tool in Night Drive, Stealth, Ultra, Color-blind safe and High contrast, + compared with `design/reference/` (drive the app over MCP). +- Checks: no series equals the cursor colour; keyboard focus visible everywhere; every status + shows icon + word. +- `CLAUDE.md`: new *Theme contract* section (tokens only, generator + drift test, data colours + ignore intensity, `Tokens` in app state and `ctx.data`, the painters, the colour gate); update + Settings Persistence fields, the cache contract (`theme_generation`), the export text notes and + the UI module list. Wiki and README screenshots per the release checklist. +- Last commit on the branch: `chore(release): bump to 3.0.0`. + +## Testing + +- Bundle tests: `anchors_are_exact`, `text_contrast_holds_across_the_slider`, + `data_colors_ignore_intensity`. +- New: generator drift; `series()` never returns `plot_cursor` in any theme; `seq`/`div` + monotonic in OKLab lightness; old settings file loads at intensity 35; settings round-trip; + export renderers use export tokens whatever the in-app settings; track-map cache rebuilds on a + theme change; PDF text with `−`/λ round-trips through the embedded font. +- `table_export.rs` tests updated from Viridis to export-token values. + +## Decisions (resolved 2026-10-23) + +1. **Non-Latin fonts:** bundle Noto Sans Devanagari, Bengali and Arabic (≈1 MB total); load a + system CJK font at runtime per locale (no bundled CJK). Arabic/Urdu stay without right-to-left + layout (egui limitation). +2. **Extra painters:** approved — focus ring (egui has no gap ring or 5px focus style), plot + legend overlay, major grid lines, as small helpers in `paint.rs` beside the brief's four. +3. **Scope:** all phases ship in 3.0.0. +4. **Series past 10:** channels 11-20 repeat the 10 colours dashed, 21+ dotted, in every theme + (cvd keeps its own dashed 9-10 rule within the first 10). +5. **Track map picker:** Sequential / Diverging (default taken; easy to change). +6. **About window:** a small new one (lockup, version, links) in Phase 7 (default taken). + +## Out of scope + +- The ultralog.co website rebuild (`design/HANDOFF-WEBSITE.md`) — separate repo and task. +- Splash screen. +- Layout changes beyond what the component specs require. diff --git a/docs/plans/2026-10-25-log-compare.md b/docs/plans/2026-10-25-log-compare.md new file mode 100644 index 00000000..03117aeb --- /dev/null +++ b/docs/plans/2026-10-25-log-compare.md @@ -0,0 +1,225 @@ +# Log compare (issue #98) + +Status: implemented on `release/3.0.0` (2026-10-25), including the step-7 Opus review fixes. Ships in **3.0.0**. + +Issue #98 asks to overlay a second log in the same tab, with each channel showing which log it +comes from. Typical use: two WOT pulls before and after a tune change, or the same pull on two +fuel maps. + +## Decisions (interview 2026-10-25) + +| # | Question | Decision | +|---|---|---| +| D1 | Release | In 3.0.0. PR #105 waits for it. | +| D2 | How log B looks | Same colour as its log-A partner, **dashed**. | +| D3 | Time alignment | Numeric offset for log B with nudge buttons, plus **Align at cursor**. No auto-align. | +| D4 | How many logs | Two per tab: **A** (the tab's file) and **B** (the comparison file). | +| D5 | Channel picking | **Auto-pair** by normalized name, plus an A/B switch in the channel list for B-only channels. | + +Derived from the plan review (2026-10-25, Opus), not asked: + +| # | Rule | Why | +|---|---|---| +| R1 | A pair shares **one y range**: the union of A's and B's min/max, on screen and in the export. | The chart scales every channel to its own 0..1. Without this, a 1.5 bar pull and a 1.8 bar pull draw at the same height and the comparison shows nothing. | +| R2 | A pair counts as **one** against `MAX_CHANNELS` (10) and `MAX_CHANNELS_PER_PLOT`; a B-only channel counts as one. | Pairing would otherwise halve the capacity to 5. Caps are checked for the pair as a unit before adding. | +| R3 | **Computed channels are not paired and not in the B list** in 3.0.0. | A formula exists in B only if it was applied to B's file, and `remove_computed_channel` shifts indices without fixing other tabs. | +| R4 | A legend value is **None outside its log's span**, the same rule for A and B (nearest sample inside the span, as the cursor record). | `find_record_at_time` returns the nearest record, so an edge value would read as live in the union range. | + +## What exists today + +- `SelectedChannel` carries its own `file_index`, and `render_chart_single_mode` / + `render_single_plot` already plot each channel from its own file + (`compute_viewport_points(selected.file_index, ..)`). A tab can hold channels from two files + without a model change. +- Parsers re-base time to 0 at the first record, so two logs line up by elapsed time. +- Gaps (found by the plan review; each one is a step in the implementation plan): + - **Add guards** refuse a channel whose `file_index != tab.file_index`: `add_channel` + (app.rs ~1213), `add_channel_to_plot` (app.rs ~1445), the chart drag-and-drop drop + (chart.rs ~390). They must also accept `compare.file_index`. + - **Cursor** — legend values read the tab's `cursor_record` (a record of file A) for every + channel (chart.rs `series_rows`); the Min/Max jump on a channel card writes the card's file + time and record into the tab cursor (channels.rs ~239, ~268). + - **Ranges** — the tab range is file A's; the chart export clips to the app-wide + `self.time_range` (export.rs ~271), which is every file at offset 0. + - **Track map** "Color by" lists every selected channel (track_map.rs ~392) and + `ensure_color_cache` indexes its values by file A's records (~923). + - **`remove_file`** remaps channel indices only in tabs whose own `file_index > index` + (app.rs ~1176), and dropping channels must also shift `plot_areas.channel_indices` and the + track-map colour slot, which only `remove_channel_from_plot` does, for the active tab. + - **Direct labels** (plot_style.rs `direct_labels`) print the name only. + - **Open file** makes the new file's tab active (`check_loading_complete`, app.rs ~877). + +## Scope + +In: the Log Viewer (single and stacked mode), its legend and channel cards, the timeline range, +the chart export. + +Out of 3.0.0 (unchanged, read file A only): scatter plot (already has a file per side), histogram, +table generators, analysis panel. The track map stays on file A and its "Color by" lists A +channels only. MCP/IPC gets no compare commands; three known gaps are fixed in step 7 +(`get_cursor_values` for file B uses A's record, handler.rs ~713; `deselect_all_channels` leaves +plot areas and `compare`, ~475; `set_time_range` vs `update_tab_time_range`). Compare state is per +session; it is not saved in `UserSettings`. + +## Design + +### Model (`src/state.rs`) + +```rust +pub struct CompareState { + /// The comparison file (log B). + pub file_index: usize, + /// Seconds added to log B's times to place them on the tab's (log A's) time axis. + pub offset_s: f64, + /// True while "Align at cursor" waits for the click on log B's matching point. + pub picking_alignment: bool, +} + +pub struct Tab { + // ... + pub compare: Option, +} +``` + +Log role is derived, not stored: a selected channel is B when +`tab.compare.is_some_and(|c| c.file_index == selected.file_index)`. Helper +`Tab::log_role(&SelectedChannel) -> LogRole { A, B }`. The tab file cannot also be log B (the +picker leaves it out). + +One time convention everywhere: **tab time** is log A's time. Log B's sample at file time `t` is +drawn at `t + offset_s`. Helper `UltraLogApp::record_at(file_index, file_time) -> Option`: +the last record with `time <= file_time` (binary search, `partition_point`), and `None` before +the first or after the last record (R4). A uses `file_time = T`, B uses `T - offset_s`. + +One removal path: `UltraLogApp::remove_selected_channel(tab_idx, idx)` removes a selected +channel from any tab and shifts that tab's `plot_areas.channel_indices` and track-map colour slot +(today's `remove_channel_from_plot` logic, generalised from the active tab). Removing several +goes from the highest index down. Remove pair, choosing None, changing B, and `remove_file` all +use it. + +### Choosing log B + +- Tool Properties (Log Viewer) gets a **Compare** row above the channel list: a dropdown with + "None", every other loaded file (name + ECU badge), and "Open file…". Opening loads the file + through the normal path (it also gets its own tab, as today). The request stores the asking + tab's `Tab::id`; when the load finishes, that tab gets the file as B and becomes active again. + The request is dropped if that tab was closed. +- File menu: **Compare with…** opens the same choice. +- Choosing B: sets `tab.compare`, auto-pairs every already-selected A channel (D5), recomputes the + tab time range. Choosing None removes the tab's B channels (through `remove_selected_channel`) + and clears `compare`. Changing B to another file does the same first, then pairs again. + +### Pairing (D5) + +- Match key: `normalize_channel_name_with_custom(name)` lowercased, the same normalization the + chart uses, so `Engine Speed` (Haltech) pairs with `RPM` (Speeduino) when normalization is on. + First match wins. Log channels only (R3). +- Adding an A channel while comparing also adds B's match, with the **same `color_index`**. No + match: A is added alone, and the card shows "not in B". Caps per R2. +- The channel list gets an **A | B** segmented switch while comparing. The B list adds B channels + (B-only allowed; a B channel whose name matches a selected A channel takes that colour, else + the next free slot, skipping slots whose colour is already on screen in Color blind mode, where + the palette is 8). +- Removing a channel card removes only that half. The card's menu has "Remove pair". +- Stacked mode: an auto-paired B channel goes into its partner's plot area; a B-only channel goes + where an A channel would. + +### Drawing (D2) + +- `theme::series` stays as it is. New `theme::compare_series(t, idx, cvd, role)`: the colour from + slot `idx` as today; stroke `Solid` for A and `Dashed` for B. In compare mode the stroke shows the + log, so slots past the palette repeat colours without changing stroke. This is the one exception + to the "past the palette changes stroke" rule. With R2 it can show only in stacked mode (more + than 10 colour slots on screen), and the A/B tag still tells the series apart. The Theme + Contract in CLAUDE.md documents it. +- B points: `compute_viewport_points` gets the viewport shifted by `-offset_s` (so LTTB detail + follows the zoom as today). The cache stores **file-time** points; `offset_s` is added to the + returned copy, never to the cached points, so a nudge smaller than one LTTB bucket (or the + `Full` view key) still moves the line. +- **Shared y range (R1).** Normalization uses the pair's union min/max for both halves. The cached + points hold normalized values, so the pair range goes into the cache key (or the entry is + cleared when the pair or B changes). An unpaired channel keeps its own range. + +### Legend and cards + +- `series_rows`: every value comes from `record_at` (R4) instead of the tab record. Each row gets + a mono **A** / **B** tag before the name (`plot_style::legend_overlay` gains an optional tag + field and counts it in its measure loop); hover shows the file name. +- `direct_labels` print the tag too: in Color blind mode and High contrast A and B share the + colour, and two identical labels would be ambiguous. +- Channel cards: the same tag; B cards show the file name in the subtitle. The Min/Max jump on a + B card sets the cursor to `time + offset_s` and the A record at that time. + +### Time alignment (D3) + +- Compare row (Tool Properties) shows **Offset** for B: a `DragValue` in seconds (0.01 s steps, + mono), nudge buttons −1 s / −0.1 s / +0.1 s / +1 s, and Reset. +- **Align at cursor**: put the cursor on an event in log A (for example the TPS rise), click + Align at cursor, then click log B's matching point on the chart. The new offset moves that B + point under the cursor: `offset_s += cursor_time - clicked_time`. A notice shows "Click log B's + matching point" while picking; Esc or a second click on the button cancels. Disabled when there + is no cursor. Pick mode takes the chart click before both cursor-setting paths (single mode + chart.rs ~293, stacked mode ~612) and checks Esc itself: `handle_keyboard_shortcuts` returns + early while a widget has focus, and its Esc stops playback. +- The tab time range is the union of A and shifted B + (`min(a0, b0 + off) .. max(a1, b1 + off)`), recomputed on every compare or offset change + (`UltraLogApp::update_tab_time_range`). The cursor is clamped into the new range. Arrow keys + still step log A records. + +### Export + +- `SeriesModel` gains `log: Option`. `draw_sheet` dashes B series (the export palette is + `theme::series(.., true)` plus `compare_series`) and prints the tag in the legend + (`A · RPM`). The meta line names both files: `A pull_1.csv · B pull_2.csv · B offset +1.25 s`. +- The sheet's range is the tab range (`get_time_range()`), not the app-wide `time_range`. B's data + is sliced at `t0 - off .. t1 - off`, then shifted, and pairs use the shared y range (R1). + +### Removing files (load-bearing) + +`remove_file(index)`, in **every** tab (not only tabs whose own `file_index > index`): remove +selected channels with `file_index == index` through `remove_selected_channel`, highest index +first; clear `compare` when it pointed at `index`; decrement `tab.file_index`, +`compare.file_index` and channel `file_index` values above `index` independently. Then recompute +time ranges. Without this a B channel would silently render another file's channel (same class +as the cache invalidation contract). + +## Implementation plan (on `release/3.0.0`, one commit per step) + +1. **Model** — `CompareState`, `Tab::compare`, `LogRole`, `log_role`, `record_at` (R4), + `remove_selected_channel`, `update_tab_time_range`, the `remove_file` remap in every tab, add + guards that accept `compare.file_index`, caps per R2. Tests: `record_at` with offset and + outside the span; union range; `remove_file` with B's index below A's, above A's, and in a + stacked tab. +2. **Chart** — shifted viewport with the offset added after the cache, shared pair range (R1), + `compare_series`, legend tags and per-log values, direct-label tags, drag-and-drop guard + (the drop goes through `add_channel_to_plot`, so step 1's guard covers it). Tests: strokes; a sub-bucket nudge moves the points; pair range. +3. **Channel list and cards** — Compare row (picker, None, Open file… with the tab-id request), + A | B switch, auto-pair (a paired B channel joins its partner's plot area in stacked mode), + Remove pair, "not in B", B card jump, track-map "Color by" filter. + File menu entry. Tests: pairing by normalized name and colour sharing; caps; B card jump; + pending open request; track-map filter. +4. **Alignment** — offset controls, Align at cursor (both click paths, own Esc), cursor clamp. + Test: offset math. +5. **Export** — tab range, B slice and shift, shared pair range, `SeriesModel::log`, dashed B, + legend tags, meta line. Tests: negative offset keeps B's early part; dump a sheet with a pair. +6. **Strings and docs** — new keys in all 15 locales (machine-drafted, listed for native + review); CLAUDE.md (compare contract + Theme Contract exception), README feature list, + wiki (User Guide "Compare two logs", FAQ), PR #105 description. +7. **IPC gaps and verify** — the three IPC gaps from Scope; fmt, clippy + `--all-targets -D warnings`, tests, colour gate, contrast check; dev-build check with two + Haltech pulls (pair, offset, align, stacked, export, close log B); Opus code review; fix round. + +## Risks + +- **Index-keyed state.** Every cache and plot area is keyed by index; step 1's remap and tests + carry the load. +- **Different sample rates.** B at 20 Hz beside A at 100 Hz is fine for plotting (each series has + its own times). Legend values use last-sample-at-or-before, same as today. +- **Normalization off.** Pairing then needs identical names; the A | B switch covers the rest. +- **3.0.0 slip.** Steps 1-4 are the feature and R1, the add guards, the cursor fixes, the export + range and the `remove_file` remap are not optional. If time runs short, cut in this order: + B-only channels in stacked mode, then the export legend tags (exports still draw B dashed). + Decide at step 5, not now. +- **Existing bug, not fixed here:** `remove_computed_channel` (app.rs ~1823) and the IPC delete + (handler.rs ~569) shift computed-channel indices without fixing any tab's selected channels. + R3 keeps compare clear of it. diff --git a/docs/screenshots/chart-cvd.png b/docs/screenshots/chart-cvd.png new file mode 100644 index 00000000..23128270 Binary files /dev/null and b/docs/screenshots/chart-cvd.png differ diff --git a/docs/screenshots/chart-night.png b/docs/screenshots/chart-night.png new file mode 100644 index 00000000..cf3fc294 Binary files /dev/null and b/docs/screenshots/chart-night.png differ diff --git a/docs/screenshots/chart-ultra.png b/docs/screenshots/chart-ultra.png new file mode 100644 index 00000000..70b21a79 Binary files /dev/null and b/docs/screenshots/chart-ultra.png differ diff --git a/docs/screenshots/export-dialog.png b/docs/screenshots/export-dialog.png new file mode 100644 index 00000000..fcd9fe95 Binary files /dev/null and b/docs/screenshots/export-dialog.png differ diff --git a/docs/screenshots/lambda-delay.png b/docs/screenshots/lambda-delay.png new file mode 100644 index 00000000..30bf1797 Binary files /dev/null and b/docs/screenshots/lambda-delay.png differ diff --git a/examples/gen_theme.rs b/examples/gen_theme.rs new file mode 100644 index 00000000..d76b5a98 --- /dev/null +++ b/examples/gen_theme.rs @@ -0,0 +1,19 @@ +//! Regenerate `src/ui/theme/tokens.rs` from `design/tokens/tokens.json`. +//! +//! Usage: `cargo run --example gen_theme` + +use std::path::Path; + +fn main() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let json_path = root.join("design/tokens/tokens.json"); + let out_path = root.join("src/ui/theme/tokens.rs"); + + let json = std::fs::read_to_string(&json_path) + .unwrap_or_else(|e| panic!("read {}: {e}", json_path.display())); + let source = ultralog::ui::theme::codegen::generate(&json) + .unwrap_or_else(|e| panic!("generate from {}: {e}", json_path.display())); + std::fs::write(&out_path, source) + .unwrap_or_else(|e| panic!("write {}: {e}", out_path.display())); + println!("wrote {}", out_path.display()); +} diff --git a/i18n/ar.yaml b/i18n/ar.yaml index ac4bd74b..007dcfde 100644 --- a/i18n/ar.yaml +++ b/i18n/ar.yaml @@ -6,11 +6,7 @@ menu: open_log_file: "فتح ملف السجل..." close_tab: "إغلاق علامة التبويب" export: "تصدير" - export_png: "تصدير كـ PNG..." - export_pdf: "تصدير كـ PDF..." - export_histogram_pdf: "تصدير المدرج التكراري كـ PDF..." - export_table_png: "تصدير الجدول كـ PNG..." - export_table_pdf: "تصدير الجدول كـ PDF..." + export_ellipsis: "تصدير..." view: "عرض" show_grid: "إظهار الشبكة" tool_mode: "وضع الأداة" @@ -30,6 +26,7 @@ menu: support_development: "دعم التطوير" check_for_updates: "التحقق من التحديثات" checking_for_updates: "جارٍ التحقق من التحديثات..." + about: "حول UltraLog" version: "الإصدار %{version}" # Files panel (src/ui/files_panel.rs) @@ -53,13 +50,40 @@ channels: with_data: "تحتوي بيانات (%{count})" empty: "فارغة (%{count})" +# Log compare (src/ui/tool_properties_panel.rs, src/ui/channels.rs, src/ui/menu.rs) +compare: + title: "مقارنة" + none: "لا شيء" + open_file: "فتح ملف…" + compare_with: "مقارنة مع…" + hint: "تُقرن القنوات بالاسم. السجل B متقطع." + no_other_files: "حمّل سجلاً ثانياً لمقارنته بهذا السجل." + log_a: "السجل A" + log_b: "السجل B" + not_in_b: "غير موجود في B" + remove_pair: "إزالة الزوج" + offset: "إزاحة السجل B" + offset_hint: "ثوانٍ تُضاف إلى أوقات السجل B. اسحب أو اكتب قيمة." + reset: "إعادة تعيين" + align: "محاذاة عند المؤشر" + align_hint: "ضع المؤشر على حدث في السجل A، انقر هنا، ثم انقر على الحدث نفسه في السجل B." + align_no_cursor: "انقر على الرسم البياني لوضع المؤشر أولاً." + align_pick: "انقر على النقطة المطابقة في السجل B." + align_pick_rest: "Esc للإلغاء." + align_cancel: "إلغاء" + # Settings panel (src/ui/settings_panel.rs) settings: language: "اللغة" language_desc: "اختر لغة العرض" display: "العرض" + ultra_intensity: "شدة Ultra" + ultra_intensity_desc: "مقدار تأثير النيون في مظهر التطبيق. لا يغيّر ألوان البيانات أو تباين النص أبدًا." + ultra_intensity_locked: "مقفل عند تفعيل التباين العالي: بلا توهج أو تدرجات أو شبكة خلفية." color_blind_mode: "وضع عمى الألوان" - color_blind_desc: "استخدام لوحة ألوان سهلة الوصول (لوحة Wong)" + color_blind_desc: "ألوان بيانات آمنة لعمى الألوان، وخطوط متقطعة ومُسمّاة، وتباين نص أقوى." + high_contrast: "تباين عالٍ" + high_contrast_desc: "خلفيات سوداء ونص أبيض وعناصر تحكم بإطارات. يوقف تأثيرات النيون." font_size: "حجم الخط:" cursor_tracking: "تتبع المؤشر" cursor_tracking_desc: "إبقاء المؤشر في المنتصف أثناء التمرير" @@ -99,6 +123,9 @@ timeline: speed: "السرعة:" time: "الوقت: %{time}" record: "السجل %{current} من %{total}" + play: "تشغيل" + pause: "إيقاف مؤقت" + stop: "إيقاف" # Formula editor (src/ui/formula_editor.rs) formula: @@ -172,9 +199,10 @@ toast: export_png_success: "تم تصدير الرسم البياني كـ PNG" export_pdf_success: "تم تصدير الرسم البياني كـ PDF" export_failed: "فشل التصدير: %{error}" + export_copied: "تم نسخ الصورة إلى الحافظة" histogram_exported_png: "تم تصدير المدرج التكراري كـ PNG" histogram_exported_pdf: "تم تصدير المدرج التكراري كـ PDF" - table_exported_png: "تم تصدير الجدول كـ PNG (الخلايا فقط؛ استخدم CSV للقيم)" + table_exported_png: "تم تصدير الجدول كـ PNG" table_exported_pdf: "تم تصدير الجدول كـ PDF" scatter_exported_png: "تم تصدير المخطط المبعثر كـ PNG" scatter_exported_pdf: "تم تصدير المخطط المبعثر كـ PDF" @@ -209,6 +237,7 @@ common: duplicate: "تكرار" apply: "تطبيق" close: "إغلاق" + cancel: "إلغاء" reset: "إعادة تعيين" # Computed channels manager (src/ui/computed_channels_manager.rs) @@ -325,10 +354,42 @@ histogram: filter_min: "الحد الأدنى" filter_max: "الحد الأقصى" no_filters: "لا توجد مرشحات نشطة" + scale_hits: "عدد الإصابات" + scale_average: "متوسط القيمة" # Chart (src/ui/chart.rs) chart: select_channels: "حدد القنوات لعرض الرسم البياني" + cursor: "المؤشر" + rich: "غني" + target: "الهدف" + lean: "فقير" + +# Export dialog (src/ui/export_dialog.rs, src/ui/tools_panel.rs) +export: + open: "تصدير..." + title_chart: "تصدير الرسم البياني" + title_scatter: "تصدير مخططات التشتت" + title_histogram: "تصدير المدرج التكراري" + title_table: "تصدير الجدول" + format: "التنسيق" + paper: "ورقة" + paper_desc: "فاتح. الافتراضي؛ يُطبع بوضوح." + graphite: "جرافيت" + graphite_desc: "داكن، رمادي موفر للحبر." + cvd_note: "تستخدم التصديرات دائماً لوحة ألوان آمنة لعمى الألوان، ليتمكن من يشاركهم معك من قراءتها." + export_pdf: "تصدير PDF" + export_png: "تصدير PNG" + copy_image: "نسخ الصورة" + copy_tooltip: "انسخ الرسم البياني أو الجدول بدون الرأس أو التذييل، للصقه في مستند" + +# About window (src/ui/about_window.rs) +about: + title: "حول UltraLog" + tagline: "عارض سجلات ECU عالي الأداء" + license: "مفتوح المصدر بموجب ترخيص %{license}" + website: "الموقع الإلكتروني" + source: "الكود المصدري" # Analysis panel (src/ui/analysis_panel.rs) analysis: @@ -401,6 +462,10 @@ table_gen: copy_failed: "فشل نسخ الجدول إلى الحافظة" exported: "تم تصدير الجدول إلى %{path}" export_failed: "فشل التصدير" + legend_high: "ثقة عالية" + legend_medium: "ثقة متوسطة" + legend_low: "ثقة منخفضة" + legend_empty: "لا توجد عينات" # Tool switcher (src/ui/tool_switcher.rs) tools: @@ -438,8 +503,8 @@ track_map: color_by: "تلوين حسب" solid: "صلب" colormap: "خريطة الألوان" - colormap.viridis: "Viridis" - colormap.turbo: "Turbo" + colormap.sequential: "متسلسلة" + colormap.diverging: "متباعدة" range: "النطاق" range_auto: "تلقائي" lap: "دورة" diff --git a/i18n/bn.yaml b/i18n/bn.yaml index 24af5e90..323e17ad 100644 --- a/i18n/bn.yaml +++ b/i18n/bn.yaml @@ -6,11 +6,7 @@ menu: open_log_file: "লগ ফাইল খুলুন..." close_tab: "ট্যাব বন্ধ করুন" export: "রপ্তানি" - export_png: "PNG হিসেবে রপ্তানি..." - export_pdf: "PDF হিসেবে রপ্তানি..." - export_histogram_pdf: "হিস্টোগ্রাম PDF হিসেবে রপ্তানি..." - export_table_png: "টেবিল PNG হিসেবে রপ্তানি..." - export_table_pdf: "টেবিল PDF হিসেবে রপ্তানি..." + export_ellipsis: "রপ্তানি..." view: "দৃশ্য" show_grid: "গ্রিড দেখান" tool_mode: "টুল মোড" @@ -30,6 +26,7 @@ menu: support_development: "উন্নয়নে সহায়তা করুন" check_for_updates: "আপডেট চেক করুন" checking_for_updates: "আপডেট চেক করা হচ্ছে..." + about: "UltraLog সম্পর্কে" version: "সংস্করণ %{version}" # Files panel (src/ui/files_panel.rs) @@ -53,13 +50,40 @@ channels: with_data: "ডেটাসহ (%{count})" empty: "খালি (%{count})" +# Log compare (src/ui/tool_properties_panel.rs, src/ui/channels.rs, src/ui/menu.rs) +compare: + title: "তুলনা" + none: "কোনোটি নয়" + open_file: "ফাইল খুলুন…" + compare_with: "এর সাথে তুলনা করুন…" + hint: "চ্যানেল নাম দিয়ে জোড়া হয়। লগ B ড্যাশ রেখা।" + no_other_files: "এটির সাথে তুলনা করতে দ্বিতীয় একটি লগ লোড করুন।" + log_a: "লগ A" + log_b: "লগ B" + not_in_b: "B-তে নেই" + remove_pair: "জোড়া সরান" + offset: "লগ B অফসেট" + offset_hint: "লগ B-এর সময়ে যোগ করা সেকেন্ড। টেনে আনুন বা মান লিখুন।" + reset: "রিসেট" + align: "কার্সারে সারিবদ্ধ করুন" + align_hint: "লগ A-এর একটি ঘটনায় কার্সার রাখুন, এখানে ক্লিক করুন, তারপর লগ B-তে একই ঘটনায় ক্লিক করুন।" + align_no_cursor: "প্রথমে কার্সার রাখতে চার্টে ক্লিক করুন।" + align_pick: "লগ B-এর মিলে যাওয়া বিন্দুতে ক্লিক করুন।" + align_pick_rest: "Esc বাতিল করে।" + align_cancel: "বাতিল" + # Settings panel (src/ui/settings_panel.rs) settings: language: "ভাষা" language_desc: "প্রদর্শন ভাষা নির্বাচন করুন" display: "প্রদর্শন" + ultra_intensity: "Ultra তীব্রতা" + ultra_intensity_desc: "অ্যাপে কতটা নিয়ন আভা দেখাবে। আপনার ডেটার রং বা লেখার কনট্রাস্ট কখনও বদলায় না।" + ultra_intensity_locked: "হাই কনট্রাস্ট চালু থাকলে বন্ধ থাকে: কোনো গ্লো, গ্রেডিয়েন্ট বা গ্রিড ফ্লোর নেই।" color_blind_mode: "বর্ণান্ধ মোড" - color_blind_desc: "অ্যাক্সেসযোগ্য রঙ প্যালেট ব্যবহার করুন (Wong's palette)" + color_blind_desc: "বর্ণান্ধ-বান্ধব ডেটার রং, ড্যাশ ও লেবেলযুক্ত লাইন এবং শক্তিশালী লেখার কনট্রাস্ট।" + high_contrast: "হাই কনট্রাস্ট" + high_contrast_desc: "কালো পটভূমি, সাদা লেখা এবং রূপরেখাযুক্ত নিয়ন্ত্রণ। নিয়ন প্রভাব বন্ধ করে।" font_size: "ফন্ট সাইজ:" cursor_tracking: "কার্সর ট্র্যাকিং" cursor_tracking_desc: "স্ক্রাবিংয়ের সময় কার্সর কেন্দ্রে রাখুন" @@ -99,6 +123,9 @@ timeline: speed: "গতি:" time: "সময়: %{time}" record: "রেকর্ড %{current} / %{total}" + play: "চালান" + pause: "বিরতি" + stop: "থামান" # Formula editor (src/ui/formula_editor.rs) formula: @@ -172,9 +199,10 @@ toast: export_png_success: "চার্ট PNG হিসেবে রপ্তানি হয়েছে" export_pdf_success: "চার্ট PDF হিসেবে রপ্তানি হয়েছে" export_failed: "রপ্তানি ব্যর্থ: %{error}" + export_copied: "ছবি ক্লিপবোর্ডে কপি হয়েছে" histogram_exported_png: "হিস্টোগ্রাম PNG হিসেবে রপ্তানি হয়েছে" histogram_exported_pdf: "হিস্টোগ্রাম PDF হিসেবে রপ্তানি হয়েছে" - table_exported_png: "টেবিল PNG হিসেবে রপ্তানি হয়েছে (শুধু সেল; মানের জন্য CSV)" + table_exported_png: "টেবিল PNG হিসেবে রপ্তানি হয়েছে" table_exported_pdf: "টেবিল PDF হিসেবে রপ্তানি হয়েছে" scatter_exported_png: "স্ক্যাটার প্লট PNG হিসেবে রপ্তানি হয়েছে" scatter_exported_pdf: "স্ক্যাটার প্লট PDF হিসেবে রপ্তানি হয়েছে" @@ -209,6 +237,7 @@ common: duplicate: "ডুপ্লিকেট" apply: "প্রয়োগ" close: "বন্ধ করুন" + cancel: "বাতিল" reset: "রিসেট" # Computed channels manager (src/ui/computed_channels_manager.rs) @@ -325,10 +354,42 @@ histogram: filter_min: "ন্যূনতম" filter_max: "সর্বোচ্চ" no_filters: "কোনো সক্রিয় ফিল্টার নেই" + scale_hits: "হিট গণনা" + scale_average: "গড় মান" # Chart (src/ui/chart.rs) chart: select_channels: "চার্ট প্রদর্শন করতে চ্যানেল নির্বাচন করুন" + cursor: "কার্সর" + rich: "রিচ" + target: "টার্গেট" + lean: "লিন" + +# Export dialog (src/ui/export_dialog.rs, src/ui/tools_panel.rs) +export: + open: "রপ্তানি..." + title_chart: "চার্ট রপ্তানি করুন" + title_scatter: "স্ক্যাটার প্লট রপ্তানি করুন" + title_histogram: "হিস্টোগ্রাম রপ্তানি করুন" + title_table: "টেবিল রপ্তানি করুন" + format: "ফরম্যাট" + paper: "কাগজ" + paper_desc: "হালকা। ডিফল্ট; পরিষ্কারভাবে প্রিন্ট হয়।" + graphite: "গ্রাফাইট" + graphite_desc: "গাঢ়, কালি-সাশ্রয়ী ধূসর।" + cvd_note: "রপ্তানিতে সবসময় রঙ-অন্ধত্ব-সহায়ক রঙ ব্যবহৃত হয়, তাই যাদের সাথে শেয়ার করবেন তারা এগুলো পড়তে পারবেন।" + export_pdf: "PDF রপ্তানি" + export_png: "PNG রপ্তানি" + copy_image: "ছবি কপি করুন" + copy_tooltip: "হেডার বা ফুটার ছাড়া চার্ট বা টেবিল কপি করুন, যাতে নথিতে পেস্ট করা যায়" + +# About window (src/ui/about_window.rs) +about: + title: "UltraLog সম্পর্কে" + tagline: "উচ্চ-কার্যক্ষমতার ECU লগ ভিউয়ার" + license: "%{license} লাইসেন্সের অধীনে ওপেন সোর্স" + website: "ওয়েবসাইট" + source: "সোর্স কোড" # Analysis panel (src/ui/analysis_panel.rs) analysis: @@ -401,6 +462,10 @@ table_gen: copy_failed: "ক্লিপবোর্ডে অনুলিপি করতে ব্যর্থ" exported: "টেবিল %{path} এ রপ্তানি করা হয়েছে" export_failed: "রপ্তানি ব্যর্থ হয়েছে" + legend_high: "উচ্চ আত্মবিশ্বাস" + legend_medium: "মাঝারি আত্মবিশ্বাস" + legend_low: "কম আত্মবিশ্বাস" + legend_empty: "কোনো নমুনা নেই" # Tool switcher (src/ui/tool_switcher.rs) tools: @@ -438,8 +503,8 @@ track_map: color_by: "রঙিন করুন" solid: "সোলিড" colormap: "রঙ মানচিত্র" - colormap.viridis: "Viridis" - colormap.turbo: "Turbo" + colormap.sequential: "ক্রমিক" + colormap.diverging: "দ্বিমুখী" range: "পরিসর" range_auto: "স্বয়ংক্রিয়" lap: "ল্যাপ" diff --git a/i18n/de.yaml b/i18n/de.yaml index 3e45c472..bb3fac50 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -6,11 +6,7 @@ menu: open_log_file: "Logdatei öffnen..." close_tab: "Tab schließen" export: "Exportieren" - export_png: "Als PNG exportieren..." - export_pdf: "Als PDF exportieren..." - export_histogram_pdf: "Histogramm als PDF exportieren..." - export_table_png: "Tabelle als PNG exportieren..." - export_table_pdf: "Tabelle als PDF exportieren..." + export_ellipsis: "Exportieren..." view: "Ansicht" show_grid: "Gitter anzeigen" tool_mode: "Werkzeugmodus" @@ -30,6 +26,7 @@ menu: support_development: "Entwicklung unterstützen" check_for_updates: "Nach Updates suchen" checking_for_updates: "Suche nach Updates..." + about: "Über UltraLog" version: "Version %{version}" # Files panel (src/ui/files_panel.rs) @@ -53,13 +50,40 @@ channels: with_data: "Mit Daten (%{count})" empty: "Leer (%{count})" +# Log compare (src/ui/tool_properties_panel.rs, src/ui/channels.rs, src/ui/menu.rs) +compare: + title: "Vergleichen" + none: "Keiner" + open_file: "Datei öffnen…" + compare_with: "Vergleichen mit…" + hint: "Kanäle werden nach Namen gepaart. Log B ist gestrichelt." + no_other_files: "Laden Sie ein zweites Log, um es mit diesem zu vergleichen." + log_a: "Log A" + log_b: "Log B" + not_in_b: "nicht in B" + remove_pair: "Paar entfernen" + offset: "Versatz Log B" + offset_hint: "Sekunden, die zu den Zeiten von Log B addiert werden. Ziehen oder Wert eingeben." + reset: "Zurücksetzen" + align: "Am Cursor ausrichten" + align_hint: "Cursor auf ein Ereignis in Log A setzen, hier klicken, dann dasselbe Ereignis in Log B anklicken." + align_no_cursor: "Klicken Sie zuerst in das Diagramm, um den Cursor zu setzen." + align_pick: "Klicken Sie auf den passenden Punkt in Log B." + align_pick_rest: "Esc bricht ab." + align_cancel: "Abbrechen" + # Settings panel (src/ui/settings_panel.rs) settings: language: "Sprache" language_desc: "Anzeigesprache auswählen" display: "Anzeige" + ultra_intensity: "Ultra-Intensität" + ultra_intensity_desc: "Wie stark die App Neon-Effekte zeigt. Ändert nie Datenfarben oder Textkontrast." + ultra_intensity_locked: "Gesperrt, solange Hoher Kontrast aktiv ist: kein Leuchten, keine Verläufe und kein Bodengitter." color_blind_mode: "Farbenblind-Modus" - color_blind_desc: "Barrierefreie Farbpalette verwenden (Wong-Palette)" + color_blind_desc: "Farbenblind-sichere Datenfarben, gestrichelte und beschriftete Linien sowie stärkerer Textkontrast." + high_contrast: "Hoher Kontrast" + high_contrast_desc: "Schwarzer Hintergrund, weiße Schrift und Steuerelemente mit Umrandung. Schaltet die Neon-Effekte ab." font_size: "Schriftgröße:" cursor_tracking: "Cursor-Verfolgung" cursor_tracking_desc: "Cursor beim Scrubben zentriert halten" @@ -99,6 +123,9 @@ timeline: speed: "Geschwindigkeit:" time: "Zeit: %{time}" record: "Datensatz %{current} von %{total}" + play: "Abspielen" + pause: "Pause" + stop: "Stopp" # Formula editor (src/ui/formula_editor.rs) formula: @@ -172,9 +199,10 @@ toast: export_png_success: "Diagramm als PNG exportiert" export_pdf_success: "Diagramm als PDF exportiert" export_failed: "Export fehlgeschlagen: %{error}" + export_copied: "Bild in die Zwischenablage kopiert" histogram_exported_png: "Histogramm als PNG exportiert" histogram_exported_pdf: "Histogramm als PDF exportiert" - table_exported_png: "Tabelle als PNG exportiert (nur Zellen; CSV für Werte)" + table_exported_png: "Tabelle als PNG exportiert" table_exported_pdf: "Tabelle als PDF exportiert" scatter_exported_png: "Streudiagramm als PNG exportiert" scatter_exported_pdf: "Streudiagramm als PDF exportiert" @@ -209,6 +237,7 @@ common: duplicate: "Duplizieren" apply: "Anwenden" close: "Schließen" + cancel: "Abbrechen" reset: "Zurücksetzen" # Computed channels manager (src/ui/computed_channels_manager.rs) @@ -325,10 +354,42 @@ histogram: filter_min: "Min" filter_max: "Max" no_filters: "Keine Filter aktiv" + scale_hits: "Trefferanzahl" + scale_average: "Durchschnittswert" # Chart (src/ui/chart.rs) chart: select_channels: "Kanäle auswählen, um Diagramm anzuzeigen" + cursor: "Cursor" + rich: "fett" + target: "Ziel" + lean: "mager" + +# Export dialog (src/ui/export_dialog.rs, src/ui/tools_panel.rs) +export: + open: "Exportieren..." + title_chart: "Diagramm exportieren" + title_scatter: "Streudiagramme exportieren" + title_histogram: "Histogramm exportieren" + title_table: "Tabelle exportieren" + format: "Format" + paper: "Papier" + paper_desc: "Hell. Standard; druckt sauber." + graphite: "Graphit" + graphite_desc: "Dunkles, tintensparendes Grau." + cvd_note: "Exporte nutzen immer die farbenblindsichere Palette, damit alle Empfänger sie lesen können." + export_pdf: "PDF exportieren" + export_png: "PNG exportieren" + copy_image: "Bild kopieren" + copy_tooltip: "Diagramm oder Tabelle ohne Kopf- und Fußzeile kopieren, zum Einfügen in ein Dokument" + +# About window (src/ui/about_window.rs) +about: + title: "Über UltraLog" + tagline: "Hochleistungs-Viewer für ECU-Logs" + license: "Open Source unter der Lizenz %{license}" + website: "Website" + source: "Quellcode" # Analysis panel (src/ui/analysis_panel.rs) analysis: @@ -401,6 +462,10 @@ table_gen: copy_failed: "Fehler beim Kopieren in die Zwischenablage" exported: "Tabelle exportiert nach %{path}" export_failed: "Export fehlgeschlagen" + legend_high: "hohe Konfidenz" + legend_medium: "mittlere Konfidenz" + legend_low: "niedrige Konfidenz" + legend_empty: "keine Messwerte" # Tool switcher (src/ui/tool_switcher.rs) tools: @@ -438,8 +503,8 @@ track_map: color_by: "Farbe nach" solid: "Durchgehend" colormap: "Farbschema" - colormap.viridis: "Viridis" - colormap.turbo: "Turbo" + colormap.sequential: "Sequenziell" + colormap.diverging: "Divergent" range: "Bereich" range_auto: "Auto" lap: "Runde" diff --git a/i18n/en.yaml b/i18n/en.yaml index 7d58d1ed..bfe04d7b 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -6,11 +6,7 @@ menu: open_log_file: "Open Log File..." close_tab: "Close Tab" export: "Export" - export_png: "Export as PNG..." - export_pdf: "Export as PDF..." - export_histogram_pdf: "Export Histogram as PDF..." - export_table_png: "Export Table as PNG..." - export_table_pdf: "Export Table as PDF..." + export_ellipsis: "Export..." view: "View" show_grid: "Show Grid" tool_mode: "Tool Mode" @@ -30,13 +26,14 @@ menu: support_development: "Support Development" check_for_updates: "Check for Updates" checking_for_updates: "Checking for Updates..." + about: "About UltraLog" version: "Version %{version}" # Files panel (src/ui/files_panel.rs) files: loading: "Loading %{filename}..." loaded_files: "Loaded Files (%{count})" - add_file: "Add File" + add_file: "Add file" select_file: "Select a file" or: "or" drop_file_here: "Drop file here" @@ -53,52 +50,82 @@ channels: with_data: "With Data (%{count})" empty: "Empty (%{count})" +# Log compare (src/ui/tool_properties_panel.rs, src/ui/channels.rs, src/ui/menu.rs) +compare: + title: "Compare" + none: "None" + open_file: "Open file…" + compare_with: "Compare with…" + hint: "Channels pair by name. Log B is dashed." + no_other_files: "Load a second log to compare it with this one." + log_a: "Log A" + log_b: "Log B" + not_in_b: "not in B" + remove_pair: "Remove pair" + offset: "Log B offset" + offset_hint: "Seconds added to log B's times. Drag or type a value." + reset: "Reset" + align: "Align at cursor" + align_hint: "Put the cursor on an event in log A, click this, then click the same event in log B." + align_no_cursor: "Click the chart to place the cursor first." + align_pick: "Click log B's matching point." + align_pick_rest: "Esc cancels." + align_cancel: "Cancel" + # Settings panel (src/ui/settings_panel.rs) settings: language: "Language" language_desc: "Select display language" display: "Display" - color_blind_mode: "Color Blind Mode" - color_blind_desc: "Use accessible color palette (Wong's palette)" - font_size: "Font Size:" - cursor_tracking: "Cursor Tracking" + ultra_intensity: "Ultra intensity" + ultra_intensity_desc: "How much neon the app wears. Never changes your data colours or text contrast." + ultra_intensity_locked: "Locked while High contrast is on: no glow, gradients or grid floor." + color_blind_mode: "Color blind mode" + color_blind_desc: "Color-blind safe data colours, dashed and labelled lines, and stronger text contrast." + high_contrast: "High contrast" + high_contrast_desc: "Black grounds, white text and outlined controls. Turns off the neon effects." + font_size: "Font size" + cursor_tracking: "Cursor tracking" cursor_tracking_desc: "Keep cursor centered while scrubbing" - window: "Window:" - scroll_to_zoom: "Scroll to Zoom" + window: "Window" + scroll_to_zoom: "Scroll to zoom" scroll_to_zoom_desc: "Scroll wheel zooms the chart directly instead of panning" - show_grid: "Show Grid" + show_grid: "Show grid" show_grid_desc: "Draw the chart background grid" - grid_opacity: "Opacity:" + grid_opacity: "Opacity" field_names: "Field Names" - field_normalization: "Field Normalization" + field_normalization: "Field normalization" field_normalization_desc: "Standardize channel names across ECU types" custom_mappings: "%{count} custom mappings" - edit_custom_mappings: "Edit Custom Mappings" + edit_custom_mappings: "Edit custom mappings" units: "Units" units_desc: "Select preferred units for display" - temperature: "Temperature:" - pressure: "Pressure:" - speed: "Speed:" - distance: "Distance:" - fuel_economy: "Fuel Economy:" - volume: "Volume:" - flow_rate: "Flow Rate:" - acceleration: "Acceleration:" - afr_lambda: "AFR / Lambda:" + temperature: "Temperature" + pressure: "Pressure" + speed: "Speed" + distance: "Distance" + fuel_economy: "Fuel economy" + volume: "Volume" + flow_rate: "Flow rate" + acceleration: "Acceleration" + afr_lambda: "AFR / Lambda" updates: "Updates" check_on_startup: "Check on startup" auto_check_desc: "Automatically check for new versions" - check_for_updates: "Check for Updates" - checking: "Checking..." + check_for_updates: "Check for updates" + checking: "Checking…" current_version: "Current version: %{version}" update_available: "Update available: v%{version}" - view_details: "View Details" + view_details: "View details" # Timeline (src/ui/timeline.rs) timeline: speed: "Speed:" time: "Time: %{time}" record: "Record %{current} of %{total}" + play: "Play" + pause: "Pause" + stop: "Stop" # Formula editor (src/ui/formula_editor.rs) formula: @@ -172,9 +199,10 @@ toast: export_png_success: "Chart exported as PNG" export_pdf_success: "Chart exported as PDF" export_failed: "Export failed: %{error}" + export_copied: "Image copied to clipboard" histogram_exported_png: "Histogram exported as PNG" histogram_exported_pdf: "Histogram exported as PDF" - table_exported_png: "Table exported as PNG (cells only; use CSV for values)" + table_exported_png: "Table exported as PNG" table_exported_pdf: "Table exported as PDF" scatter_exported_png: "Scatter plot exported as PNG" scatter_exported_pdf: "Scatter plot exported as PDF" @@ -209,6 +237,7 @@ common: duplicate: "Duplicate" apply: "Apply" close: "Close" + cancel: "Cancel" reset: "Reset" # Computed channels manager (src/ui/computed_channels_manager.rs) @@ -325,10 +354,42 @@ histogram: filter_min: "Min" filter_max: "Max" no_filters: "No filters active" + scale_hits: "Hit count" + scale_average: "Average value" # Chart (src/ui/chart.rs) chart: select_channels: "Select channels to display chart" + cursor: "Cursor" + rich: "rich" + target: "target" + lean: "lean" + +# Export dialog (src/ui/export_dialog.rs, src/ui/tools_panel.rs) +export: + open: "Export..." + title_chart: "Export Chart" + title_scatter: "Export Scatter Plots" + title_histogram: "Export Histogram" + title_table: "Export Table" + format: "Format" + paper: "Paper" + paper_desc: "Light. Default; prints cleanly." + graphite: "Graphite" + graphite_desc: "Dark, ink-saving grey." + cvd_note: "Exports always use the color-blind safe palette, so anyone you share them with can read them." + export_pdf: "Export PDF" + export_png: "Export PNG" + copy_image: "Copy image" + copy_tooltip: "Copy the chart or table without the header or footer, to paste into a document" + +# About window (src/ui/about_window.rs) +about: + title: "About UltraLog" + tagline: "High-performance ECU log viewer" + license: "Open source under the %{license} license" + website: "Website" + source: "Source code" # Analysis panel (src/ui/analysis_panel.rs) analysis: @@ -401,6 +462,10 @@ table_gen: copy_failed: "Clipboard copy failed" exported: "Table exported to %{path}" export_failed: "Export failed" + legend_high: "high confidence" + legend_medium: "medium" + legend_low: "low" + legend_empty: "no samples" # Tool switcher (src/ui/tool_switcher.rs) tools: @@ -438,8 +503,8 @@ track_map: color_by: "Color by" solid: "Solid" colormap: "Colormap" - colormap.viridis: "Viridis" - colormap.turbo: "Turbo" + colormap.sequential: "Sequential" + colormap.diverging: "Diverging" range: "Range" range_auto: "Auto" lap: "Lap" diff --git a/i18n/es.yaml b/i18n/es.yaml index 9029a464..ee1c304a 100644 --- a/i18n/es.yaml +++ b/i18n/es.yaml @@ -6,11 +6,7 @@ menu: open_log_file: "Abrir Archivo de Log..." close_tab: "Cerrar Pestana" export: "Exportar" - export_png: "Exportar como PNG..." - export_pdf: "Exportar como PDF..." - export_histogram_pdf: "Exportar Histograma como PDF..." - export_table_png: "Exportar Tabla como PNG..." - export_table_pdf: "Exportar Tabla como PDF..." + export_ellipsis: "Exportar..." view: "Vista" show_grid: "Mostrar cuadrícula" tool_mode: "Modo de Herramienta" @@ -30,6 +26,7 @@ menu: support_development: "Apoyar el Desarrollo" check_for_updates: "Buscar Actualizaciones" checking_for_updates: "Buscando Actualizaciones..." + about: "Acerca de UltraLog" version: "Version %{version}" # Panel de archivos (src/ui/files_panel.rs) @@ -58,8 +55,13 @@ settings: language: "Idioma" language_desc: "Seleccionar idioma de visualizacion" display: "Pantalla" + ultra_intensity: "Intensidad Ultra" + ultra_intensity_desc: "Cuánto neón muestra la app. Nunca cambia los colores de tus datos ni el contraste del texto." + ultra_intensity_locked: "Bloqueado mientras el Contraste alto está activado: sin brillo, degradados ni cuadrícula de fondo." color_blind_mode: "Modo Daltonico" - color_blind_desc: "Usar paleta de colores accesible (paleta de Wong)" + color_blind_desc: "Colores de datos aptos para daltonismo, líneas discontinuas y con etiqueta, y mayor contraste de texto." + high_contrast: "Contraste alto" + high_contrast_desc: "Fondos negros, texto blanco y controles con contorno. Desactiva los efectos de neón." font_size: "Tamano de Fuente:" cursor_tracking: "Seguimiento del Cursor" cursor_tracking_desc: "Mantener el cursor centrado al desplazar" @@ -99,6 +101,9 @@ timeline: speed: "Velocidad:" time: "Tiempo: %{time}" record: "Registro %{current} de %{total}" + play: "Reproducir" + pause: "Pausar" + stop: "Detener" # Editor de formulas (src/ui/formula_editor.rs) formula: @@ -172,9 +177,10 @@ toast: export_png_success: "Grafico exportado como PNG" export_pdf_success: "Grafico exportado como PDF" export_failed: "Error de exportacion: %{error}" + export_copied: "Imagen copiada al portapapeles" histogram_exported_png: "Histograma exportado como PNG" histogram_exported_pdf: "Histograma exportado como PDF" - table_exported_png: "Tabla exportada como PNG (solo celdas; use CSV para valores)" + table_exported_png: "Tabla exportada como PNG" table_exported_pdf: "Tabla exportada como PDF" scatter_exported_png: "Grafico de dispersion exportado como PNG" scatter_exported_pdf: "Grafico de dispersion exportado como PDF" @@ -209,6 +215,7 @@ common: duplicate: "Duplicar" apply: "Aplicar" close: "Cerrar" + cancel: "Cancelar" reset: "Restablecer" # Gestor de canales calculados (src/ui/computed_channels_manager.rs) @@ -325,10 +332,42 @@ histogram: filter_min: "Mín" filter_max: "Máx" no_filters: "Sin filtros activos" + scale_hits: "Conteo de Impactos" + scale_average: "Valor promedio" # Grafico (src/ui/chart.rs) chart: select_channels: "Seleccione canales para mostrar grafico" + cursor: "Cursor" + rich: "rica" + target: "objetivo" + lean: "pobre" + +# Export dialog (src/ui/export_dialog.rs, src/ui/tools_panel.rs) +export: + open: "Exportar..." + title_chart: "Exportar gráfico" + title_scatter: "Exportar gráficos de dispersión" + title_histogram: "Exportar histograma" + title_table: "Exportar tabla" + format: "Formato" + paper: "Papel" + paper_desc: "Claro. Predeterminado; imprime bien." + graphite: "Grafito" + graphite_desc: "Gris oscuro, ahorra tinta." + cvd_note: "Las exportaciones usan siempre la paleta segura para daltonismo, para que cualquiera pueda leerlas." + export_pdf: "Exportar PDF" + export_png: "Exportar PNG" + copy_image: "Copiar imagen" + copy_tooltip: "Copia el gráfico o la tabla sin encabezado ni pie, para pegarlo en un documento" + +# About window (src/ui/about_window.rs) +about: + title: "Acerca de UltraLog" + tagline: "Visor de registros de ECU de alto rendimiento" + license: "Código abierto bajo la licencia %{license}" + website: "Sitio web" + source: "Código fuente" # Panel de analisis (src/ui/analysis_panel.rs) analysis: @@ -402,6 +441,10 @@ table_gen: copy_failed: "Error al copiar al portapapeles" exported: "Tabla exportada a %{path}" export_failed: "Fallo en la exportación" + legend_high: "confianza alta" + legend_medium: "confianza media" + legend_low: "confianza baja" + legend_empty: "sin muestras" # Tool switcher (src/ui/tool_switcher.rs) tools: @@ -439,8 +482,8 @@ track_map: color_by: "Colorear por" solid: "Sólido" colormap: "Mapa de colores" - colormap.viridis: "Viridis" - colormap.turbo: "Turbo" + colormap.sequential: "Secuencial" + colormap.diverging: "Divergente" range: "Rango" range_auto: "Auto" lap: "Vuelta" @@ -455,3 +498,25 @@ track_map: tiles.privacy_notice: "Los mosaicos del mapa se descargan de %{provider}. La ubicación aproximada de tu trazado se comparte con este proveedor." tooltip.time: "Tiempo" tooltip.position: "Posición" + +# Log compare (src/ui/tool_properties_panel.rs, src/ui/channels.rs, src/ui/menu.rs) +compare: + title: "Comparar" + none: "Ninguno" + open_file: "Abrir archivo…" + compare_with: "Comparar con…" + hint: "Los canales se emparejan por nombre. El registro B es discontinuo." + no_other_files: "Cargue un segundo registro para compararlo con este." + log_a: "Registro A" + log_b: "Registro B" + not_in_b: "no está en B" + remove_pair: "Quitar par" + offset: "Desfase del registro B" + offset_hint: "Segundos que se suman a los tiempos del registro B. Arrastre o escriba un valor." + reset: "Restablecer" + align: "Alinear en el cursor" + align_hint: "Ponga el cursor en un evento del registro A, haga clic aquí y luego en el mismo evento del registro B." + align_no_cursor: "Haga clic en el gráfico para colocar el cursor primero." + align_pick: "Haga clic en el punto correspondiente del registro B." + align_pick_rest: "Esc cancela." + align_cancel: "Cancelar" diff --git a/i18n/fr.yaml b/i18n/fr.yaml index 59446eba..d1001d2e 100644 --- a/i18n/fr.yaml +++ b/i18n/fr.yaml @@ -6,11 +6,7 @@ menu: open_log_file: "Ouvrir un fichier journal..." close_tab: "Fermer l'onglet" export: "Exporter" - export_png: "Exporter en PNG..." - export_pdf: "Exporter en PDF..." - export_histogram_pdf: "Exporter l'histogramme en PDF..." - export_table_png: "Exporter le tableau en PNG..." - export_table_pdf: "Exporter le tableau en PDF..." + export_ellipsis: "Exporter..." view: "Affichage" show_grid: "Afficher la grille" tool_mode: "Mode outil" @@ -30,6 +26,7 @@ menu: support_development: "Soutenir le developpement" check_for_updates: "Rechercher des mises a jour" checking_for_updates: "Recherche de mises a jour..." + about: "À propos d'UltraLog" version: "Version %{version}" # Files panel (src/ui/files_panel.rs) @@ -53,13 +50,40 @@ channels: with_data: "Avec donnees (%{count})" empty: "Vides (%{count})" +# Log compare (src/ui/tool_properties_panel.rs, src/ui/channels.rs, src/ui/menu.rs) +compare: + title: "Comparer" + none: "Aucun" + open_file: "Ouvrir un fichier…" + compare_with: "Comparer avec…" + hint: "Les canaux sont appariés par nom. Le journal B est en pointillés." + no_other_files: "Chargez un second journal pour le comparer à celui-ci." + log_a: "Journal A" + log_b: "Journal B" + not_in_b: "absent de B" + remove_pair: "Retirer la paire" + offset: "Décalage du journal B" + offset_hint: "Secondes ajoutées aux temps du journal B. Faites glisser ou saisissez une valeur." + reset: "Réinitialiser" + align: "Aligner sur le curseur" + align_hint: "Placez le curseur sur un événement du journal A, cliquez ici, puis cliquez sur le même événement dans le journal B." + align_no_cursor: "Cliquez d'abord sur le graphique pour placer le curseur." + align_pick: "Cliquez sur le point correspondant du journal B." + align_pick_rest: "Échap annule." + align_cancel: "Annuler" + # Settings panel (src/ui/settings_panel.rs) settings: language: "Langue" language_desc: "Selectionnez la langue d'affichage" display: "Affichage" + ultra_intensity: "Intensité Ultra" + ultra_intensity_desc: "Quantité de néon affichée par l'application. Ne modifie jamais les couleurs de vos données ni le contraste du texte." + ultra_intensity_locked: "Verrouillé quand le contraste élevé est activé : pas de lueur, de dégradés ni de grille de fond." color_blind_mode: "Mode daltonien" - color_blind_desc: "Utiliser une palette de couleurs accessible (palette de Wong)" + color_blind_desc: "Couleurs de données adaptées au daltonisme, lignes en pointillés et étiquetées, et contraste de texte renforcé." + high_contrast: "Contraste élevé" + high_contrast_desc: "Fonds noirs, texte blanc et commandes contournées. Désactive les effets néon." font_size: "Taille de police :" cursor_tracking: "Suivi du curseur" cursor_tracking_desc: "Garder le curseur centre pendant le defilement" @@ -99,6 +123,9 @@ timeline: speed: "Vitesse :" time: "Temps : %{time}" record: "Enregistrement %{current} sur %{total}" + play: "Lecture" + pause: "Pause" + stop: "Arrêter" # Formula editor (src/ui/formula_editor.rs) formula: @@ -172,9 +199,10 @@ toast: export_png_success: "Graphique exporte en PNG" export_pdf_success: "Graphique exporte en PDF" export_failed: "Echec de l'exportation : %{error}" + export_copied: "Image copiée dans le presse-papiers" histogram_exported_png: "Histogramme exporte en PNG" histogram_exported_pdf: "Histogramme exporte en PDF" - table_exported_png: "Tableau exporté en PNG (cellules seulement ; CSV pour les valeurs)" + table_exported_png: "Tableau exporté en PNG" table_exported_pdf: "Tableau exporté en PDF" scatter_exported_png: "Nuage de points exporte en PNG" scatter_exported_pdf: "Nuage de points exporte en PDF" @@ -209,6 +237,7 @@ common: duplicate: "Dupliquer" apply: "Appliquer" close: "Fermer" + cancel: "Annuler" reset: "Reinitialiser" # Computed channels manager (src/ui/computed_channels_manager.rs) @@ -325,10 +354,42 @@ histogram: filter_min: "Min" filter_max: "Max" no_filters: "Aucun filtre actif" + scale_hits: "Nombre d'occurrences" + scale_average: "Valeur moyenne" # Chart (src/ui/chart.rs) chart: select_channels: "Selectionnez des canaux pour afficher le graphique" + cursor: "Curseur" + rich: "riche" + target: "cible" + lean: "pauvre" + +# Export dialog (src/ui/export_dialog.rs, src/ui/tools_panel.rs) +export: + open: "Exporter..." + title_chart: "Exporter le graphique" + title_scatter: "Exporter les nuages de points" + title_histogram: "Exporter l'histogramme" + title_table: "Exporter le tableau" + format: "Format" + paper: "Papier" + paper_desc: "Clair. Par défaut ; s'imprime proprement." + graphite: "Graphite" + graphite_desc: "Gris foncé, économise l'encre." + cvd_note: "Les exports utilisent toujours la palette adaptée au daltonisme, pour que tous vos destinataires puissent les lire." + export_pdf: "Exporter en PDF" + export_png: "Exporter en PNG" + copy_image: "Copier l'image" + copy_tooltip: "Copie le graphique ou le tableau sans en-tête ni pied de page, pour le coller dans un document" + +# About window (src/ui/about_window.rs) +about: + title: "À propos d'UltraLog" + tagline: "Visualiseur de journaux ECU haute performance" + license: "Open source sous licence %{license}" + website: "Site web" + source: "Code source" # Analysis panel (src/ui/analysis_panel.rs) analysis: @@ -401,6 +462,10 @@ table_gen: copy_failed: "Échec de la copie dans le presse-papiers" exported: "Tableau exporté vers %{path}" export_failed: "Échec de l'exportation" + legend_high: "haute confiance" + legend_medium: "confiance moyenne" + legend_low: "faible confiance" + legend_empty: "aucun échantillon" # Tool switcher (src/ui/tool_switcher.rs) tools: @@ -438,8 +503,8 @@ track_map: color_by: "Colorer par" solid: "Uniforme" colormap: "Palette de couleurs" - colormap.viridis: "Viridis" - colormap.turbo: "Turbo" + colormap.sequential: "Séquentielle" + colormap.diverging: "Divergente" range: "Plage" range_auto: "Auto" lap: "Tour" diff --git a/i18n/hi.yaml b/i18n/hi.yaml index 4c1bb8ab..8e8d0c02 100644 --- a/i18n/hi.yaml +++ b/i18n/hi.yaml @@ -6,11 +6,7 @@ menu: open_log_file: "लॉग फ़ाइल खोलें..." close_tab: "टैब बंद करें" export: "निर्यात" - export_png: "PNG के रूप में निर्यात करें..." - export_pdf: "PDF के रूप में निर्यात करें..." - export_histogram_pdf: "हिस्टोग्राम PDF के रूप में निर्यात करें..." - export_table_png: "तालिका PNG के रूप में निर्यात करें..." - export_table_pdf: "तालिका PDF के रूप में निर्यात करें..." + export_ellipsis: "निर्यात..." view: "दृश्य" show_grid: "ग्रिड दिखाएं" tool_mode: "टूल मोड" @@ -30,6 +26,7 @@ menu: support_development: "विकास में सहयोग करें" check_for_updates: "अपडेट की जाँच करें" checking_for_updates: "अपडेट की जाँच हो रही है..." + about: "UltraLog के बारे में" version: "संस्करण %{version}" # Files panel (src/ui/files_panel.rs) @@ -53,13 +50,40 @@ channels: with_data: "डेटा सहित (%{count})" empty: "खाली (%{count})" +# Log compare (src/ui/tool_properties_panel.rs, src/ui/channels.rs, src/ui/menu.rs) +compare: + title: "तुलना" + none: "कोई नहीं" + open_file: "फ़ाइल खोलें…" + compare_with: "इससे तुलना करें…" + hint: "चैनल नाम से जोड़े जाते हैं। लॉग B डैश वाली रेखा है।" + no_other_files: "इससे तुलना के लिए दूसरा लॉग लोड करें।" + log_a: "लॉग A" + log_b: "लॉग B" + not_in_b: "B में नहीं" + remove_pair: "जोड़ी हटाएँ" + offset: "लॉग B ऑफ़सेट" + offset_hint: "लॉग B के समय में जोड़े जाने वाले सेकंड। खींचें या मान लिखें।" + reset: "रीसेट" + align: "कर्सर पर संरेखित करें" + align_hint: "कर्सर को लॉग A की किसी घटना पर रखें, यहाँ क्लिक करें, फिर लॉग B में वही घटना क्लिक करें।" + align_no_cursor: "पहले कर्सर रखने के लिए चार्ट पर क्लिक करें।" + align_pick: "लॉग B का मेल खाता बिंदु क्लिक करें।" + align_pick_rest: "Esc रद्द करता है।" + align_cancel: "रद्द करें" + # Settings panel (src/ui/settings_panel.rs) settings: language: "भाषा" language_desc: "प्रदर्शन भाषा चुनें" display: "प्रदर्शन" + ultra_intensity: "Ultra तीव्रता" + ultra_intensity_desc: "ऐप पर कितनी नियॉन चमक दिखे। आपके डेटा के रंग या टेक्स्ट का कंट्रास्ट कभी नहीं बदलता।" + ultra_intensity_locked: "हाई कंट्रास्ट चालू होने पर यह बंद रहता है: कोई ग्लो, ग्रेडिएंट या ग्रिड फ़्लोर नहीं।" color_blind_mode: "कलर ब्लाइंड मोड" - color_blind_desc: "सुलभ रंग पैलेट का उपयोग करें (Wong's पैलेट)" + color_blind_desc: "कलर-ब्लाइंड के लिए सुरक्षित डेटा रंग, डैश और लेबल वाली लाइनें, और मज़बूत टेक्स्ट कंट्रास्ट।" + high_contrast: "हाई कंट्रास्ट" + high_contrast_desc: "काली पृष्ठभूमि, सफ़ेद टेक्स्ट और बॉर्डर वाले कंट्रोल। नियॉन प्रभाव बंद कर देता है।" font_size: "फ़ॉन्ट आकार:" cursor_tracking: "कर्सर ट्रैकिंग" cursor_tracking_desc: "स्क्रबिंग के दौरान कर्सर को केंद्रित रखें" @@ -99,6 +123,9 @@ timeline: speed: "गति:" time: "समय: %{time}" record: "रिकॉर्ड %{current} में से %{total}" + play: "चलाएं" + pause: "विराम" + stop: "रोकें" # Formula editor (src/ui/formula_editor.rs) formula: @@ -172,9 +199,10 @@ toast: export_png_success: "चार्ट PNG के रूप में निर्यात किया गया" export_pdf_success: "चार्ट PDF के रूप में निर्यात किया गया" export_failed: "निर्यात विफल: %{error}" + export_copied: "छवि क्लिपबोर्ड पर कॉपी हुई" histogram_exported_png: "हिस्टोग्राम PNG के रूप में निर्यात किया गया" histogram_exported_pdf: "हिस्टोग्राम PDF के रूप में निर्यात किया गया" - table_exported_png: "तालिका PNG के रूप में निर्यात हुई (केवल सेल; मानों के लिए CSV)" + table_exported_png: "तालिका PNG के रूप में निर्यात हुई" table_exported_pdf: "तालिका PDF के रूप में निर्यात हुई" scatter_exported_png: "स्कैटर प्लॉट PNG के रूप में निर्यात किया गया" scatter_exported_pdf: "स्कैटर प्लॉट PDF के रूप में निर्यात किया गया" @@ -209,6 +237,7 @@ common: duplicate: "डुप्लिकेट" apply: "लागू करें" close: "बंद करें" + cancel: "रद्द करें" reset: "रीसेट" # Computed channels manager (src/ui/computed_channels_manager.rs) @@ -325,10 +354,42 @@ histogram: filter_min: "न्यूनतम" filter_max: "अधिकतम" no_filters: "कोई सक्रिय फ़िल्टर नहीं" + scale_hits: "हिट गणना" + scale_average: "औसत मान" # Chart (src/ui/chart.rs) chart: select_channels: "चार्ट प्रदर्शित करने के लिए चैनल चुनें" + cursor: "कर्सर" + rich: "रिच" + target: "टारगेट" + lean: "लीन" + +# Export dialog (src/ui/export_dialog.rs, src/ui/tools_panel.rs) +export: + open: "निर्यात..." + title_chart: "चार्ट निर्यात करें" + title_scatter: "स्कैटर प्लॉट निर्यात करें" + title_histogram: "हिस्टोग्राम निर्यात करें" + title_table: "तालिका निर्यात करें" + format: "प्रारूप" + paper: "कागज़" + paper_desc: "हल्का। डिफ़ॉल्ट; साफ़ छपता है।" + graphite: "ग्रेफ़ाइट" + graphite_desc: "गहरा, स्याही बचाने वाला धूसर।" + cvd_note: "निर्यात हमेशा रंग-अंधता-सुरक्षित पैलेट का उपयोग करते हैं, ताकि जिनके साथ आप साझा करें वे इन्हें पढ़ सकें।" + export_pdf: "PDF निर्यात करें" + export_png: "PNG निर्यात करें" + copy_image: "छवि कॉपी करें" + copy_tooltip: "हेडर या फ़ुटर के बिना चार्ट या तालिका कॉपी करें, ताकि किसी दस्तावेज़ में चिपकाया जा सके" + +# About window (src/ui/about_window.rs) +about: + title: "UltraLog के बारे में" + tagline: "उच्च-प्रदर्शन ECU लॉग व्यूअर" + license: "%{license} लाइसेंस के तहत ओपन सोर्स" + website: "वेबसाइट" + source: "स्रोत कोड" # Analysis panel (src/ui/analysis_panel.rs) analysis: @@ -401,6 +462,10 @@ table_gen: copy_failed: "क्लिपबोर्ड पर कॉपी करने में विफल" exported: "तालिका %{path} को निर्यात की गई" export_failed: "निर्यात विफल हुआ" + legend_high: "उच्च आत्मविश्वास" + legend_medium: "मध्यम आत्मविश्वास" + legend_low: "कम आत्मविश्वास" + legend_empty: "कोई नमूना नहीं" # Tool switcher (src/ui/tool_switcher.rs) tools: @@ -438,8 +503,8 @@ track_map: color_by: "रंग चुनें" solid: "ठोस" colormap: "रंग मानचित्र" - colormap.viridis: "Viridis" - colormap.turbo: "Turbo" + colormap.sequential: "अनुक्रमिक" + colormap.diverging: "अपसारी" range: "रेंज" range_auto: "स्वतः" lap: "लैप" diff --git a/i18n/id.yaml b/i18n/id.yaml index 7e0d5dc0..4dfda2d6 100644 --- a/i18n/id.yaml +++ b/i18n/id.yaml @@ -6,11 +6,7 @@ menu: open_log_file: "Buka Berkas Log..." close_tab: "Tutup Tab" export: "Ekspor" - export_png: "Ekspor sebagai PNG..." - export_pdf: "Ekspor sebagai PDF..." - export_histogram_pdf: "Ekspor Histogram sebagai PDF..." - export_table_png: "Ekspor Tabel sebagai PNG..." - export_table_pdf: "Ekspor Tabel sebagai PDF..." + export_ellipsis: "Ekspor..." view: "Tampilan" show_grid: "Tampilkan Kisi" tool_mode: "Mode Alat" @@ -30,6 +26,7 @@ menu: support_development: "Dukung Pengembangan" check_for_updates: "Periksa Pembaruan" checking_for_updates: "Memeriksa Pembaruan..." + about: "Tentang UltraLog" version: "Versi %{version}" # Files panel (src/ui/files_panel.rs) @@ -53,13 +50,40 @@ channels: with_data: "Dengan Data (%{count})" empty: "Kosong (%{count})" +# Log compare (src/ui/tool_properties_panel.rs, src/ui/channels.rs, src/ui/menu.rs) +compare: + title: "Bandingkan" + none: "Tidak ada" + open_file: "Buka file…" + compare_with: "Bandingkan dengan…" + hint: "Kanal dipasangkan berdasarkan nama. Log B bergaris putus-putus." + no_other_files: "Muat log kedua untuk membandingkannya dengan log ini." + log_a: "Log A" + log_b: "Log B" + not_in_b: "tidak ada di B" + remove_pair: "Hapus pasangan" + offset: "Offset log B" + offset_hint: "Detik yang ditambahkan ke waktu log B. Seret atau ketik nilai." + reset: "Atur ulang" + align: "Sejajarkan di kursor" + align_hint: "Letakkan kursor pada peristiwa di log A, klik ini, lalu klik peristiwa yang sama di log B." + align_no_cursor: "Klik grafik untuk menempatkan kursor terlebih dahulu." + align_pick: "Klik titik yang cocok di log B." + align_pick_rest: "Esc membatalkan." + align_cancel: "Batal" + # Settings panel (src/ui/settings_panel.rs) settings: language: "Bahasa" language_desc: "Pilih bahasa tampilan" display: "Tampilan" + ultra_intensity: "Intensitas Ultra" + ultra_intensity_desc: "Seberapa banyak efek neon yang ditampilkan aplikasi. Tidak pernah mengubah warna data atau kontras teks." + ultra_intensity_locked: "Terkunci saat Kontras Tinggi aktif: tanpa efek cahaya, gradien, atau kisi latar." color_blind_mode: "Mode Buta Warna" - color_blind_desc: "Gunakan palet warna aksesibel (palet Wong)" + color_blind_desc: "Warna data yang aman untuk buta warna, garis putus-putus dan berlabel, serta kontras teks yang lebih kuat." + high_contrast: "Kontras Tinggi" + high_contrast_desc: "Latar hitam, teks putih, dan kontrol berbingkai. Mematikan efek neon." font_size: "Ukuran Font:" cursor_tracking: "Pelacakan Kursor" cursor_tracking_desc: "Pertahankan kursor di tengah saat menggulir" @@ -99,6 +123,9 @@ timeline: speed: "Kecepatan:" time: "Waktu: %{time}" record: "Rekaman %{current} dari %{total}" + play: "Putar" + pause: "Jeda" + stop: "Berhenti" # Formula editor (src/ui/formula_editor.rs) formula: @@ -172,9 +199,10 @@ toast: export_png_success: "Grafik diekspor sebagai PNG" export_pdf_success: "Grafik diekspor sebagai PDF" export_failed: "Ekspor gagal: %{error}" + export_copied: "Gambar disalin ke papan klip" histogram_exported_png: "Histogram diekspor sebagai PNG" histogram_exported_pdf: "Histogram diekspor sebagai PDF" - table_exported_png: "Tabel diekspor sebagai PNG (sel saja; gunakan CSV untuk nilai)" + table_exported_png: "Tabel diekspor sebagai PNG" table_exported_pdf: "Tabel diekspor sebagai PDF" scatter_exported_png: "Diagram sebar diekspor sebagai PNG" scatter_exported_pdf: "Diagram sebar diekspor sebagai PDF" @@ -209,6 +237,7 @@ common: duplicate: "Duplikat" apply: "Terapkan" close: "Tutup" + cancel: "Batal" reset: "Reset" # Computed channels manager (src/ui/computed_channels_manager.rs) @@ -325,10 +354,42 @@ histogram: filter_min: "Min" filter_max: "Maks" no_filters: "Tidak ada filter aktif" + scale_hits: "Jumlah Hit" + scale_average: "Nilai rata-rata" # Chart (src/ui/chart.rs) chart: select_channels: "Pilih kanal untuk menampilkan grafik" + cursor: "Kursor" + rich: "kaya" + target: "target" + lean: "miskin" + +# Export dialog (src/ui/export_dialog.rs, src/ui/tools_panel.rs) +export: + open: "Ekspor..." + title_chart: "Ekspor Grafik" + title_scatter: "Ekspor Diagram Sebar" + title_histogram: "Ekspor Histogram" + title_table: "Ekspor Tabel" + format: "Format" + paper: "Kertas" + paper_desc: "Terang. Default; tercetak dengan rapi." + graphite: "Grafit" + graphite_desc: "Abu-abu gelap, hemat tinta." + cvd_note: "Ekspor selalu memakai palet aman untuk buta warna, agar siapa pun yang Anda bagikan dapat membacanya." + export_pdf: "Ekspor PDF" + export_png: "Ekspor PNG" + copy_image: "Salin gambar" + copy_tooltip: "Salin grafik atau tabel tanpa header atau footer, untuk ditempel ke dokumen" + +# About window (src/ui/about_window.rs) +about: + title: "Tentang UltraLog" + tagline: "Penampil log ECU berkinerja tinggi" + license: "Open source di bawah lisensi %{license}" + website: "Situs web" + source: "Kode sumber" # Analysis panel (src/ui/analysis_panel.rs) analysis: @@ -401,6 +462,10 @@ table_gen: copy_failed: "Gagal menyalin ke clipboard" exported: "Tabel diekspor ke %{path}" export_failed: "Ekspor gagal" + legend_high: "kepercayaan tinggi" + legend_medium: "kepercayaan sedang" + legend_low: "kepercayaan rendah" + legend_empty: "tanpa sampel" # Tool switcher (src/ui/tool_switcher.rs) tools: @@ -438,8 +503,8 @@ track_map: color_by: "Warna berdasarkan" solid: "Padat" colormap: "Peta warna" - colormap.viridis: "Viridis" - colormap.turbo: "Turbo" + colormap.sequential: "Berurutan" + colormap.diverging: "Divergen" range: "Rentang" range_auto: "Otomatis" lap: "Lap" diff --git a/i18n/it.yaml b/i18n/it.yaml index b1fba6ee..be20990b 100644 --- a/i18n/it.yaml +++ b/i18n/it.yaml @@ -6,11 +6,7 @@ menu: open_log_file: "Apri File di Log..." close_tab: "Chiudi Scheda" export: "Esporta" - export_png: "Esporta come PNG..." - export_pdf: "Esporta come PDF..." - export_histogram_pdf: "Esporta Istogramma come PDF..." - export_table_png: "Esporta Tabella come PNG..." - export_table_pdf: "Esporta Tabella come PDF..." + export_ellipsis: "Esporta..." view: "Visualizza" show_grid: "Mostra griglia" tool_mode: "Modalita' Strumento" @@ -30,6 +26,7 @@ menu: support_development: "Supporta lo Sviluppo" check_for_updates: "Verifica Aggiornamenti" checking_for_updates: "Verifica Aggiornamenti in Corso..." + about: "Informazioni su UltraLog" version: "Versione %{version}" # Files panel (src/ui/files_panel.rs) @@ -53,13 +50,40 @@ channels: with_data: "Con Dati (%{count})" empty: "Vuoti (%{count})" +# Log compare (src/ui/tool_properties_panel.rs, src/ui/channels.rs, src/ui/menu.rs) +compare: + title: "Confronta" + none: "Nessuno" + open_file: "Apri file…" + compare_with: "Confronta con…" + hint: "I canali si abbinano per nome. Il log B è tratteggiato." + no_other_files: "Carica un secondo log per confrontarlo con questo." + log_a: "Log A" + log_b: "Log B" + not_in_b: "non in B" + remove_pair: "Rimuovi coppia" + offset: "Offset log B" + offset_hint: "Secondi aggiunti ai tempi del log B. Trascina o digita un valore." + reset: "Ripristina" + align: "Allinea al cursore" + align_hint: "Metti il cursore su un evento nel log A, fai clic qui, poi fai clic sullo stesso evento nel log B." + align_no_cursor: "Fai prima clic sul grafico per posizionare il cursore." + align_pick: "Fai clic sul punto corrispondente del log B." + align_pick_rest: "Esc annulla." + align_cancel: "Annulla" + # Settings panel (src/ui/settings_panel.rs) settings: language: "Lingua" language_desc: "Seleziona la lingua di visualizzazione" display: "Visualizzazione" + ultra_intensity: "Intensità Ultra" + ultra_intensity_desc: "Quanto neon mostra l'app. Non cambia mai i colori dei dati né il contrasto del testo." + ultra_intensity_locked: "Bloccato con il Contrasto elevato attivo: niente bagliore, sfumature o griglia di sfondo." color_blind_mode: "Modalita' Daltonici" - color_blind_desc: "Usa una palette di colori accessibile (palette di Wong)" + color_blind_desc: "Colori dei dati accessibili ai daltonici, linee tratteggiate ed etichettate e contrasto del testo più forte." + high_contrast: "Contrasto elevato" + high_contrast_desc: "Sfondi neri, testo bianco e controlli con contorno. Disattiva gli effetti neon." font_size: "Dimensione Carattere:" cursor_tracking: "Tracciamento Cursore" cursor_tracking_desc: "Mantieni il cursore centrato durante lo scorrimento" @@ -99,6 +123,9 @@ timeline: speed: "Velocita':" time: "Tempo: %{time}" record: "Record %{current} di %{total}" + play: "Riproduci" + pause: "Pausa" + stop: "Stop" # Formula editor (src/ui/formula_editor.rs) formula: @@ -172,9 +199,10 @@ toast: export_png_success: "Grafico esportato come PNG" export_pdf_success: "Grafico esportato come PDF" export_failed: "Esportazione fallita: %{error}" + export_copied: "Immagine copiata negli appunti" histogram_exported_png: "Istogramma esportato come PNG" histogram_exported_pdf: "Istogramma esportato come PDF" - table_exported_png: "Tabella esportata come PNG (solo celle; CSV per i valori)" + table_exported_png: "Tabella esportata come PNG" table_exported_pdf: "Tabella esportata come PDF" scatter_exported_png: "Grafico a dispersione esportato come PNG" scatter_exported_pdf: "Grafico a dispersione esportato come PDF" @@ -209,6 +237,7 @@ common: duplicate: "Duplica" apply: "Applica" close: "Chiudi" + cancel: "Annulla" reset: "Reimposta" # Computed channels manager (src/ui/computed_channels_manager.rs) @@ -325,10 +354,42 @@ histogram: filter_min: "Min" filter_max: "Max" no_filters: "Nessun filtro attivo" + scale_hits: "Conteggio Occorrenze" + scale_average: "Valore medio" # Chart (src/ui/chart.rs) chart: select_channels: "Seleziona i canali per visualizzare il grafico" + cursor: "Cursore" + rich: "ricca" + target: "target" + lean: "magra" + +# Export dialog (src/ui/export_dialog.rs, src/ui/tools_panel.rs) +export: + open: "Esporta..." + title_chart: "Esporta grafico" + title_scatter: "Esporta grafici a dispersione" + title_histogram: "Esporta istogramma" + title_table: "Esporta tabella" + format: "Formato" + paper: "Carta" + paper_desc: "Chiaro. Predefinito; stampa in modo pulito." + graphite: "Grafite" + graphite_desc: "Grigio scuro, risparmia inchiostro." + cvd_note: "Le esportazioni usano sempre la palette sicura per il daltonismo, così chiunque le riceva può leggerle." + export_pdf: "Esporta PDF" + export_png: "Esporta PNG" + copy_image: "Copia immagine" + copy_tooltip: "Copia grafico o tabella senza intestazione né piè di pagina, da incollare in un documento" + +# About window (src/ui/about_window.rs) +about: + title: "Informazioni su UltraLog" + tagline: "Visualizzatore di log ECU ad alte prestazioni" + license: "Open source sotto licenza %{license}" + website: "Sito web" + source: "Codice sorgente" # Analysis panel (src/ui/analysis_panel.rs) analysis: @@ -401,6 +462,10 @@ table_gen: copy_failed: "Copia negli appunti non riuscita" exported: "Tabella esportata in %{path}" export_failed: "Esportazione non riuscita" + legend_high: "alta confidenza" + legend_medium: "media confidenza" + legend_low: "bassa confidenza" + legend_empty: "nessun campione" # Tool switcher (src/ui/tool_switcher.rs) tools: @@ -438,8 +503,8 @@ track_map: color_by: "Colora per" solid: "Solid" colormap: "Mappa colori" - colormap.viridis: "Viridis" - colormap.turbo: "Turbo" + colormap.sequential: "Sequenziale" + colormap.diverging: "Divergente" range: "Intervallo" range_auto: "Auto" lap: "Giro" diff --git a/i18n/ja.yaml b/i18n/ja.yaml index f820cf66..791a8112 100644 --- a/i18n/ja.yaml +++ b/i18n/ja.yaml @@ -6,11 +6,7 @@ menu: open_log_file: "ログファイルを開く..." close_tab: "タブを閉じる" export: "エクスポート" - export_png: "PNGとしてエクスポート..." - export_pdf: "PDFとしてエクスポート..." - export_histogram_pdf: "ヒストグラムをPDFでエクスポート..." - export_table_png: "テーブルをPNGでエクスポート..." - export_table_pdf: "テーブルをPDFでエクスポート..." + export_ellipsis: "エクスポート..." view: "表示" show_grid: "グリッドを表示" tool_mode: "ツールモード" @@ -30,6 +26,7 @@ menu: support_development: "開発を支援" check_for_updates: "アップデートを確認" checking_for_updates: "アップデートを確認中..." + about: "UltraLog について" version: "バージョン %{version}" # Files panel (src/ui/files_panel.rs) @@ -53,13 +50,40 @@ channels: with_data: "データあり (%{count})" empty: "空 (%{count})" +# Log compare (src/ui/tool_properties_panel.rs, src/ui/channels.rs, src/ui/menu.rs) +compare: + title: "比較" + none: "なし" + open_file: "ファイルを開く…" + compare_with: "比較対象…" + hint: "チャンネルは名前でペアになります。ログ B は破線です。" + no_other_files: "比較する 2 つ目のログを読み込んでください。" + log_a: "ログ A" + log_b: "ログ B" + not_in_b: "B にない" + remove_pair: "ペアを削除" + offset: "ログ B のオフセット" + offset_hint: "ログ B の時刻に加える秒数。ドラッグまたは値を入力します。" + reset: "リセット" + align: "カーソルで揃える" + align_hint: "ログ A のイベントにカーソルを置き、これをクリックしてから、ログ B の同じイベントをクリックします。" + align_no_cursor: "先にチャートをクリックしてカーソルを置いてください。" + align_pick: "ログ B の対応する点をクリックしてください。" + align_pick_rest: "Esc でキャンセル。" + align_cancel: "キャンセル" + # Settings panel (src/ui/settings_panel.rs) settings: language: "言語" language_desc: "表示言語を選択" display: "表示" + ultra_intensity: "Ultra 強度" + ultra_intensity_desc: "アプリにネオンの装飾をどれだけ表示するかを決めます。データの色や文字のコントラストは変わりません。" + ultra_intensity_locked: "ハイコントラストが有効な間はロックされます。グロー、グラデーション、背景グリッドは表示されません。" color_blind_mode: "色覚サポートモード" - color_blind_desc: "アクセシブルなカラーパレットを使用(ウォンのパレット)" + color_blind_desc: "色覚に配慮したデータの色、破線とラベル付きの線、より強い文字のコントラストを使用します。" + high_contrast: "ハイコントラスト" + high_contrast_desc: "黒い背景、白い文字、枠線のあるコントロールを使用します。ネオン効果はオフになります。" font_size: "フォントサイズ:" cursor_tracking: "カーソル追従" cursor_tracking_desc: "スクラブ中にカーソルを中央に保持" @@ -99,6 +123,9 @@ timeline: speed: "速度:" time: "時間: %{time}" record: "レコード %{current} / %{total}" + play: "再生" + pause: "一時停止" + stop: "停止" # Formula editor (src/ui/formula_editor.rs) formula: @@ -172,9 +199,10 @@ toast: export_png_success: "チャートをPNGとしてエクスポートしました" export_pdf_success: "チャートをPDFとしてエクスポートしました" export_failed: "エクスポートに失敗しました: %{error}" + export_copied: "画像をクリップボードにコピーしました" histogram_exported_png: "ヒストグラムをPNGとしてエクスポートしました" histogram_exported_pdf: "ヒストグラムをPDFとしてエクスポートしました" - table_exported_png: "テーブルをPNGとしてエクスポートしました(セルのみ、数値はCSVを使用)" + table_exported_png: "テーブルをPNGとしてエクスポートしました" table_exported_pdf: "テーブルをPDFとしてエクスポートしました" scatter_exported_png: "散布図をPNGとしてエクスポートしました" scatter_exported_pdf: "散布図をPDFとしてエクスポートしました" @@ -209,6 +237,7 @@ common: duplicate: "複製" apply: "適用" close: "閉じる" + cancel: "キャンセル" reset: "リセット" # Computed channels manager (src/ui/computed_channels_manager.rs) @@ -325,10 +354,42 @@ histogram: filter_min: "最小" filter_max: "最大" no_filters: "アクティブなフィルターなし" + scale_hits: "ヒット数" + scale_average: "平均値" # Chart (src/ui/chart.rs) chart: select_channels: "チャートを表示するにはチャンネルを選択してください" + cursor: "カーソル" + rich: "リッチ" + target: "ターゲット" + lean: "リーン" + +# Export dialog (src/ui/export_dialog.rs, src/ui/tools_panel.rs) +export: + open: "エクスポート..." + title_chart: "チャートをエクスポート" + title_scatter: "散布図をエクスポート" + title_histogram: "ヒストグラムをエクスポート" + title_table: "テーブルをエクスポート" + format: "形式" + paper: "ペーパー" + paper_desc: "明るい配色。標準設定です。きれいに印刷されます。" + graphite: "グラファイト" + graphite_desc: "暗いグレー。インクを節約します。" + cvd_note: "エクスポートは常に色覚多様性に配慮したパレットを使うため、共有先の人も読めます。" + export_pdf: "PDFをエクスポート" + export_png: "PNGをエクスポート" + copy_image: "画像をコピー" + copy_tooltip: "ヘッダーとフッターを除いてチャートまたはテーブルをコピーし、文書に貼り付けます" + +# About window (src/ui/about_window.rs) +about: + title: "UltraLog について" + tagline: "高性能 ECU ログビューア" + license: "%{license} ライセンスのオープンソース" + website: "ウェブサイト" + source: "ソースコード" # Analysis panel (src/ui/analysis_panel.rs) analysis: @@ -401,6 +462,10 @@ table_gen: copy_failed: "クリップボードへのコピーが失敗しました" exported: "テーブルが%{path}にエクスポートされました" export_failed: "エクスポートに失敗しました" + legend_high: "高信頼度" + legend_medium: "中信頼度" + legend_low: "低信頼度" + legend_empty: "サンプルなし" # Tool switcher (src/ui/tool_switcher.rs) tools: @@ -438,8 +503,8 @@ track_map: color_by: "色分け方法" solid: "ベタ塗り" colormap: "カラーマップ" - colormap.viridis: "Viridis" - colormap.turbo: "Turbo" + colormap.sequential: "連続" + colormap.diverging: "発散" range: "範囲" range_auto: "自動" lap: "ラップ" diff --git a/i18n/pt-BR.yaml b/i18n/pt-BR.yaml index 163d656d..67c07f0f 100644 --- a/i18n/pt-BR.yaml +++ b/i18n/pt-BR.yaml @@ -6,11 +6,7 @@ menu: open_log_file: "Abrir Arquivo de Log..." close_tab: "Fechar Aba" export: "Exportar" - export_png: "Exportar como PNG..." - export_pdf: "Exportar como PDF..." - export_histogram_pdf: "Exportar Histograma como PDF..." - export_table_png: "Exportar Tabela como PNG..." - export_table_pdf: "Exportar Tabela como PDF..." + export_ellipsis: "Exportar..." view: "Visualizar" show_grid: "Mostrar Grade" tool_mode: "Modo de Ferramenta" @@ -30,6 +26,7 @@ menu: support_development: "Apoiar o Desenvolvimento" check_for_updates: "Verificar Atualizações" checking_for_updates: "Verificando Atualizações..." + about: "Sobre o UltraLog" version: "Versão %{version}" # Painel de arquivos (src/ui/files_panel.rs) @@ -58,8 +55,13 @@ settings: language: "Idioma" language_desc: "Selecionar idioma de exibição" display: "Exibição" + ultra_intensity: "Intensidade Ultra" + ultra_intensity_desc: "Quanto de neon o app exibe. Nunca altera as cores dos seus dados nem o contraste do texto." + ultra_intensity_locked: "Bloqueado enquanto o Alto contraste estiver ativado: sem brilho, gradientes ou grade de fundo." color_blind_mode: "Modo para Daltonismo" - color_blind_desc: "Usar paleta de cores acessível (paleta de Wong)" + color_blind_desc: "Cores de dados seguras para daltonismo, linhas tracejadas e rotuladas e contraste de texto mais forte." + high_contrast: "Alto contraste" + high_contrast_desc: "Fundos pretos, texto branco e controles com contorno. Desativa os efeitos de neon." font_size: "Tamanho da Fonte:" cursor_tracking: "Rastreamento do Cursor" cursor_tracking_desc: "Manter o cursor centralizado durante a navegação" @@ -99,6 +101,9 @@ timeline: speed: "Velocidade:" time: "Tempo: %{time}" record: "Registro %{current} de %{total}" + play: "Reproduzir" + pause: "Pausar" + stop: "Parar" # Editor de fórmulas (src/ui/formula_editor.rs) formula: @@ -172,9 +177,10 @@ toast: export_png_success: "Gráfico exportado como PNG" export_pdf_success: "Gráfico exportado como PDF" export_failed: "Falha na exportação: %{error}" + export_copied: "Imagem copiada para a área de transferência" histogram_exported_png: "Histograma exportado como PNG" histogram_exported_pdf: "Histograma exportado como PDF" - table_exported_png: "Tabela exportada como PNG (apenas células; use CSV para valores)" + table_exported_png: "Tabela exportada como PNG" table_exported_pdf: "Tabela exportada como PDF" scatter_exported_png: "Gráfico de dispersão exportado como PNG" scatter_exported_pdf: "Gráfico de dispersão exportado como PDF" @@ -209,6 +215,7 @@ common: duplicate: "Duplicar" apply: "Aplicar" close: "Fechar" + cancel: "Cancelar" reset: "Redefinir" # Gerenciador de canais calculados (src/ui/computed_channels_manager.rs) @@ -325,10 +332,42 @@ histogram: filter_min: "Min" filter_max: "Max" no_filters: "Nenhum filtro ativo" + scale_hits: "Contagem de Ocorrências" + scale_average: "Valor médio" # Gráfico (src/ui/chart.rs) chart: select_channels: "Selecione canais para exibir o gráfico" + cursor: "Cursor" + rich: "rica" + target: "alvo" + lean: "pobre" + +# Export dialog (src/ui/export_dialog.rs, src/ui/tools_panel.rs) +export: + open: "Exportar..." + title_chart: "Exportar gráfico" + title_scatter: "Exportar gráficos de dispersão" + title_histogram: "Exportar histograma" + title_table: "Exportar tabela" + format: "Formato" + paper: "Papel" + paper_desc: "Claro. Padrão; imprime bem." + graphite: "Grafite" + graphite_desc: "Cinza escuro, economiza tinta." + cvd_note: "As exportações sempre usam a paleta segura para daltonismo, para que quem receber consiga ler." + export_pdf: "Exportar PDF" + export_png: "Exportar PNG" + copy_image: "Copiar imagem" + copy_tooltip: "Copia o gráfico ou a tabela sem cabeçalho e rodapé, para colar em um documento" + +# About window (src/ui/about_window.rs) +about: + title: "Sobre o UltraLog" + tagline: "Visualizador de logs de ECU de alto desempenho" + license: "Código aberto sob a licença %{license}" + website: "Site" + source: "Código-fonte" # Painel de análise (src/ui/analysis_panel.rs) analysis: @@ -402,6 +441,10 @@ table_gen: copy_failed: "Falha ao copiar para a área de transferência" exported: "Tabela exportada para %{path}" export_failed: "Falha na exportação" + legend_high: "confiança alta" + legend_medium: "confiança média" + legend_low: "confiança baixa" + legend_empty: "sem amostras" # Tool switcher (src/ui/tool_switcher.rs) tools: @@ -439,8 +482,8 @@ track_map: color_by: "Colorir por" solid: "Sólido" colormap: "Mapa de cores" - colormap.viridis: "Viridis" - colormap.turbo: "Turbo" + colormap.sequential: "Sequencial" + colormap.diverging: "Divergente" range: "Intervalo" range_auto: "Automático" lap: "Volta" @@ -455,3 +498,25 @@ track_map: tiles.privacy_notice: "Os blocos do mapa são baixados de %{provider}. A localização aproximada do seu traçado é compartilhada com esse provedor." tooltip.time: "Tempo" tooltip.position: "Posição" + +# Log compare (src/ui/tool_properties_panel.rs, src/ui/channels.rs, src/ui/menu.rs) +compare: + title: "Comparar" + none: "Nenhum" + open_file: "Abrir arquivo…" + compare_with: "Comparar com…" + hint: "Os canais são pareados pelo nome. O log B é tracejado." + no_other_files: "Carregue um segundo log para compará-lo com este." + log_a: "Log A" + log_b: "Log B" + not_in_b: "não está em B" + remove_pair: "Remover par" + offset: "Deslocamento do log B" + offset_hint: "Segundos somados aos tempos do log B. Arraste ou digite um valor." + reset: "Redefinir" + align: "Alinhar no cursor" + align_hint: "Coloque o cursor em um evento do log A, clique aqui e depois clique no mesmo evento do log B." + align_no_cursor: "Clique no gráfico para posicionar o cursor primeiro." + align_pick: "Clique no ponto correspondente do log B." + align_pick_rest: "Esc cancela." + align_cancel: "Cancelar" diff --git a/i18n/pt-PT.yaml b/i18n/pt-PT.yaml index 45c9cf4c..5c1a11d3 100644 --- a/i18n/pt-PT.yaml +++ b/i18n/pt-PT.yaml @@ -6,11 +6,7 @@ menu: open_log_file: "Abrir Ficheiro de Registo..." close_tab: "Fechar Separador" export: "Exportar" - export_png: "Exportar como PNG..." - export_pdf: "Exportar como PDF..." - export_histogram_pdf: "Exportar Histograma como PDF..." - export_table_png: "Exportar Tabela como PNG..." - export_table_pdf: "Exportar Tabela como PDF..." + export_ellipsis: "Exportar..." view: "Ver" show_grid: "Mostrar Grelha" tool_mode: "Modo de Ferramenta" @@ -30,6 +26,7 @@ menu: support_development: "Apoiar Desenvolvimento" check_for_updates: "Verificar Atualizações" checking_for_updates: "A Verificar Atualizações..." + about: "Acerca do UltraLog" version: "Versão %{version}" # Files panel (src/ui/files_panel.rs) @@ -53,13 +50,40 @@ channels: with_data: "Com Dados (%{count})" empty: "Vazios (%{count})" +# Log compare (src/ui/tool_properties_panel.rs, src/ui/channels.rs, src/ui/menu.rs) +compare: + title: "Comparar" + none: "Nenhum" + open_file: "Abrir ficheiro…" + compare_with: "Comparar com…" + hint: "Os canais são emparelhados pelo nome. O registo B é tracejado." + no_other_files: "Carregue um segundo registo para o comparar com este." + log_a: "Registo A" + log_b: "Registo B" + not_in_b: "não está em B" + remove_pair: "Remover par" + offset: "Desvio do registo B" + offset_hint: "Segundos somados aos tempos do registo B. Arraste ou escreva um valor." + reset: "Repor" + align: "Alinhar no cursor" + align_hint: "Coloque o cursor num evento do registo A, clique aqui e depois clique no mesmo evento do registo B." + align_no_cursor: "Clique no gráfico para colocar o cursor primeiro." + align_pick: "Clique no ponto correspondente do registo B." + align_pick_rest: "Esc cancela." + align_cancel: "Cancelar" + # Settings panel (src/ui/settings_panel.rs) settings: language: "Idioma" language_desc: "Selecionar idioma de apresentação" display: "Visualização" + ultra_intensity: "Intensidade Ultra" + ultra_intensity_desc: "Quantidade de néon que a aplicação apresenta. Nunca altera as cores dos dados nem o contraste do texto." + ultra_intensity_locked: "Bloqueado enquanto o Alto contraste estiver ativado: sem brilho, gradientes nem grelha de fundo." color_blind_mode: "Modo Daltonismo" - color_blind_desc: "Usar paleta de cores acessível (paleta de Wong)" + color_blind_desc: "Cores de dados seguras para daltonismo, linhas tracejadas e rotuladas e contraste de texto mais forte." + high_contrast: "Alto contraste" + high_contrast_desc: "Fundos pretos, texto branco e controlos com contorno. Desliga os efeitos de néon." font_size: "Tamanho da Letra:" cursor_tracking: "Seguimento do Cursor" cursor_tracking_desc: "Manter o cursor centrado durante a navegação" @@ -99,6 +123,9 @@ timeline: speed: "Velocidade:" time: "Tempo: %{time}" record: "Registo %{current} de %{total}" + play: "Reproduzir" + pause: "Pausa" + stop: "Parar" # Formula editor (src/ui/formula_editor.rs) formula: @@ -172,9 +199,10 @@ toast: export_png_success: "Gráfico exportado como PNG" export_pdf_success: "Gráfico exportado como PDF" export_failed: "Falha na exportação: %{error}" + export_copied: "Imagem copiada para a área de transferência" histogram_exported_png: "Histograma exportado como PNG" histogram_exported_pdf: "Histograma exportado como PDF" - table_exported_png: "Tabela exportada como PNG (apenas células; use CSV para valores)" + table_exported_png: "Tabela exportada como PNG" table_exported_pdf: "Tabela exportada como PDF" scatter_exported_png: "Gráfico de dispersão exportado como PNG" scatter_exported_pdf: "Gráfico de dispersão exportado como PDF" @@ -209,6 +237,7 @@ common: duplicate: "Duplicar" apply: "Aplicar" close: "Fechar" + cancel: "Cancelar" reset: "Repor" # Computed channels manager (src/ui/computed_channels_manager.rs) @@ -325,10 +354,42 @@ histogram: filter_min: "Min" filter_max: "Max" no_filters: "Nenhum filtro ativo" + scale_hits: "Contagem de Ocorrências" + scale_average: "Valor médio" # Chart (src/ui/chart.rs) chart: select_channels: "Selecione canais para apresentar o gráfico" + cursor: "Cursor" + rich: "rica" + target: "alvo" + lean: "pobre" + +# Export dialog (src/ui/export_dialog.rs, src/ui/tools_panel.rs) +export: + open: "Exportar..." + title_chart: "Exportar gráfico" + title_scatter: "Exportar gráficos de dispersão" + title_histogram: "Exportar histograma" + title_table: "Exportar tabela" + format: "Formato" + paper: "Papel" + paper_desc: "Claro. Predefinido; imprime bem." + graphite: "Grafite" + graphite_desc: "Cinzento escuro, poupa tinta." + cvd_note: "As exportações usam sempre a paleta segura para daltonismo, para que qualquer pessoa com quem partilhe as consiga ler." + export_pdf: "Exportar PDF" + export_png: "Exportar PNG" + copy_image: "Copiar imagem" + copy_tooltip: "Copia o gráfico ou a tabela sem cabeçalho nem rodapé, para colar num documento" + +# About window (src/ui/about_window.rs) +about: + title: "Acerca do UltraLog" + tagline: "Visualizador de registos de ECU de alto desempenho" + license: "Código aberto sob a licença %{license}" + website: "Website" + source: "Código-fonte" # Analysis panel (src/ui/analysis_panel.rs) analysis: @@ -401,6 +462,10 @@ table_gen: copy_failed: "Falha ao copiar para a área de transferência" exported: "Tabela exportada para %{path}" export_failed: "Falha na exportação" + legend_high: "confiança alta" + legend_medium: "confiança média" + legend_low: "confiança baixa" + legend_empty: "sem amostras" # Tool switcher (src/ui/tool_switcher.rs) tools: @@ -438,8 +503,8 @@ track_map: color_by: "Colorir por" solid: "Sólido" colormap: "Mapa de cores" - colormap.viridis: "Viridis" - colormap.turbo: "Turbo" + colormap.sequential: "Sequencial" + colormap.diverging: "Divergente" range: "Intervalo" range_auto: "Automático" lap: "Volta" diff --git a/i18n/ru.yaml b/i18n/ru.yaml index 1d146296..d05b7537 100644 --- a/i18n/ru.yaml +++ b/i18n/ru.yaml @@ -6,11 +6,7 @@ menu: open_log_file: "Открыть лог-файл..." close_tab: "Закрыть вкладку" export: "Экспорт" - export_png: "Экспортировать в PNG..." - export_pdf: "Экспортировать в PDF..." - export_histogram_pdf: "Экспортировать гистограмму в PDF..." - export_table_png: "Экспортировать таблицу в PNG..." - export_table_pdf: "Экспортировать таблицу в PDF..." + export_ellipsis: "Экспорт..." view: "Вид" show_grid: "Показывать сетку" tool_mode: "Режим инструмента" @@ -30,6 +26,7 @@ menu: support_development: "Поддержать разработку" check_for_updates: "Проверить обновления" checking_for_updates: "Проверка обновлений..." + about: "О UltraLog" version: "Версия %{version}" # Files panel (src/ui/files_panel.rs) @@ -53,13 +50,40 @@ channels: with_data: "С данными (%{count})" empty: "Пустые (%{count})" +# Log compare (src/ui/tool_properties_panel.rs, src/ui/channels.rs, src/ui/menu.rs) +compare: + title: "Сравнение" + none: "Нет" + open_file: "Открыть файл…" + compare_with: "Сравнить с…" + hint: "Каналы сопоставляются по имени. Лог B — пунктир." + no_other_files: "Загрузите второй лог, чтобы сравнить его с этим." + log_a: "Лог A" + log_b: "Лог B" + not_in_b: "нет в B" + remove_pair: "Удалить пару" + offset: "Смещение лога B" + offset_hint: "Секунды, добавляемые ко времени лога B. Перетащите или введите значение." + reset: "Сбросить" + align: "Выровнять по курсору" + align_hint: "Поставьте курсор на событие в логе A, нажмите здесь, затем щёлкните то же событие в логе B." + align_no_cursor: "Сначала щёлкните по графику, чтобы поставить курсор." + align_pick: "Щёлкните соответствующую точку лога B." + align_pick_rest: "Esc — отмена." + align_cancel: "Отмена" + # Settings panel (src/ui/settings_panel.rs) settings: language: "Язык" language_desc: "Выберите язык интерфейса" display: "Отображение" + ultra_intensity: "Интенсивность Ultra" + ultra_intensity_desc: "Насколько сильно приложение использует неоновые эффекты. Никогда не меняет цвета данных и контраст текста." + ultra_intensity_locked: "Заблокировано, пока включён высокий контраст: без свечения, градиентов и декоративной сетки." color_blind_mode: "Режим для дальтоников" - color_blind_desc: "Использовать доступную цветовую палитру (палитра Вонга)" + color_blind_desc: "Цвета данных, безопасные для дальтоников, пунктирные и подписанные линии и более контрастный текст." + high_contrast: "Высокий контраст" + high_contrast_desc: "Чёрный фон, белый текст и элементы управления с обводкой. Отключает неоновые эффекты." font_size: "Размер шрифта:" cursor_tracking: "Отслеживание курсора" cursor_tracking_desc: "Удерживать курсор в центре при прокрутке" @@ -99,6 +123,9 @@ timeline: speed: "Скорость:" time: "Время: %{time}" record: "Запись %{current} из %{total}" + play: "Воспроизвести" + pause: "Пауза" + stop: "Стоп" # Formula editor (src/ui/formula_editor.rs) formula: @@ -172,9 +199,10 @@ toast: export_png_success: "График экспортирован в PNG" export_pdf_success: "График экспортирован в PDF" export_failed: "Ошибка экспорта: %{error}" + export_copied: "Изображение скопировано в буфер обмена" histogram_exported_png: "Гистограмма экспортирована в PNG" histogram_exported_pdf: "Гистограмма экспортирована в PDF" - table_exported_png: "Таблица экспортирована в PNG (только ячейки; значения в CSV)" + table_exported_png: "Таблица экспортирована в PNG" table_exported_pdf: "Таблица экспортирована в PDF" scatter_exported_png: "Диаграмма рассеяния экспортирована в PNG" scatter_exported_pdf: "Диаграмма рассеяния экспортирована в PDF" @@ -209,6 +237,7 @@ common: duplicate: "Дублировать" apply: "Применить" close: "Закрыть" + cancel: "Отмена" reset: "Сбросить" # Computed channels manager (src/ui/computed_channels_manager.rs) @@ -325,10 +354,42 @@ histogram: filter_min: "Мин" filter_max: "Макс" no_filters: "Активные фильтры отсутствуют" + scale_hits: "Количество попаданий" + scale_average: "Среднее значение" # Chart (src/ui/chart.rs) chart: select_channels: "Выберите каналы для отображения графика" + cursor: "Курсор" + rich: "богатая" + target: "цель" + lean: "бедная" + +# Export dialog (src/ui/export_dialog.rs, src/ui/tools_panel.rs) +export: + open: "Экспорт..." + title_chart: "Экспорт графика" + title_scatter: "Экспорт диаграмм рассеяния" + title_histogram: "Экспорт гистограммы" + title_table: "Экспорт таблицы" + format: "Формат" + paper: "Бумага" + paper_desc: "Светлая. По умолчанию; печатается чисто." + graphite: "Графит" + graphite_desc: "Тёмно-серая, экономит чернила." + cvd_note: "Экспорт всегда использует палитру, безопасную для дальтоников, поэтому его смогут прочитать все, с кем вы поделитесь." + export_pdf: "Экспорт в PDF" + export_png: "Экспорт в PNG" + copy_image: "Копировать изображение" + copy_tooltip: "Копировать график или таблицу без заголовка и подвала, чтобы вставить в документ" + +# About window (src/ui/about_window.rs) +about: + title: "О UltraLog" + tagline: "Высокопроизводительный просмотрщик логов ECU" + license: "Открытый исходный код под лицензией %{license}" + website: "Веб-сайт" + source: "Исходный код" # Analysis panel (src/ui/analysis_panel.rs) analysis: @@ -401,6 +462,10 @@ table_gen: copy_failed: "Ошибка копирования в буфер обмена" exported: "Таблица экспортирована в %{path}" export_failed: "Ошибка экспорта" + legend_high: "высокая уверенность" + legend_medium: "средняя уверенность" + legend_low: "низкая уверенность" + legend_empty: "нет выборок" # Tool switcher (src/ui/tool_switcher.rs) tools: @@ -438,8 +503,8 @@ track_map: color_by: "Цвет по" solid: "Сплошной" colormap: "Палитра" - colormap.viridis: "Viridis" - colormap.turbo: "Turbo" + colormap.sequential: "Последовательная" + colormap.diverging: "Расходящаяся" range: "Диапазон" range_auto: "Авто" lap: "Круг" diff --git a/i18n/ur.yaml b/i18n/ur.yaml index 1cfc0cab..69ea4b78 100644 --- a/i18n/ur.yaml +++ b/i18n/ur.yaml @@ -7,11 +7,7 @@ menu: open_log_file: "لاگ فائل کھولیں..." close_tab: "ٹیب بند کریں" export: "برآمد" - export_png: "PNG کے طور پر برآمد کریں..." - export_pdf: "PDF کے طور پر برآمد کریں..." - export_histogram_pdf: "ہسٹوگرام PDF کے طور پر برآمد کریں..." - export_table_png: "ٹیبل PNG کے طور پر برآمد کریں..." - export_table_pdf: "ٹیبل PDF کے طور پر برآمد کریں..." + export_ellipsis: "برآمد..." view: "منظر" show_grid: "گرڈ دکھائیں" tool_mode: "ٹول موڈ" @@ -31,6 +27,7 @@ menu: support_development: "ترقی میں مدد کریں" check_for_updates: "اپڈیٹس کی جانچ کریں" checking_for_updates: "اپڈیٹس کی جانچ ہو رہی ہے..." + about: "UltraLog کے بارے میں" version: "ورژن %{version}" # Files panel (src/ui/files_panel.rs) @@ -54,13 +51,40 @@ channels: with_data: "ڈیٹا والے (%{count})" empty: "خالی (%{count})" +# Log compare (src/ui/tool_properties_panel.rs, src/ui/channels.rs, src/ui/menu.rs) +compare: + title: "موازنہ" + none: "کوئی نہیں" + open_file: "فائل کھولیں…" + compare_with: "اس سے موازنہ کریں…" + hint: "چینل نام سے جوڑے جاتے ہیں۔ لاگ B ڈیش والی لکیر ہے۔" + no_other_files: "اس سے موازنہ کے لیے دوسرا لاگ لوڈ کریں۔" + log_a: "لاگ A" + log_b: "لاگ B" + not_in_b: "B میں نہیں" + remove_pair: "جوڑا ہٹائیں" + offset: "لاگ B آفسیٹ" + offset_hint: "لاگ B کے اوقات میں شامل کیے جانے والے سیکنڈ۔ گھسیٹیں یا قدر لکھیں۔" + reset: "ری سیٹ" + align: "کرسر پر سیدھ کریں" + align_hint: "کرسر کو لاگ A کے کسی واقعے پر رکھیں، یہاں کلک کریں، پھر لاگ B میں وہی واقعہ کلک کریں۔" + align_no_cursor: "پہلے کرسر رکھنے کے لیے چارٹ پر کلک کریں۔" + align_pick: "لاگ B کا مماثل نقطہ کلک کریں۔" + align_pick_rest: "Esc منسوخ کرتا ہے۔" + align_cancel: "منسوخ کریں" + # Settings panel (src/ui/settings_panel.rs) settings: language: "زبان" language_desc: "ڈسپلے زبان منتخب کریں" display: "ڈسپلے" + ultra_intensity: "Ultra شدت" + ultra_intensity_desc: "ایپ پر نیون اثر کتنا نظر آئے۔ آپ کے ڈیٹا کے رنگ یا متن کا کنٹراسٹ کبھی تبدیل نہیں ہوتا۔" + ultra_intensity_locked: "ہائی کنٹراسٹ آن ہونے پر بند رہتا ہے: کوئی چمک، گریڈینٹ یا پس منظر کا گرڈ نہیں۔" color_blind_mode: "کلر بلائنڈ موڈ" - color_blind_desc: "قابل رسائی رنگ پیلٹ استعمال کریں (Wong's پیلٹ)" + color_blind_desc: "رنگ اندھے پن کے لیے محفوظ ڈیٹا رنگ، ڈیش اور لیبل والی لکیریں، اور زیادہ واضح متن کنٹراسٹ۔" + high_contrast: "ہائی کنٹراسٹ" + high_contrast_desc: "سیاہ پس منظر، سفید متن اور خاکہ دار کنٹرول۔ نیون اثرات بند کر دیتا ہے۔" font_size: "فونٹ سائز:" cursor_tracking: "کرسر ٹریکنگ" cursor_tracking_desc: "سکربنگ کے دوران کرسر کو مرکز میں رکھیں" @@ -100,6 +124,9 @@ timeline: speed: "رفتار:" time: "وقت: %{time}" record: "ریکارڈ %{current} از %{total}" + play: "چلائیں" + pause: "وقفہ" + stop: "روکیں" # Formula editor (src/ui/formula_editor.rs) formula: @@ -173,9 +200,10 @@ toast: export_png_success: "چارٹ PNG کے طور پر برآمد ہوگیا" export_pdf_success: "چارٹ PDF کے طور پر برآمد ہوگیا" export_failed: "برآمد ناکام: %{error}" + export_copied: "تصویر کلپ بورڈ پر کاپی ہوگئی" histogram_exported_png: "ہسٹوگرام PNG کے طور پر برآمد ہوگیا" histogram_exported_pdf: "ہسٹوگرام PDF کے طور پر برآمد ہوگیا" - table_exported_png: "ٹیبل PNG کے طور پر برآمد ہوا (صرف خلیے؛ اقدار کے لیے CSV)" + table_exported_png: "ٹیبل PNG کے طور پر برآمد ہوا" table_exported_pdf: "ٹیبل PDF کے طور پر برآمد ہوا" scatter_exported_png: "سکیٹر پلاٹ PNG کے طور پر برآمد ہوگیا" scatter_exported_pdf: "سکیٹر پلاٹ PDF کے طور پر برآمد ہوگیا" @@ -210,6 +238,7 @@ common: duplicate: "نقل بنائیں" apply: "لاگو کریں" close: "بند کریں" + cancel: "منسوخ" reset: "ری سیٹ" # Computed channels manager (src/ui/computed_channels_manager.rs) @@ -326,10 +355,42 @@ histogram: filter_min: "کم سے کم" filter_max: "زیادہ سے زیادہ" no_filters: "کوئی فلٹر فعال نہیں" + scale_hits: "ہٹ کاؤنٹ" + scale_average: "اوسط قدر" # Chart (src/ui/chart.rs) chart: select_channels: "چارٹ دکھانے کے لیے چینلز منتخب کریں" + cursor: "کرسر" + rich: "رچ" + target: "ہدف" + lean: "لین" + +# Export dialog (src/ui/export_dialog.rs, src/ui/tools_panel.rs) +export: + open: "برآمد..." + title_chart: "چارٹ برآمد کریں" + title_scatter: "سکیٹر پلاٹ برآمد کریں" + title_histogram: "ہسٹوگرام برآمد کریں" + title_table: "ٹیبل برآمد کریں" + format: "فارمیٹ" + paper: "کاغذ" + paper_desc: "ہلکا۔ ڈیفالٹ؛ صاف چھپتا ہے۔" + graphite: "گریفائٹ" + graphite_desc: "گہرا، سیاہی بچانے والا خاکستری۔" + cvd_note: "برآمدات ہمیشہ رنگ-اندھے پن کے لیے محفوظ پیلیٹ استعمال کرتی ہیں، تاکہ جن کے ساتھ آپ شیئر کریں وہ انہیں پڑھ سکیں۔" + export_pdf: "PDF برآمد کریں" + export_png: "PNG برآمد کریں" + copy_image: "تصویر کاپی کریں" + copy_tooltip: "ہیڈر یا فوٹر کے بغیر چارٹ یا ٹیبل کاپی کریں، تاکہ دستاویز میں چسپاں کیا جا سکے" + +# About window (src/ui/about_window.rs) +about: + title: "UltraLog کے بارے میں" + tagline: "اعلیٰ کارکردگی والا ECU لاگ ویور" + license: "%{license} لائسنس کے تحت اوپن سورس" + website: "ویب سائٹ" + source: "سورس کوڈ" # Analysis panel (src/ui/analysis_panel.rs) analysis: @@ -402,6 +463,10 @@ table_gen: copy_failed: "کلپ بورڈ پر کاپی کرنے میں ناکام" exported: "ٹیبل %{path} پر برآمد کیا گیا" export_failed: "برآمد ناکام" + legend_high: "اعلیٰ اعتماد" + legend_medium: "درمیانہ اعتماد" + legend_low: "کم اعتماد" + legend_empty: "کوئی نمونہ نہیں" # Tool switcher (src/ui/tool_switcher.rs) tools: @@ -439,8 +504,8 @@ track_map: color_by: "رنگ کریں" solid: "ٹھوس" colormap: "رنگ نقشہ" - colormap.viridis: "Viridis" - colormap.turbo: "Turbo" + colormap.sequential: "ترتیبی" + colormap.diverging: "دو طرفہ" range: "رینج" range_auto: "خودکار" lap: "چکر" diff --git a/i18n/zh-CN.yaml b/i18n/zh-CN.yaml index 6e490151..d0e51381 100644 --- a/i18n/zh-CN.yaml +++ b/i18n/zh-CN.yaml @@ -6,11 +6,7 @@ menu: open_log_file: "打开日志文件..." close_tab: "关闭标签页" export: "导出" - export_png: "导出为 PNG..." - export_pdf: "导出为 PDF..." - export_histogram_pdf: "导出直方图为 PDF..." - export_table_png: "导出表格为 PNG..." - export_table_pdf: "导出表格为 PDF..." + export_ellipsis: "导出..." view: "视图" show_grid: "显示网格" tool_mode: "工具模式" @@ -30,6 +26,7 @@ menu: support_development: "支持开发" check_for_updates: "检查更新" checking_for_updates: "正在检查更新..." + about: "关于 UltraLog" version: "版本 %{version}" # 文件面板 (src/ui/files_panel.rs) @@ -58,8 +55,13 @@ settings: language: "语言" language_desc: "选择显示语言" display: "显示" + ultra_intensity: "Ultra 强度" + ultra_intensity_desc: "应用显示多少霓虹效果。不会改变数据颜色或文字对比度。" + ultra_intensity_locked: "开启高对比度时锁定:无光晕、渐变或背景网格。" color_blind_mode: "色盲模式" - color_blind_desc: "使用无障碍色彩方案 (Wong 配色)" + color_blind_desc: "色盲友好的数据颜色、虚线和带标签的线条,以及更强的文字对比度。" + high_contrast: "高对比度" + high_contrast_desc: "黑色背景、白色文字和描边控件。关闭霓虹效果。" font_size: "字体大小:" cursor_tracking: "光标跟踪" cursor_tracking_desc: "拖动时保持光标居中" @@ -99,6 +101,9 @@ timeline: speed: "速度:" time: "时间: %{time}" record: "记录 %{current} / %{total}" + play: "播放" + pause: "暂停" + stop: "停止" # 公式编辑器 (src/ui/formula_editor.rs) formula: @@ -172,9 +177,10 @@ toast: export_png_success: "图表已导出为 PNG" export_pdf_success: "图表已导出为 PDF" export_failed: "导出失败: %{error}" + export_copied: "图像已复制到剪贴板" histogram_exported_png: "直方图已导出为 PNG" histogram_exported_pdf: "直方图已导出为 PDF" - table_exported_png: "表格已导出为 PNG(仅单元格;数值请使用 CSV)" + table_exported_png: "表格已导出为 PNG" table_exported_pdf: "表格已导出为 PDF" scatter_exported_png: "散点图已导出为 PNG" scatter_exported_pdf: "散点图已导出为 PDF" @@ -209,6 +215,7 @@ common: duplicate: "复制" apply: "应用" close: "关闭" + cancel: "取消" reset: "重置" # 计算通道管理器 (src/ui/computed_channels_manager.rs) @@ -325,10 +332,42 @@ histogram: filter_min: "最小值" filter_max: "最大值" no_filters: "没有活动过滤器" + scale_hits: "命中次数" + scale_average: "平均值" # 图表 (src/ui/chart.rs) chart: select_channels: "选择通道以显示图表" + cursor: "光标" + rich: "浓" + target: "目标" + lean: "稀" + +# Export dialog (src/ui/export_dialog.rs, src/ui/tools_panel.rs) +export: + open: "导出..." + title_chart: "导出图表" + title_scatter: "导出散点图" + title_histogram: "导出直方图" + title_table: "导出表格" + format: "格式" + paper: "纸张" + paper_desc: "浅色。默认;打印清晰。" + graphite: "石墨" + graphite_desc: "深灰色,节省墨水。" + cvd_note: "导出内容始终使用色盲安全配色,分享对象都能看清。" + export_pdf: "导出 PDF" + export_png: "导出 PNG" + copy_image: "复制图像" + copy_tooltip: "复制图表或表格(不含页眉页脚),可粘贴到文档中" + +# About window (src/ui/about_window.rs) +about: + title: "关于 UltraLog" + tagline: "高性能 ECU 日志查看器" + license: "基于 %{license} 许可证的开源软件" + website: "网站" + source: "源代码" # 分析面板 (src/ui/analysis_panel.rs) analysis: @@ -402,6 +441,10 @@ table_gen: copy_failed: "复制到剪贴板失败" exported: "表格导出到%{path}" export_failed: "导出失败" + legend_high: "高置信度" + legend_medium: "中置信度" + legend_low: "低置信度" + legend_empty: "无样本" # Tool switcher (src/ui/tool_switcher.rs) tools: @@ -439,8 +482,8 @@ track_map: color_by: "按以下方式着色" solid: "实心" colormap: "颜色映射" - colormap.viridis: "Viridis" - colormap.turbo: "Turbo" + colormap.sequential: "顺序" + colormap.diverging: "发散" range: "范围" range_auto: "自动" lap: "圈" @@ -455,3 +498,25 @@ track_map: tiles.privacy_notice: "地图图块从 %{provider} 下载。您赛道的大致位置将共享给该提供商。" tooltip.time: "时间" tooltip.position: "位置" + +# Log compare (src/ui/tool_properties_panel.rs, src/ui/channels.rs, src/ui/menu.rs) +compare: + title: "对比" + none: "无" + open_file: "打开文件…" + compare_with: "对比…" + hint: "通道按名称配对。日志 B 为虚线。" + no_other_files: "加载第二个日志以与此日志对比。" + log_a: "日志 A" + log_b: "日志 B" + not_in_b: "B 中没有" + remove_pair: "移除配对" + offset: "日志 B 偏移" + offset_hint: "加到日志 B 时间上的秒数。拖动或输入数值。" + reset: "重置" + align: "在光标处对齐" + align_hint: "将光标放在日志 A 的某个事件上,点击此处,然后点击日志 B 中的同一事件。" + align_no_cursor: "请先点击图表放置光标。" + align_pick: "点击日志 B 中对应的点。" + align_pick_rest: "按 Esc 取消。" + align_cancel: "取消" diff --git a/scripts/brand/build_app_icons.sh b/scripts/brand/build_app_icons.sh new file mode 100755 index 00000000..5b6f58a8 --- /dev/null +++ b/scripts/brand/build_app_icons.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Build assets/icons/ from the app icon master design/logos/ultralog-app-icon-1024.png +# (needs ImageMagick: `brew install imagemagick`). The paths stay fixed: main.rs embeds the PNGs, +# build.rs embeds windows.ico, and release.yml builds the .icns from mac.png. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SRC="$ROOT/design/logos/ultralog-app-icon-1024.png" +OUT="$ROOT/assets/icons" +mkdir -p "$OUT" + +# macOS: Apple's icon grid puts an 824 px tile in a 1024 canvas, so the Dock icon matches the +# size of other apps. The master is full-bleed, so scale it into that tile. +magick "$SRC" -resize 824x824 -background none -gravity center -extent 1024x1024 \ + -strip "$OUT/mac.png" + +# Windows and Linux use the tile full-bleed. +magick "$SRC" -strip "$OUT/windows.png" +magick "$SRC" -strip "$OUT/linux.png" +magick "$SRC" -define icon:auto-resize=256,128,64,48,32,24,16 "$OUT/windows.ico" + +ls -l "$OUT" diff --git a/scripts/brand/rasterize_lockups.sh b/scripts/brand/rasterize_lockups.sh new file mode 100755 index 00000000..74fc47aa --- /dev/null +++ b/scripts/brand/rasterize_lockups.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Rasterize the export lockups from design/logos/ (needs rsvg-convert: `brew install librsvg`). +# Exports embed these PNGs; there is no SVG renderer in the app. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +OUT="$ROOT/assets/brand" +mkdir -p "$OUT" +for name in ultralog-lockup ultralog-lockup-paper; do + rsvg-convert --height 96 --keep-aspect-ratio \ + "$ROOT/design/logos/$name.svg" -o "$OUT/$name.png" +done +ls -l "$OUT" diff --git a/scripts/fonts/build_static_fonts.sh b/scripts/fonts/build_static_fonts.sh new file mode 100755 index 00000000..29f5a292 --- /dev/null +++ b/scripts/fonts/build_static_fonts.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Build the static TTFs in assets/fonts/ from the variable fonts in google/fonts. +# +# egui renders only the default instance of a variable font, so every weight the type scale +# uses (design/design-system/README.md → Typography) is instanced to its own static file. +# The outputs are committed; run this only to update or add a weight. +# +# Usage: scripts/fonts/build_static_fonts.sh [SOURCE_DIR] +# SOURCE_DIR holds the downloaded variable fonts; when omitted they are downloaded to a +# temporary directory. Needs `uv` (fonttools runs through `uv run --with`). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +OUT="$ROOT/assets/fonts" +SRC="${1:-$(mktemp -d)}" +BASE="https://github.com/google/fonts/raw/main/ofl" +FONTTOOLS=(uv run --quiet --no-project --with "fonttools>=4.50,<5" fonttools) + +fetch() { # + [ -f "$SRC/$2" ] || curl -fsSL -o "$SRC/$2" "$BASE/$1" +} + +fetch 'saira/Saira%5Bwdth%2Cwght%5D.ttf' Saira-VF.ttf +fetch 'saira/Saira-Italic%5Bwdth%2Cwght%5D.ttf' Saira-Italic-VF.ttf +fetch 'outfit/Outfit%5Bwght%5D.ttf' Outfit-VF.ttf +fetch 'jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf' JetBrainsMono-VF.ttf +fetch 'notosansdevanagari/NotoSansDevanagari%5Bwdth%2Cwght%5D.ttf' NotoSansDevanagari-VF.ttf +fetch 'notosansbengali/NotoSansBengali%5Bwdth%2Cwght%5D.ttf' NotoSansBengali-VF.ttf +fetch 'notosansarabic/NotoSansArabic%5Bwdth%2Cwght%5D.ttf' NotoSansArabic-VF.ttf +fetch 'notosanssc/NotoSansSC%5Bwght%5D.ttf' NotoSansSC-VF.ttf +for family in saira outfit jetbrainsmono notosansdevanagari notosansbengali notosansarabic notosanssc; do + fetch "$family/OFL.txt" "$family-OFL.txt" +done + +mkdir -p "$OUT" +instance() { # ... + local src="$1" out="$2" + shift 2 + "${FONTTOOLS[@]}" varLib.instancer --quiet "$SRC/$src" "$@" -o "$OUT/$out" +} + +# Brand faces (weights from the type scale). +instance Saira-VF.ttf Saira-SemiBold.ttf wght=600 wdth=100 +instance Saira-Italic-VF.ttf Saira-BoldItalic.ttf wght=700 wdth=100 +instance Saira-Italic-VF.ttf Saira-ExtraBoldItalic.ttf wght=800 wdth=100 +instance Outfit-VF.ttf Outfit-Regular.ttf wght=400 +instance Outfit-VF.ttf Outfit-Medium.ttf wght=500 +instance Outfit-VF.ttf Outfit-SemiBold.ttf wght=600 +instance JetBrainsMono-VF.ttf JetBrainsMono-Regular.ttf wght=400 +instance JetBrainsMono-VF.ttf JetBrainsMono-Medium.ttf wght=500 + +# Script fallbacks for the hi, bn, ar and ur locales. +instance NotoSansDevanagari-VF.ttf NotoSansDevanagari-Regular.ttf wght=400 wdth=100 +instance NotoSansBengali-VF.ttf NotoSansBengali-Regular.ttf wght=400 wdth=100 +instance NotoSansArabic-VF.ttf NotoSansArabic-Regular.ttf wght=400 wdth=100 + +# The language picker names Chinese and Japanese in their own scripts in every locale. Bundling +# full CJK would add ~10 MB, so ship only those glyphs; the ja / zh-CN UI loads a system CJK font +# at runtime (src/ui/fonts.rs). +instance NotoSansSC-VF.ttf NotoSansSC-tmp.ttf wght=400 +"${FONTTOOLS[@]}" subset "$OUT/NotoSansSC-tmp.ttf" --text="简体中文日本語" \ + --layout-features='*' --output-file="$OUT/NotoSansSC-LanguageNames.ttf" +rm "$OUT/NotoSansSC-tmp.ttf" + +cp "$SRC/saira-OFL.txt" "$OUT/Saira-OFL.txt" +cp "$SRC/outfit-OFL.txt" "$OUT/Outfit-OFL.txt" +cp "$SRC/jetbrainsmono-OFL.txt" "$OUT/JetBrainsMono-OFL.txt" +cp "$SRC/notosansdevanagari-OFL.txt" "$OUT/NotoSansDevanagari-OFL.txt" +cp "$SRC/notosansbengali-OFL.txt" "$OUT/NotoSansBengali-OFL.txt" +cp "$SRC/notosansarabic-OFL.txt" "$OUT/NotoSansArabic-OFL.txt" +cp "$SRC/notosanssc-OFL.txt" "$OUT/NotoSansSC-OFL.txt" + +ls -l "$OUT" diff --git a/src/analysis/tables/mod.rs b/src/analysis/tables/mod.rs index 3b2b84e4..6c6dacb0 100644 --- a/src/analysis/tables/mod.rs +++ b/src/analysis/tables/mod.rs @@ -95,6 +95,18 @@ pub struct MeasureSpec { pub decimals: usize, } +impl MeasureSpec { + /// Whether the measure is a lambda deviation where positive means leaner than target, so a + /// diverging scale may be labelled rich · target · lean. Other signed measures (Lambda Delay's + /// PW step, where positive means more fuel) get the plain ± scale. + pub fn lean_is_positive(&self) -> bool { + matches!( + self.key, + "correction_pct" | "depth_lambda" | "area_pct" | "area_lambda_s" + ) + } +} + /// One detected event, accepted or rejected. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TableEvent { diff --git a/src/analytics.rs b/src/analytics.rs index cf241156..38e1a16e 100644 --- a/src/analytics.rs +++ b/src/analytics.rs @@ -190,6 +190,28 @@ pub fn track_update_checked(update_available: bool) { capture_event(event); } +/// Track a change of the Ultra intensity slider (sent when the drag ends, not per frame) +pub fn track_ultra_intensity_changed(intensity: u8) { + let mut event = create_event("ultra_intensity_changed"); + + event + .properties + .insert("intensity".to_string(), serde_json::json!(intensity)); + + capture_event(event); +} + +/// Track toggling the High contrast theme +pub fn track_high_contrast_toggled(enabled: bool) { + let mut event = create_event("high_contrast_toggled"); + + event + .properties + .insert("enabled".to_string(), serde_json::json!(enabled)); + + capture_event(event); +} + /// Track colorblind mode toggle pub fn track_colorblind_mode_toggled(enabled: bool) { let mut event = create_event("colorblind_mode_toggled"); diff --git a/src/app.rs b/src/app.rs index 8de45909..0510a481 100644 --- a/src/app.rs +++ b/src/app.rs @@ -27,8 +27,8 @@ use crate::parsers::{ }; use crate::settings::UserSettings; use crate::state::{ - ActivePanel, ActiveTool, CHART_COLORS, COLORBLIND_COLORS, CacheKey, DownsampleCache, FontScale, - LoadResult, LoadedFile, LoadingState, MAX_CHANNELS, MAX_CHANNELS_PER_PLOT, MAX_TOTAL_CHANNELS, + ActivePanel, ActiveTool, CacheKey, DownsampleCache, FontScale, LoadResult, LoadedFile, + LoadingState, LogRole, MAX_CHANNELS, MAX_CHANNELS_PER_PLOT, MAX_TOTAL_CHANNELS, MIN_PLOT_HEIGHT, PlotArea, ScatterHistogramCache, ScatterPlotConfig, ScatterPlotState, SelectedChannel, Tab, TileProviderId, ToastType, }; @@ -56,6 +56,11 @@ pub struct UltraLogApp { /// Starting a second load while one is in flight would drop the first /// receiver and silently lose its result. pending_loads: Vec, + /// The file the background load is reading, if any. + loading_path: Option, + /// "Open file…" requests from a tab's Compare row: (path, `Tab::id`). When that file + /// finishes loading, it becomes log B of that tab (if the tab is still open). + pending_compare: Vec<(PathBuf, u64)>, /// Current loading state pub(crate) loading_state: LoadingState, /// Cache for channel min/max values (avoids O(n) scans) @@ -101,6 +106,26 @@ pub struct UltraLogApp { // === Accessibility === /// When true, use colorblind-friendly color palette pub(crate) color_blind_mode: bool, + /// Ultra intensity, 0..=100 (35 = Night Drive) + pub(crate) ultra_intensity: u8, + /// High contrast theme; locks the intensity effects off + pub(crate) high_contrast: bool, + // === Export === + /// ExportDialog choices, remembered across sessions. + pub(crate) export_choice: crate::ui::export_sheet::ExportChoice, + pub(crate) show_export_dialog: bool, + pub(crate) show_about: bool, + // === Theme === + /// Live Night Drive tokens, rebuilt by `sync_theme` when a theme setting changes. + pub(crate) tokens: crate::ui::theme::Tokens, + /// Settings the current `tokens` were built from (`None` until the first install). + applied_theme: Option, + /// Incremented on every theme install; caches of derived colours key on it. + pub(crate) theme_generation: u64, + /// Font scale the installed text styles were built for (`None` until the first install). + applied_font_scale: Option, + /// Language the installed fonts were built for; only `ja` / `zh-CN` load a system CJK font. + applied_font_language: Option, /// When true, normalize field names to standard names pub(crate) field_normalization: bool, // === Chart View State === @@ -222,6 +247,8 @@ impl Default for UltraLogApp { last_drop_time: None, load_receiver: None, pending_loads: Vec::new(), + loading_path: None, + pending_compare: Vec::new(), loading_state: LoadingState::Idle, minmax_cache: HashMap::new(), chart_last_x_bounds: HashMap::new(), @@ -237,6 +264,16 @@ impl Default for UltraLogApp { last_frame_time: None, playback_speed: 1.0, color_blind_mode: false, + ultra_intensity: crate::ui::theme::DEFAULT_INTENSITY as u8, + high_contrast: false, + export_choice: Default::default(), + show_export_dialog: false, + show_about: false, + tokens: crate::ui::theme::tokens_at(crate::ui::theme::ThemeSettings::default()), + applied_theme: None, + theme_generation: 0, + applied_font_scale: None, + applied_font_language: None, field_normalization: true, // Enabled by default for better readability initial_view_seconds: 60.0, // Start with 60 second view scroll_to_zoom: false, @@ -289,42 +326,10 @@ impl Default for UltraLogApp { } impl UltraLogApp { - /// Create a new UltraLogApp instance with custom fonts + /// Create a new UltraLogApp instance pub fn new(cc: &eframe::CreationContext<'_>) -> Self { - // Load custom Outfit font - let mut fonts = egui::FontDefinitions::default(); - - // Load Outfit Regular - fonts.font_data.insert( - "Outfit-Regular".to_owned(), - egui::FontData::from_static(include_bytes!("../assets/Outfit-Regular.ttf")).into(), - ); - - // Load Outfit Bold - fonts.font_data.insert( - "Outfit-Bold".to_owned(), - egui::FontData::from_static(include_bytes!("../assets/Outfit-Bold.ttf")).into(), - ); - - // Set Outfit as the primary proportional font - fonts - .families - .entry(egui::FontFamily::Proportional) - .or_default() - .insert(0, "Outfit-Regular".to_owned()); - - // Add bold variant for strong text - fonts - .families - .entry(egui::FontFamily::Name("Bold".into())) - .or_default() - .insert(0, "Outfit-Bold".to_owned()); - - // Apply fonts - cc.egui_ctx.set_fonts(fonts); - - // Apply the dark theme once at startup (the app is dark-only) - cc.egui_ctx.set_visuals(egui::Visuals::dark()); + // Fonts and the Night Drive theme are installed by `sync_fonts` / `sync_theme`, + // before the first frame below. // Load user settings and set locale let user_settings = UserSettings::load(); @@ -339,6 +344,9 @@ impl UltraLogApp { unit_preferences: user_settings.unit_preferences.clone(), font_scale: user_settings.font_scale, color_blind_mode: user_settings.color_blind_mode, + ultra_intensity: user_settings.ultra_intensity.min(100), + high_contrast: user_settings.high_contrast, + export_choice: user_settings.export, field_normalization: user_settings.field_normalization, cursor_tracking: user_settings.cursor_tracking, auto_check_updates: user_settings.auto_check_updates, @@ -387,6 +395,8 @@ impl UltraLogApp { } } + app.sync_fonts(&cc.egui_ctx); + app.sync_theme(&cc.egui_ctx); app } @@ -394,14 +404,61 @@ impl UltraLogApp { // Color and Unit Helpers // ======================================================================== - /// Get color for a channel based on color blind mode setting - pub fn get_channel_color(&self, color_index: usize) -> [u8; 3] { - let palette = if self.color_blind_mode { - COLORBLIND_COLORS - } else { - CHART_COLORS + /// Colour and stroke of the series in `color_index`. Color blind mode and High contrast use + /// the eight Okabe-Ito colours; see `theme::series`. + pub fn series_style( + &self, + color_index: usize, + ) -> (egui::Color32, crate::ui::theme::SeriesStroke) { + crate::ui::theme::series( + &self.tokens, + color_index, + self.color_blind_mode || self.high_contrast, + ) + } + + /// Colour and stroke of a selected channel in the active tab: [`Self::series_style`], or + /// `theme::compare_series` while the tab compares two logs (log B dashed). + pub fn channel_style( + &self, + selected: &SelectedChannel, + ) -> (egui::Color32, crate::ui::theme::SeriesStroke) { + match self.active_tab.map(|idx| &self.tabs[idx]) { + Some(tab) if tab.compare.is_some() => crate::ui::theme::compare_series( + &self.tokens, + selected.color_index, + self.color_blind_mode || self.high_contrast, + tab.log_role(selected) == crate::state::LogRole::B, + ), + _ => self.series_style(selected.color_index), + } + } + + /// The A/B tag of a selected channel while the active tab compares two logs. + pub fn log_tag(&self, selected: &SelectedChannel) -> Option<&'static str> { + let tab = &self.tabs[self.active_tab?]; + tab.compare.as_ref()?; + Some(match tab.log_role(selected) { + crate::state::LogRole::A => "A", + crate::state::LogRole::B => "B", + }) + } + + /// The y range a channel is scaled to: its own min/max, or the union with its compare + /// partner's (rule R1), so a pair is drawn on one scale and a higher peak looks higher. + pub fn pair_y_range(&mut self, selected: &SelectedChannel) -> Option<(f64, f64)> { + let own = self.get_channel_min_max(selected.file_index, selected.channel_index)?; + let partner = self + .active_tab + .and_then(|idx| self.tabs[idx].partner_of(selected)) + .map(|p| (p.file_index, p.channel_index)); + let Some((file, channel)) = partner else { + return Some(own); }; - palette[color_index % palette.len()] + Some(match self.get_channel_min_max(file, channel) { + Some((lo, hi)) => (own.0.min(lo), own.1.max(hi)), + None => own, + }) } /// Get a scaled font size based on user's font scale preference @@ -410,6 +467,39 @@ impl UltraLogApp { (base_size * self.font_scale.multiplier()).round() } + /// The theme settings implied by the live preference fields. + fn theme_settings(&self) -> crate::ui::theme::ThemeSettings { + crate::ui::theme::ThemeSettings { + intensity: f32::from(self.ultra_intensity), + color_blind: self.color_blind_mode, + high_contrast: self.high_contrast, + } + } + + /// Rebuild and install the tokens when a theme setting changed. Runs every frame, so a + /// setting toggled anywhere takes effect on the next frame without its own hook. + pub(crate) fn sync_theme(&mut self, ctx: &egui::Context) { + let settings = self.theme_settings(); + if self.applied_theme == Some(settings) && self.applied_font_scale == Some(self.font_scale) + { + return; + } + self.tokens = crate::ui::theme::tokens_at(settings); + crate::ui::theme::install(ctx, &self.tokens, self.font_scale.multiplier()); + self.applied_theme = Some(settings); + self.applied_font_scale = Some(self.font_scale); + self.theme_generation = self.theme_generation.wrapping_add(1); + } + + /// Reinstall the font families when the language changes (a CJK locale adds a system font). + pub(crate) fn sync_fonts(&mut self, ctx: &egui::Context) { + if self.applied_font_language == Some(self.language) { + return; + } + crate::ui::fonts::install(ctx, self.language); + self.applied_font_language = Some(self.language); + } + // ======================================================================== // File Loading // ======================================================================== @@ -437,6 +527,7 @@ impl UltraLogApp { .unwrap_or_else(|| "Unknown".to_string()); self.loading_state = LoadingState::Loading(filename.clone()); + self.loading_path = Some(path.clone()); let (sender, receiver): (Sender, Receiver) = channel(); self.load_receiver = Some(receiver); @@ -849,11 +940,17 @@ impl UltraLogApp { // Switch to Channels panel so user can select channels self.active_panel = ActivePanel::ToolProperties; + + self.finish_compare_request(file_index); } LoadResult::Error(e) => { self.show_toast_error(&format!("Error: {}", e)); + if let Some(path) = &self.loading_path { + self.pending_compare.retain(|(p, _)| p != path); + } } } + self.loading_path = None; self.load_receiver = None; self.loading_state = LoadingState::Idle; @@ -896,36 +993,180 @@ impl UltraLogApp { } } - /// Find the record index closest to the given time + /// Find the record index closest to the given time in the active tab's file pub fn find_record_at_time(&self, time: f64) -> Option { - // Use the first file with data for record indexing - if let Some(file) = self.files.first() { - let times = file.log.get_times_as_f64(); - if times.is_empty() { - return None; + // The record is stored on the active tab and read against that tab's + // file, so it must come from the same time axis. Searching the first + // loaded file gave "Record 13101 of 10020" once two logs were open. + let file_index = self + .active_tab + .map(|idx| self.tabs[idx].file_index) + .unwrap_or(0); + let file = self.files.get(file_index)?; + crate::state::nearest_record(file.log.get_times_as_f64(), time) + } + + /// The record of `file_index` at tab time `tab_time` in the active tab: the last sample at + /// or before it, after removing log B's offset, or `None` outside that log's span (R4). + pub fn record_at(&self, file_index: usize, tab_time: f64) -> Option { + let offset = self + .active_tab + .map_or(0.0, |idx| self.tabs[idx].time_offset(file_index)); + let file = self.files.get(file_index)?; + crate::state::record_at(file.log.get_times_as_f64(), tab_time - offset) + } + + /// The tab cursor (time, log-A record) for a sample of `file_index` at `record` / `time` in + /// that file's own time. Log B's sample moves onto the tab axis by its offset, and the record + /// becomes log A's record there, which is what the tab cursor holds. + pub fn cursor_for_file_sample( + &self, + file_index: usize, + record: usize, + time: f64, + ) -> (f64, usize) { + match self.active_tab.map(|idx| &self.tabs[idx]) { + Some(tab) if file_index != tab.file_index => { + let tab_time = time + tab.time_offset(file_index); + (tab_time, self.find_record_at_time(tab_time).unwrap_or(0)) } - // Binary search for closest time - let mut low = 0; - let mut high = times.len() - 1; - while low < high { - let mid = (low + high) / 2; - if times[mid] < time { - low = mid + 1; - } else { - high = mid; - } + _ => (time, record), + } + } + + /// The time span of one file, in its own time. + fn file_time_range(&self, file_index: usize) -> Option<(f64, f64)> { + let times = self.files.get(file_index)?.log.get_times_as_f64(); + Some((*times.first()?, *times.last()?)) + } + + /// Recompute a tab's time range: log A's span joined with log B's shifted span. Clamps the + /// tab cursor into the new range. Call after any change to the compare file or offset. + pub fn update_tab_time_range(&mut self, tab_idx: usize) { + let Some(tab) = self.tabs.get(tab_idx) else { + return; + }; + let a = self.file_time_range(tab.file_index); + let b = tab.compare_file().and_then(|f| self.file_time_range(f)); + let offset = tab.compare.as_ref().map_or(0.0, |c| c.offset_s); + let range = crate::state::union_range(a, b, offset); + let a_file = tab.file_index; + + let tab = &mut self.tabs[tab_idx]; + tab.time_range = range; + if let (Some((t0, t1)), Some(cursor)) = (range, tab.cursor_time) { + let clamped = cursor.clamp(t0, t1); + if clamped != cursor { + tab.cursor_time = Some(clamped); + tab.cursor_record = self + .files + .get(a_file) + .and_then(|f| crate::state::nearest_record(f.log.get_times_as_f64(), clamped)); } - // Check if low or low-1 is closer - if low > 0 && (times[low] - time).abs() > (times[low - 1] - time).abs() { - Some(low - 1) - } else { - Some(low) + } + } + + /// Start, change or stop comparing the tab with another file (log B). The old log B's + /// channels leave the tab first; the offset starts at 0. + /// Returns whether log B changed. + pub fn set_compare_file(&mut self, tab_idx: usize, file_index: Option) -> bool { + let Some(tab) = self.tabs.get_mut(tab_idx) else { + return false; + }; + // The tab's own file or a file that does not exist cannot be log B: ignore the request. + if file_index.is_some_and(|f| f == tab.file_index || f >= self.files.len()) { + return false; + } + if tab.compare_file() == file_index { + return false; + } + if let Some(old) = tab.compare_file() { + tab.remove_channels_of_file(old); + } + tab.compare = file_index.map(crate::state::CompareState::new); + self.update_tab_time_range(tab_idx); + true + } + + /// Start "Align at cursor": the next chart click marks log B's point that matches the + /// cursor's event in log A. Needs a cursor and a log B. + pub fn start_alignment(&mut self, tab_idx: usize) { + let Some(tab) = self.tabs.get_mut(tab_idx) else { + return; + }; + let Some(target) = tab.cursor_time else { + return; + }; + let Some(compare) = tab.compare.as_mut() else { + return; + }; + compare.picking_alignment = true; + compare.align_target = Some(target); + // Playback would move the cursor away from the event before the second click. + self.is_playing = false; + self.last_frame_time = None; + } + + /// Leave "Align at cursor" without changing the offset. + pub fn cancel_alignment(&mut self, tab_idx: usize) { + if let Some(compare) = self.tabs.get_mut(tab_idx).and_then(|t| t.compare.as_mut()) { + compare.picking_alignment = false; + compare.align_target = None; + } + } + + /// Leave "Align at cursor" on every tab but the active one, so a tab or tool switch never + /// leaves a pending pick that turns the next chart click into an offset change. + fn cancel_alignment_elsewhere(&mut self) { + let active = self.active_tab; + let on_chart = self.active_tool == ActiveTool::LogViewer; + for (idx, tab) in self.tabs.iter_mut().enumerate() { + if let Some(compare) = tab.compare.as_mut() + && compare.picking_alignment + && (Some(idx) != active || !on_chart) + { + compare.picking_alignment = false; + compare.align_target = None; } - } else { - None } } + /// Finish "Align at cursor" with the chart click at tab time `clicked`: the offset changes so + /// that log B's point under the click moves under the cursor. + pub fn finish_alignment(&mut self, tab_idx: usize, clicked: f64) { + let Some(tab) = self.tabs.get(tab_idx) else { + return; + }; + let Some(compare) = tab.compare.as_ref() else { + return; + }; + let Some(target) = compare.align_target.or(tab.cursor_time) else { + return; + }; + let offset = compare.offset_s + (target - clicked); + self.cancel_alignment(tab_idx); + self.set_compare_offset(tab_idx, offset); + } + + /// Whether the active tab waits for the "Align at cursor" click. + pub fn picking_alignment(&self) -> bool { + self.active_tab + .and_then(|idx| self.tabs[idx].compare.as_ref()) + .is_some_and(|c| c.picking_alignment) + } + + /// Set log B's time offset for a tab and recompute its range. + pub fn set_compare_offset(&mut self, tab_idx: usize, offset_s: f64) { + let Some(compare) = self.tabs.get_mut(tab_idx).and_then(|t| t.compare.as_mut()) else { + return; + }; + if !offset_s.is_finite() { + return; + } + compare.offset_s = offset_s; + self.update_tab_time_range(tab_idx); + } + /// Get value at a specific record index for a channel (handles computed channels) pub fn get_value_at_record( &self, @@ -1127,20 +1368,16 @@ impl UltraLogApp { } self.analysis_results = new_analysis_results; - // Update file indices for remaining tabs and their channels + // Every tab, whatever its own file: log B can be any file, so a tab whose own file + // comes before `index` can still hold channels of a file after it. for tab in &mut self.tabs { - if tab.file_index > index { - tab.file_index -= 1; - // Update file indices in selected channels - for channel in &mut tab.selected_channels { - if channel.file_index > index { - channel.file_index -= 1; - } - } - } + tab.remap_after_file_removed(index); } self.files.remove(index); + for tab_idx in 0..self.tabs.len() { + self.update_tab_time_range(tab_idx); + } // Reconcile selected_file from the active tab (close_tab may have set it // using pre-removal file indices, so re-derive from the active tab). @@ -1164,26 +1401,34 @@ impl UltraLogApp { let tab = &self.tabs[tab_idx]; - // Only allow adding channels from the tab's file - if file_index != tab.file_index { + // Only the tab's file (log A) or its comparison file (log B) + if !tab.accepts_file(file_index) { self.show_toast_warning(&t!("toast.channel_from_active_tab")); return; } + // A log-B channel that joins an unpaired log-A channel costs nothing (R2), and goes + // into its partner's plot area whatever plot is chosen here. + let free = self.joins_a_pair(tab_idx, file_index, channel_index); + // Check if stacked mode is enabled if tab.stacked_mode { // In stacked mode, add to first plot with capacity - let plot_with_capacity = tab.plot_areas.iter().find(|p| p.has_capacity()); - if let Some(plot) = plot_with_capacity { - self.add_channel_to_plot(file_index, channel_index, plot.id); + let plot_with_capacity = tab + .plot_areas + .iter() + .find(|p| free || tab.plot_has_capacity(p.id)) + .map(|p| p.id); + if let Some(plot_id) = plot_with_capacity { + self.add_channel_to_plot(file_index, channel_index, plot_id); } else { self.show_toast_warning("All plots are full. Create a new plot area."); } return; } - // Single-plot mode logic (original) - if tab.selected_channels.len() >= MAX_CHANNELS { + // Single-plot mode logic (original). A compare pair counts as one channel (R2). + if !free && tab.total_channel_units() >= MAX_CHANNELS { self.show_toast_warning(&t!("toast.max_channels")); return; } @@ -1198,37 +1443,9 @@ impl UltraLogApp { return; } - let file = &self.files[file_index]; - let channel = file.log.channels[channel_index].clone(); - - // Find the first unused color index - let used_colors: std::collections::HashSet = tab - .selected_channels - .iter() - .map(|c| c.color_index) - .collect(); - - let color_index = (0..CHART_COLORS.len()) - .find(|i| !used_colors.contains(i)) - .unwrap_or(0); - - self.tabs[tab_idx].selected_channels.push(SelectedChannel { - file_index, - channel_index, - channel, - color_index, - }); - - // In single-plot mode, also add to the default plot area - if !self.tabs[tab_idx].plot_areas.is_empty() { - let new_idx = self.tabs[tab_idx].selected_channels.len() - 1; - self.tabs[tab_idx].plot_areas[0] - .channel_indices - .push(new_idx); - } - - // Track channel selection for analytics - analytics::track_channel_selected(self.tabs[tab_idx].selected_channels.len()); + // In single-plot mode, the channel also goes into the default plot area + let plot_idx = (!tab.plot_areas.is_empty()).then_some(0); + self.insert_channel(tab_idx, file_index, channel_index, plot_idx); } /// Remove a channel from the active tab's selection @@ -1405,14 +1622,17 @@ impl UltraLogApp { let tab = &self.tabs[tab_idx]; - // Validate file index matches tab - if file_index != tab.file_index { + // Only the tab's file (log A) or its comparison file (log B) + if !tab.accepts_file(file_index) { self.show_toast_warning(&t!("toast.channel_from_active_tab")); return; } - // Check total channel limit - if tab.selected_channels.len() >= MAX_TOTAL_CHANNELS { + // A log-B channel joining an unpaired log-A channel costs nothing (R2). + let free = self.joins_a_pair(tab_idx, file_index, channel_index); + + // Check total channel limit (a compare pair counts as one, R2) + if !free && tab.total_channel_units() >= MAX_TOTAL_CHANNELS { self.show_toast_warning(&format!( "Maximum {} total channels reached", MAX_TOTAL_CHANNELS @@ -1430,7 +1650,7 @@ impl UltraLogApp { let plot = &tab.plot_areas[plot_idx.unwrap()]; // Check plot area capacity - if !plot.has_capacity() { + if !free && !tab.plot_has_capacity(plot_area_id) { self.show_toast_warning(&format!( "Plot '{}' is full (max {} channels)", plot.name, MAX_CHANNELS_PER_PLOT @@ -1448,69 +1668,244 @@ impl UltraLogApp { return; } - // Add the channel to selected_channels - let file = &self.files[file_index]; - let channel = file.log.channels[channel_index].clone(); - - let used_colors: std::collections::HashSet = tab - .selected_channels - .iter() - .map(|c| c.color_index) - .collect(); + self.insert_channel(tab_idx, file_index, channel_index, plot_idx); + } - let color_index = (0..CHART_COLORS.len()) - .find(|i| !used_colors.contains(i)) - .unwrap_or(0); + /// Push a checked, not-yet-selected log channel into a tab, in plot area `plot_idx`. While + /// the tab compares two logs (D5): a log-B channel matching an unpaired log-A channel joins + /// it (same colour slot, same plot area), and a log-A channel brings its log-B match along. + fn insert_channel( + &mut self, + tab_idx: usize, + file_index: usize, + channel_index: usize, + plot_idx: Option, + ) { + let channel = self.files[file_index].log.channels[channel_index].clone(); + let is_b = self.tabs[tab_idx].compare_file() == Some(file_index); + let mate = if is_b { + self.unpaired_match(tab_idx, LogRole::A, &channel.name()) + } else { + None + }; + let tab = &self.tabs[tab_idx]; + let color_index = match mate { + Some(m) => tab.selected_channels[m].color_index, + None => self.new_color_index(tab_idx), + }; + let plot_idx = mate.and_then(|m| plot_of(tab, m)).or(plot_idx); - let selected_channel = SelectedChannel { + let tab = &mut self.tabs[tab_idx]; + tab.selected_channels.push(SelectedChannel { file_index, channel_index, channel, color_index, + }); + let new_idx = tab.selected_channels.len() - 1; + if let Some(plot) = plot_idx.and_then(|i| tab.plot_areas.get_mut(i)) { + plot.channel_indices.push(new_idx); + } + if !is_b { + self.attach_partner(tab_idx, new_idx); + } + + // Track channel selection for analytics + analytics::track_channel_selected(self.tabs[tab_idx].selected_channels.len()); + } + + /// Whether adding log channel `channel_index` of `file_index` completes a pair (a log-B + /// channel matching a selected, unpaired log-A channel): it then costs no channel unit. + fn joins_a_pair(&self, tab_idx: usize, file_index: usize, channel_index: usize) -> bool { + self.tabs[tab_idx].compare_file() == Some(file_index) + && self + .files + .get(file_index) + .and_then(|f| f.log.channels.get(channel_index)) + .is_some_and(|c| { + self.unpaired_match(tab_idx, LogRole::A, &c.name()) + .is_some() + }) + } + + /// The name two logs' channels are matched on: the normalized display name (when field + /// normalization is on), case-insensitive, so `Engine Speed` pairs with `RPM`. + fn pair_key(&self, name: &str) -> String { + let name = if self.field_normalization { + crate::normalize::normalize_channel_name_with_custom( + name, + Some(&self.custom_normalizations), + ) + } else { + name.to_string() }; + name.to_lowercase() + } + + /// The first log channel of `file_index` that pairs with `name`. Log channels only: + /// computed channels are not paired (R3). + fn matching_channel(&self, file_index: usize, name: &str) -> Option { + let key = self.pair_key(name); + self.files + .get(file_index)? + .log + .channels + .iter() + .position(|c| self.pair_key(&c.name()) == key) + } - // Add to selected_channels and track its index - self.tabs[tab_idx].selected_channels.push(selected_channel); - let new_channel_idx = self.tabs[tab_idx].selected_channels.len() - 1; + /// A selected log channel of `role` in the tab, without a partner, that pairs with `name`. + fn unpaired_match(&self, tab_idx: usize, role: LogRole, name: &str) -> Option { + let tab = &self.tabs[tab_idx]; + let key = self.pair_key(name); + (0..tab.selected_channels.len()).find(|&i| { + let c = &tab.selected_channels[i]; + tab.log_role(c) == role + && !c.channel.is_computed() + && tab.partner(i).is_none() + && self.pair_key(&c.channel.name()) == key + }) + } + + /// Give the log-A channel at `a_idx` its log-B partner: an unpaired selected B channel with + /// the same name takes A's colour slot, otherwise B's matching channel is added beside A. + /// No match leaves A alone (its card says "not in B"). + fn attach_partner(&mut self, tab_idx: usize, a_idx: usize) { + let tab = &self.tabs[tab_idx]; + let (Some(b_file), Some(a)) = (tab.compare_file(), tab.selected_channels.get(a_idx)) else { + return; + }; + if a.channel.is_computed() || tab.partner(a_idx).is_some() { + return; + } + let (name, color_index) = (a.channel.name(), a.color_index); + if let Some(b_idx) = self.unpaired_match(tab_idx, LogRole::B, &name) { + self.tabs[tab_idx].selected_channels[b_idx].color_index = color_index; + return; + } + let Some(b_channel) = self.matching_channel(b_file, &name) else { + return; + }; + let tab = &mut self.tabs[tab_idx]; + if tab + .selected_channels + .iter() + .any(|c| c.file_index == b_file && c.channel_index == b_channel) + { + return; + } + let plot_idx = plot_of(tab, a_idx); + tab.selected_channels.push(SelectedChannel { + file_index: b_file, + channel_index: b_channel, + channel: self.files[b_file].log.channels[b_channel].clone(), + color_index, + }); + let new_idx = tab.selected_channels.len() - 1; + if let Some(plot) = plot_idx.and_then(|i| tab.plot_areas.get_mut(i)) { + plot.channel_indices.push(new_idx); + } + } - // Add the index to the plot area's channel list - let plot = &mut self.tabs[tab_idx].plot_areas[plot_idx.unwrap()]; - plot.channel_indices.push(new_channel_idx); + /// Pair every selected log-A channel that has no partner yet (after choosing log B). + pub fn pair_selected_channels(&mut self, tab_idx: usize) { + let count = self + .tabs + .get(tab_idx) + .map_or(0, |t| t.selected_channels.len()); + for a_idx in 0..count { + if self.tabs[tab_idx].log_role(&self.tabs[tab_idx].selected_channels[a_idx]) + == LogRole::A + { + self.attach_partner(tab_idx, a_idx); + } + } + } - // Track channel selection for analytics - analytics::track_channel_selected(self.tabs[tab_idx].selected_channels.len()); + /// A colour slot for a new channel that is not half of a pair. While comparing, it also + /// skips slots whose palette colour is already on screen (the palette is 8 in Color blind + /// mode), so an unpaired line never looks like the other half of a pair. + fn new_color_index(&self, tab_idx: usize) -> usize { + let tab = &self.tabs[tab_idx]; + if tab.compare.is_none() { + return crate::state::next_color_index(&tab.selected_channels); + } + let distinct = if self.color_blind_mode || self.high_contrast { + 8 + } else { + 10 + }; + let used: HashSet = tab + .selected_channels + .iter() + .map(|c| c.color_index) + .collect(); + let colours: HashSet = used.iter().map(|i| i % distinct).collect(); + (0..distinct) + .find(|i| !used.contains(i) && !colours.contains(i)) + .unwrap_or_else(|| crate::state::next_color_index(&tab.selected_channels)) } - /// Remove a channel from a plot area and from selected channels - pub fn remove_channel_from_plot(&mut self, channel_idx: usize) { + /// Remove a selected channel and its compare partner from the active tab. + pub fn remove_pair(&mut self, idx: usize) { let Some(tab_idx) = self.active_tab else { return; }; - let tab = &mut self.tabs[tab_idx]; + let partner = tab.partner(idx); + let mut doomed = vec![idx]; + doomed.extend(partner); + doomed.sort_unstable_by(|a, b| b.cmp(a)); + for i in doomed { + tab.remove_selected_channel(i); + } + } - if channel_idx >= tab.selected_channels.len() { - return; + /// Choose log B for a tab from the Compare row and pair the channels already selected. + /// Choosing the current log B again changes nothing, so a B half the user removed stays + /// removed. + pub fn choose_compare_file(&mut self, tab_idx: usize, file_index: Option) { + if self.set_compare_file(tab_idx, file_index) && file_index.is_some() { + self.pair_selected_channels(tab_idx); } + } - // Remove from all plot areas' channel indices - for plot in &mut tab.plot_areas { - plot.channel_indices.retain(|&idx| idx != channel_idx); + /// "Open file…" in a tab's Compare row: a file already loaded becomes log B at once; + /// otherwise it loads, and becomes log B of that tab when it finishes. + pub fn open_compare_file(&mut self, tab_idx: usize, path: PathBuf) { + if let Some(file_index) = self.files.iter().position(|f| f.path == path) { + self.choose_compare_file(tab_idx, Some(file_index)); + return; + } + let tab_id = self.tabs[tab_idx].id; + self.pending_compare.retain(|(_, id)| *id != tab_id); + self.pending_compare.push((path.clone(), tab_id)); + self.start_loading_file(path); + } - // Shift down indices greater than the removed one - for idx in &mut plot.channel_indices { - if *idx > channel_idx { - *idx -= 1; - } - } + /// After a load: if a tab asked for this file as log B, set it there and make that tab + /// active again (the load made the new file's own tab active). A closed tab's request is + /// dropped. + fn finish_compare_request(&mut self, file_index: usize) { + let path = &self.files[file_index].path; + let Some(pos) = self.pending_compare.iter().position(|(p, _)| p == path) else { + return; + }; + let (_, tab_id) = self.pending_compare.remove(pos); + if let Some(tab_idx) = self.tabs.iter().position(|t| t.id == tab_id) { + self.choose_compare_file(tab_idx, Some(file_index)); + self.active_tab = Some(tab_idx); + self.selected_file = Some(self.tabs[tab_idx].file_index); } + } - tab.data_panel_state - .track_map - .remove_color_channel_slot(channel_idx); + /// Remove a channel from a plot area and from selected channels + pub fn remove_channel_from_plot(&mut self, channel_idx: usize) { + let Some(tab_idx) = self.active_tab else { + return; + }; - // Remove from selected_channels - tab.selected_channels.remove(channel_idx); + self.tabs[tab_idx].remove_selected_channel(channel_idx); } /// Move a channel from one plot area to another @@ -1541,19 +1936,21 @@ impl UltraLogApp { return; // Already in target plot } - // Check target plot capacity - if !tab.plot_areas[target_idx].has_capacity() { + // A compare pair moves as one unit (R2): moving one half moves its partner too, and + // joining a partner already in the target plot costs nothing. + let partner = tab.partner(channel_idx); + let joins_partner = + partner.is_some_and(|p| tab.plot_areas[target_idx].channel_indices.contains(&p)); + if !joins_partner && !tab.plot_has_capacity(target_plot_id) { self.show_toast_warning("Target plot is full"); return; } - // Remove from current plot - tab.plot_areas[current_idx] - .channel_indices - .retain(|&idx| idx != channel_idx); - - // Add to target plot - tab.plot_areas[target_idx].channel_indices.push(channel_idx); + let moving: Vec = std::iter::once(channel_idx).chain(partner).collect(); + for plot in &mut tab.plot_areas { + plot.channel_indices.retain(|idx| !moving.contains(idx)); + } + tab.plot_areas[target_idx].channel_indices.extend(moving); } /// Adjust plot heights after resize drag @@ -1629,6 +2026,7 @@ impl UltraLogApp { } self.active_tool = tool; self.table_generator.clear_selection(); + self.cancel_alignment_elsewhere(); analytics::track_tool_switched(tool.name()); } @@ -2101,10 +2499,11 @@ impl UltraLogApp { let file_index = self.tabs[tab_idx].file_index; if file_index < self.files.len() { let times = self.files[file_index].log.get_times_as_f64(); - if !times.is_empty() { - let current = self.get_cursor_record().unwrap_or(0); - let step = if shift { 10 } else { 1 }; - let new_record = current.saturating_sub(step); + let current = self.get_cursor_record().unwrap_or(0); + let step = if shift { -10 } else { -1 }; + if let Some(new_record) = + crate::state::arrow_step(times, self.get_cursor_time(), current, step) + { self.set_cursor_time(Some(times[new_record])); self.set_cursor_record(Some(new_record)); } @@ -2120,10 +2519,11 @@ impl UltraLogApp { let file_index = self.tabs[tab_idx].file_index; if file_index < self.files.len() { let times = self.files[file_index].log.get_times_as_f64(); - if !times.is_empty() { - let current = self.get_cursor_record().unwrap_or(0); - let step = if shift { 10 } else { 1 }; - let new_record = (current + step).min(times.len() - 1); + let current = self.get_cursor_record().unwrap_or(0); + let step = if shift { 10 } else { 1 }; + if let Some(new_record) = + crate::state::arrow_step(times, self.get_cursor_time(), current, step) + { self.set_cursor_time(Some(times[new_record])); self.set_cursor_record(Some(new_record)); } @@ -2160,14 +2560,9 @@ impl UltraLogApp { return; } - // ⌘E - export current view as PNG + // ⌘E - open the ExportDialog for the current view if cmd && i.key_pressed(egui::Key::E) { - match self.active_tool { - ActiveTool::LogViewer => self.export_chart_png(), - ActiveTool::ScatterPlot => self.export_scatter_plot_png(), - ActiveTool::Histogram => self.export_histogram_png(), - ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => self.export_table_png(), - } + self.show_export_dialog = true; return; } @@ -2241,6 +2636,9 @@ impl eframe::App for UltraLogApp { unit_preferences: self.unit_preferences.clone(), font_scale: self.font_scale, color_blind_mode: self.color_blind_mode, + ultra_intensity: self.ultra_intensity, + high_contrast: self.high_contrast, + export: self.export_choice, field_normalization: self.field_normalization, cursor_tracking: self.cursor_tracking, auto_check_updates: self.auto_check_updates, @@ -2268,6 +2666,12 @@ impl eframe::App for UltraLogApp { return; } + self.cancel_alignment_elsewhere(); + + // Rebuild fonts and theme if the language, font scale or a theme setting changed + self.sync_fonts(ctx); + self.sync_theme(ctx); + // Check for updates on startup (runs once) self.check_startup_update(); @@ -2314,18 +2718,22 @@ impl eframe::App for UltraLogApp { self.render_normalization_editor(ctx); self.render_update_dialog(ctx); self.render_computed_channels_manager(ctx); + self.render_export_dialog(ctx); + self.render_about_window(ctx); self.render_formula_editor(ctx); self.render_analysis_panel(ctx); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // Menu bar at top with padding - let menu_frame = egui::Frame::NONE.inner_margin(egui::Margin { - left: 10, - right: 10, - top: 8, - bottom: 8, - }); + let menu_frame = egui::Frame::NONE + .fill(self.tokens.bg_void) + .inner_margin(egui::Margin { + left: 10, + right: 10, + top: 8, + bottom: 8, + }); egui::Panel::top("menu_bar") .frame(menu_frame) @@ -2334,14 +2742,16 @@ impl eframe::App for UltraLogApp { }); // Tool switcher panel (pill tabs) + // High contrast separates surfaces by borders, not by shade. + let hc_outline = if self.high_contrast { + egui::Stroke::new(1.0, self.tokens.line) + } else { + egui::Stroke::NONE + }; let tool_switcher_frame = egui::Frame::NONE - .fill(egui::Color32::from_rgb(35, 35, 35)) - .inner_margin(egui::Margin { - left: 10, - right: 10, - top: 8, - bottom: 8, - }); + .fill(self.tokens.bg_panel) + .stroke(hc_outline) + .inner_margin(egui::Margin::symmetric(12, 0)); egui::Panel::top("tool_switcher") .frame(tool_switcher_frame) @@ -2349,17 +2759,15 @@ impl eframe::App for UltraLogApp { self.render_tool_switcher(ui); }); - // Panel background color - let panel_bg = egui::Color32::from_rgb(45, 45, 45); let panel_frame = egui::Frame::NONE - .fill(panel_bg) + .fill(self.tokens.bg_panel) + .stroke(hc_outline) .inner_margin(egui::Margin::symmetric(10, 10)); // Activity bar (far left, narrow icon strip) - let activity_bar_bg = egui::Color32::from_rgb(35, 35, 35); let activity_bar_frame = egui::Frame::NONE - .fill(activity_bar_bg) - .inner_margin(egui::Margin::symmetric(4, 8)); + .fill(self.tokens.bg_panel) + .stroke(hc_outline); egui::Panel::left("activity_bar") .exact_size(crate::ui::activity_bar::ACTIVITY_BAR_WIDTH) @@ -2391,87 +2799,547 @@ impl eframe::App for UltraLogApp { .show(ui, |ui| { ui.add_space(5.0); self.render_record_indicator(ui); - ui.separator(); + ui.add_space(crate::ui::theme::tokens::space::SPACE_1); self.render_timeline_scrubber(ui); ui.add_space(5.0); }); } // Main content area - render based on active tool - egui::CentralPanel::default().show(ui, |ui| { - match self.active_tool { - ActiveTool::LogViewer => { - // Tab bar at top (Chrome-style tabs for log files) - self.render_tab_bar(ui); - - // Selected channels below tabs - ui.add_space(10.0); - self.render_selected_channels(ui); - - ui.add_space(10.0); - ui.separator(); - - // Right-side data panel (track map + future widgets). - // - When the user has it expanded -> render the full - // panel carved out before the chart. - // - When collapsed but there's content available -> - // render a thin rail with an expand affordance so - // the panel is discoverable. The rail itself is - // the only entry point; the previous "Show data - // panel" checkbox was removed. - if self.data_panel_should_show() { - let avail_w = ui.available_width(); - let tab_idx = self - .active_tab - .expect("a visible data panel requires an active tab"); - let tab_id = self.tabs[tab_idx].id; - let frac = self.tabs[tab_idx] - .data_panel_state - .split_fraction - .clamp(0.2, 0.7); - let max_panel_w = (avail_w * 0.7).max(260.0); - let panel_w = (avail_w * frac).clamp(260.0, max_panel_w); - let panel = - egui::Panel::right(egui::Id::new(("ultralog_data_panel", tab_id))) - .resizable(true) - .default_size(panel_w) - .min_size(260.0) - .max_size(max_panel_w) - .show(ui, |ui| { - self.render_data_panel(ui); - }); - if avail_w > 0.0 { - self.tabs[tab_idx].data_panel_state.split_fraction = - (panel.response.rect.width() / avail_w).clamp(0.2, 0.7); + let central_frame = egui::Frame::central_panel(ui.style()).fill(self.tokens.bg_base); + egui::CentralPanel::default() + .frame(central_frame) + .show(ui, |ui| { + match self.active_tool { + ActiveTool::LogViewer => { + // Tab bar at top (Chrome-style tabs for log files) + self.render_tab_bar(ui); + + // Selected channels below tabs + ui.add_space(10.0); + self.render_selected_channels(ui); + + ui.add_space(10.0); + ui.separator(); + + // Right-side data panel (track map + future widgets). + // - When the user has it expanded -> render the full + // panel carved out before the chart. + // - When collapsed but there's content available -> + // render a thin rail with an expand affordance so + // the panel is discoverable. The rail itself is + // the only entry point; the previous "Show data + // panel" checkbox was removed. + if self.data_panel_should_show() { + let avail_w = ui.available_width(); + let tab_idx = self + .active_tab + .expect("a visible data panel requires an active tab"); + let tab_id = self.tabs[tab_idx].id; + let frac = self.tabs[tab_idx] + .data_panel_state + .split_fraction + .clamp(0.2, 0.7); + let max_panel_w = (avail_w * 0.7).max(260.0); + let panel_w = (avail_w * frac).clamp(260.0, max_panel_w); + let panel = + egui::Panel::right(egui::Id::new(("ultralog_data_panel", tab_id))) + .resizable(true) + .default_size(panel_w) + .min_size(260.0) + .max_size(max_panel_w) + .show(ui, |ui| { + self.render_data_panel(ui); + }); + if avail_w > 0.0 { + self.tabs[tab_idx].data_panel_state.split_fraction = + (panel.response.rect.width() / avail_w).clamp(0.2, 0.7); + } + } else if self.data_panel_has_content() { + self.render_data_panel_rail(ui); } - } else if self.data_panel_has_content() { - self.render_data_panel_rail(ui); - } - // Chart takes remaining space - self.render_chart(ui); - } - ActiveTool::ScatterPlot => { - ui.add_space(10.0); - self.render_scatter_plot_view(ui); - } - ActiveTool::Histogram => { - ui.add_space(10.0); - self.render_histogram_view(ui); - } - ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => { - ui.add_space(10.0); - self.render_table_tool_view(ui); + // Chart takes remaining space + self.render_chart(ui); + } + ActiveTool::ScatterPlot => { + ui.add_space(10.0); + self.render_scatter_plot_view(ui); + } + ActiveTool::Histogram => { + ui.add_space(10.0); + self.render_histogram_view(ui); + } + ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => { + ui.add_space(10.0); + self.render_table_tool_view(ui); + } } - } - }); + }); + + // One focus ring for every control, after everything else has laid out. + crate::ui::theme::paint::keyboard_focus_ring(ui.ctx(), &self.tokens, self.high_contrast); } } +/// The plot area holding the tab's selected channel `idx`, by position in `plot_areas`. +fn plot_of(tab: &Tab, idx: usize) -> Option { + tab.plot_areas + .iter() + .position(|p| p.channel_indices.contains(&idx)) +} + #[cfg(test)] mod tests { use super::*; + fn file_with_times(name: &str, times: Vec) -> LoadedFile { + use crate::parsers::aim::AimChannel; + use crate::parsers::types::Meta; + use crate::parsers::{Channel, Log, Value}; + let log = Log { + meta: Meta::Empty, + channels: vec![Channel::Aim(AimChannel { + name: "RPM".to_string(), + unit: String::new(), + })], + data: times.iter().map(|_| vec![Value::Float(0.0)]).collect(), + times, + }; + LoadedFile::new(PathBuf::from(name), name.to_string(), EcuType::Aim, log) + } + + #[test] + fn find_record_at_time_uses_the_active_tabs_file() { + let mut app = UltraLogApp::default(); + // File 0 samples at 100 Hz, file 1 at 10 Hz, so the same time maps to + // different record indices in each. + app.files.push(file_with_times( + "fast.csv", + (0..2000).map(|i| i as f64 * 0.01).collect(), + )); + app.files.push(file_with_times( + "slow.csv", + (0..200).map(|i| i as f64 * 0.1).collect(), + )); + app.tabs.push(Tab::new(0, "fast.csv".to_string())); + app.tabs.push(Tab::new(1, "slow.csv".to_string())); + + app.active_tab = Some(1); + assert_eq!(app.find_record_at_time(10.0), Some(100)); + + app.active_tab = Some(0); + assert_eq!(app.find_record_at_time(10.0), Some(1000)); + } + + /// Files 0..3 at 10 Hz: 0..=10 s, 0..=8 s, 0..=5 s. Tab 0 shows file 0. + fn app_with_three_files() -> UltraLogApp { + let mut app = UltraLogApp::default(); + for (name, end) in [("a.csv", 100), ("b.csv", 80), ("c.csv", 50)] { + app.files.push(file_with_times( + name, + (0..=end).map(|i| i as f64 * 0.1).collect(), + )); + } + app.tabs.push(Tab::new(0, "a.csv".to_string())); + app.active_tab = Some(0); + app.update_tab_time_range(0); + app + } + + #[test] + fn compare_offset_moves_log_b_on_the_tab_axis() { + let mut app = app_with_three_files(); + app.set_compare_file(0, Some(1)); + app.set_compare_offset(0, -3.0); + + assert_eq!(app.tabs[0].time_range, Some((-3.0, 10.0))); + // Tab time 2.0 is file time 5.0 in log B. + assert_eq!(app.record_at(1, 2.0), Some(50)); + // Before log A starts and after log B ends there is no value (R4). + assert_eq!(app.record_at(0, -1.0), None); + assert_eq!(app.record_at(1, 5.5), None); + assert_eq!(app.record_at(0, 5.5), Some(55)); + } + + #[test] + fn compare_offset_clamps_the_cursor() { + let mut app = app_with_three_files(); + app.set_compare_file(0, Some(1)); + app.set_compare_offset(0, 6.0); // range 0..14 + app.tabs[0].cursor_time = Some(13.0); + app.set_compare_offset(0, 0.0); // range 0..10 + assert_eq!(app.tabs[0].cursor_time, Some(10.0)); + assert_eq!(app.tabs[0].cursor_record, Some(100)); + } + + #[test] + fn add_channel_accepts_log_b_only() { + let mut app = app_with_three_files(); + app.add_channel(1, 0); + assert!(app.tabs[0].selected_channels.is_empty(), "no compare yet"); + + app.set_compare_file(0, Some(1)); + app.add_channel(0, 0); + app.add_channel(1, 0); + app.add_channel(2, 0); + let files: Vec = app.tabs[0] + .selected_channels + .iter() + .map(|c| c.file_index) + .collect(); + assert_eq!(files, vec![0, 1]); + assert_eq!(app.tabs[0].plot_areas[0].channel_indices, vec![0, 1]); + } + + #[test] + fn changing_log_b_drops_the_old_b_channels() { + let mut app = app_with_three_files(); + app.set_compare_file(0, Some(1)); + app.add_channel(0, 0); + app.add_channel(1, 0); + app.set_compare_offset(0, 2.0); + + app.set_compare_file(0, Some(2)); + assert_eq!(app.tabs[0].selected_channels.len(), 1); + assert_eq!(app.tabs[0].compare.as_ref().unwrap().offset_s, 0.0); + assert_eq!(app.tabs[0].time_range, Some((0.0, 10.0))); + + // The tab's own file cannot be log B. + app.set_compare_file(0, Some(0)); + assert_eq!(app.tabs[0].compare_file(), Some(2)); + app.set_compare_file(0, None); + assert!(app.tabs[0].compare.is_none()); + } + + #[test] + fn remove_file_remaps_log_b_in_other_tabs() { + // Tab 0: A = file 0, B = file 2. Tab 1: A = file 2, B = file 0. + let mut app = app_with_three_files(); + app.tabs.push(Tab::new(1, "b.csv".to_string())); + app.tabs.push(Tab::new(2, "c.csv".to_string())); + app.set_compare_file(0, Some(2)); + app.add_channel(0, 0); + app.add_channel(2, 0); + app.active_tab = Some(2); + app.set_compare_file(2, Some(0)); + app.add_channel(2, 0); + app.add_channel(0, 0); + + // Remove file 1: tab 1 closes, both B links shift without losing channels. + app.remove_file(1); + assert_eq!(app.tabs.len(), 2); + let (t0, t1) = (&app.tabs[0], &app.tabs[1]); + assert_eq!((t0.file_index, t0.compare_file()), (0, Some(1))); + assert_eq!((t1.file_index, t1.compare_file()), (1, Some(0))); + assert_eq!( + t0.selected_channels + .iter() + .map(|c| c.file_index) + .collect::>(), + vec![0, 1] + ); + assert_eq!( + t1.selected_channels + .iter() + .map(|c| c.file_index) + .collect::>(), + vec![1, 0] + ); + + // Remove file 0: its tab closes, and the other tab stops comparing against it. + app.remove_file(0); + assert_eq!(app.tabs.len(), 1); + let t = &app.tabs[0]; + assert_eq!((t.file_index, t.compare_file()), (0, None)); + assert_eq!(t.selected_channels.len(), 1); + assert_eq!(t.plot_areas[0].channel_indices, vec![0]); + assert_eq!(t.time_range, Some((0.0, 5.0))); + } + + /// A log with named channels at 10 Hz over 0..=1 s, every value 1.0. + fn file_with_channels(name: &str, channels: &[&str]) -> LoadedFile { + use crate::parsers::aim::AimChannel; + use crate::parsers::types::Meta; + use crate::parsers::{Channel, Log, Value}; + let times: Vec = (0..=10).map(|i| i as f64 * 0.1).collect(); + let log = Log { + meta: Meta::Empty, + channels: channels + .iter() + .map(|n| { + Channel::Aim(AimChannel { + name: n.to_string(), + unit: String::new(), + }) + }) + .collect(), + data: times + .iter() + .map(|_| vec![Value::Float(1.0); channels.len()]) + .collect(), + times, + }; + LoadedFile::new(PathBuf::from(name), name.to_string(), EcuType::Aim, log) + } + + /// Tab 0: log A has RPM, Boost, IAT; log B has engine/rpm (normalizes to RPM), Boost, EGT. + fn pairing_app() -> UltraLogApp { + let mut app = UltraLogApp { + field_normalization: true, + ..Default::default() + }; + app.files + .push(file_with_channels("a.csv", &["RPM", "Boost", "IAT"])); + app.files + .push(file_with_channels("b.csv", &["engine/rpm", "Boost", "EGT"])); + app.tabs.push(Tab::new(0, "a.csv".to_string())); + app.active_tab = Some(0); + app.update_tab_time_range(0); + app + } + + fn picked(app: &UltraLogApp) -> Vec<(usize, usize, usize)> { + app.tabs[0] + .selected_channels + .iter() + .map(|c| (c.file_index, c.channel_index, c.color_index)) + .collect() + } + + #[test] + fn choosing_log_b_pairs_selected_channels_by_normalized_name() { + let mut app = pairing_app(); + app.add_channel(0, 0); // RPM + app.add_channel(0, 2); // IAT: not in B + app.choose_compare_file(0, Some(1)); + // RPM pairs with engine/rpm in RPM's colour slot; IAT stays alone. + assert_eq!(picked(&app), vec![(0, 0, 0), (0, 2, 1), (1, 0, 0)]); + assert_eq!(app.tabs[0].partner(0), Some(2)); + assert_eq!(app.tabs[0].partner(1), None); + assert_eq!(app.tabs[0].plot_areas[0].channel_indices, vec![0, 1, 2]); + } + + #[test] + fn adding_a_channel_while_comparing_adds_its_partner() { + let mut app = pairing_app(); + app.choose_compare_file(0, Some(1)); + app.add_channel(0, 1); // Boost brings B's Boost along + assert_eq!(picked(&app), vec![(0, 1, 0), (1, 1, 0)]); + + // A B-only channel gets a free colour that is on screen nowhere else. + app.add_channel(1, 2); // EGT + assert_eq!(picked(&app)[2], (1, 2, 1)); + + // A B channel added first is joined by its A match later, in the A colour. + app.add_channel(1, 0); // engine/rpm alone: slot 2 + assert_eq!(picked(&app)[3], (1, 0, 2)); + app.add_channel(0, 0); // RPM takes slot 3 and recolours its B partner + let rpm_slot = picked(&app)[4].2; + assert_eq!(picked(&app)[3].2, rpm_slot); + assert_eq!(app.tabs[0].partner(4), Some(3)); + } + + #[test] + fn remove_pair_removes_both_halves() { + let mut app = pairing_app(); + app.choose_compare_file(0, Some(1)); + app.add_channel(0, 1); + app.add_channel(0, 0); + app.remove_pair(0); + assert_eq!(picked(&app), vec![(0, 0, 1), (1, 0, 1)]); + assert_eq!(app.tabs[0].plot_areas[0].channel_indices, vec![0, 1]); + } + + #[test] + fn paired_b_joins_its_partners_plot_in_stacked_mode() { + let mut app = pairing_app(); + app.toggle_stacked_mode(); + app.create_plot_area("P2".to_string()); + app.choose_compare_file(0, Some(1)); + let p2 = app.tabs[0].plot_areas[1].id; + app.add_channel_to_plot(0, 1, p2); + assert_eq!(app.tabs[0].plot_areas[1].channel_indices, vec![0, 1]); + assert!(app.tabs[0].plot_areas[0].channel_indices.is_empty()); + } + + #[test] + fn ten_pairs_fit_in_single_mode() { + let names: Vec = (0..11).map(|i| format!("ch{i}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let mut app = UltraLogApp::default(); + app.files.push(file_with_channels("a.csv", &names)); + app.files.push(file_with_channels("b.csv", &names)); + app.tabs.push(Tab::new(0, "a.csv".to_string())); + app.active_tab = Some(0); + app.choose_compare_file(0, Some(1)); + for i in 0..11 { + app.add_channel(0, i); + } + assert_eq!(app.tabs[0].selected_channels.len(), 20); + assert_eq!(app.tabs[0].total_channel_units(), 10); + } + + #[test] + fn a_log_b_card_jump_lands_on_the_tab_axis() { + let mut app = pairing_app(); + app.choose_compare_file(0, Some(1)); + app.set_compare_offset(0, 0.5); + // B's sample 3 is at file time 0.3: tab time 0.8, log A record 8. + let (t, r) = app.cursor_for_file_sample(1, 3, 0.3); + assert!((t - 0.8).abs() < 1e-9); + assert_eq!(r, 8); + // Log A's own samples pass through. + assert_eq!(app.cursor_for_file_sample(0, 3, 0.3), (0.3, 3)); + } + + #[test] + fn a_compare_open_request_returns_to_the_asking_tab() { + let mut app = pairing_app(); + app.files.push(file_with_channels("c.csv", &["RPM"])); + app.tabs.push(Tab::new(2, "c.csv".to_string())); + let asking = app.tabs[0].id; + app.pending_compare.push((PathBuf::from("c.csv"), asking)); + app.active_tab = Some(1); // the load made the new file's tab active + app.finish_compare_request(2); + assert_eq!(app.active_tab, Some(0)); + assert_eq!(app.tabs[0].compare_file(), Some(2)); + assert!(app.pending_compare.is_empty()); + + // A request from a tab that has closed is dropped. + app.pending_compare.push((PathBuf::from("b.csv"), 9_999)); + app.finish_compare_request(1); + assert!(app.pending_compare.is_empty()); + assert_eq!(app.tabs[0].compare_file(), Some(2)); + + // A file already loaded becomes log B at once. + app.open_compare_file(0, PathBuf::from("b.csv")); + assert_eq!(app.tabs[0].compare_file(), Some(1)); + } + + #[test] + fn align_at_cursor_moves_log_bs_point_under_the_cursor() { + let mut app = app_with_three_files(); + app.set_compare_file(0, Some(1)); + app.set_compare_offset(0, 1.0); + + // No cursor: nothing to align to. + app.tabs[0].cursor_time = None; + app.start_alignment(0); + assert!(!app.picking_alignment()); + + // Cursor on log A's event at 4.0; log B's matching event is drawn at 6.5. + app.tabs[0].cursor_time = Some(4.0); + app.start_alignment(0); + assert!(app.picking_alignment()); + app.finish_alignment(0, 6.5); + assert!(!app.picking_alignment()); + // B's event was at file time 5.5 (6.5 - 1.0); now 5.5 + offset == 4.0. + let offset = app.tabs[0].compare.as_ref().unwrap().offset_s; + assert!((offset - (-1.5)).abs() < 1e-9); + assert_eq!(app.tabs[0].time_range, Some((-1.5, 10.0))); + + // Cancel leaves the offset alone. + app.start_alignment(0); + app.cancel_alignment(0); + assert!(!app.picking_alignment()); + assert!((app.tabs[0].compare.as_ref().unwrap().offset_s + 1.5).abs() < 1e-9); + } + + #[test] + fn a_non_finite_offset_is_ignored() { + let mut app = app_with_three_files(); + app.set_compare_file(0, Some(1)); + app.set_compare_offset(0, 2.0); + app.set_compare_offset(0, f64::NAN); + app.set_compare_offset(0, f64::INFINITY); + assert_eq!(app.tabs[0].compare.as_ref().unwrap().offset_s, 2.0); + } + + #[test] + fn align_uses_the_cursor_from_when_it_started() { + let mut app = app_with_three_files(); + app.set_compare_file(0, Some(1)); + app.tabs[0].cursor_time = Some(4.0); + app.is_playing = true; + app.start_alignment(0); + assert!(!app.is_playing, "starting a pick stops playback"); + // The live cursor moves (track-map hover) before the click on log B. + app.tabs[0].cursor_time = Some(6.0); + app.finish_alignment(0, 6.5); + let offset = app.tabs[0].compare.as_ref().unwrap().offset_s; + assert!( + (offset + 2.5).abs() < 1e-9, + "lined up with 4.0, not 6.0: {offset}" + ); + } + + #[test] + fn a_tool_or_tab_switch_cancels_a_pending_pick() { + let mut app = app_with_three_files(); + app.set_compare_file(0, Some(1)); + app.tabs[0].cursor_time = Some(4.0); + app.start_alignment(0); + app.set_active_tool(ActiveTool::ScatterPlot); + assert!(!app.tabs[0].compare.as_ref().unwrap().picking_alignment); + + app.set_active_tool(ActiveTool::LogViewer); + app.start_alignment(0); + app.tabs.push(Tab::new(2, "c.csv".to_string())); + app.active_tab = Some(1); + app.cancel_alignment_elsewhere(); + assert!(!app.tabs[0].compare.as_ref().unwrap().picking_alignment); + } + + #[test] + fn choosing_the_same_log_b_again_keeps_a_removed_half_removed() { + let mut app = pairing_app(); + app.choose_compare_file(0, Some(1)); + app.add_channel(0, 1); // Boost pair + app.remove_channel(1); // drop B's Boost + app.choose_compare_file(0, Some(1)); + assert_eq!(picked(&app), vec![(0, 1, 0)]); + } + + #[test] + fn re_adding_a_b_half_at_the_cap_is_free() { + let names: Vec = (0..10).map(|i| format!("ch{i}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let mut app = UltraLogApp::default(); + app.files.push(file_with_channels("a.csv", &names)); + app.files.push(file_with_channels("b.csv", &names)); + app.tabs.push(Tab::new(0, "a.csv".to_string())); + app.active_tab = Some(0); + app.choose_compare_file(0, Some(1)); + for i in 0..10 { + app.add_channel(0, i); + } + assert_eq!(app.tabs[0].total_channel_units(), 10); + // Drop B's ch3, then check it again in the B list: it rejoins its pair at no cost. + let b3 = app.tabs[0] + .selected_channels + .iter() + .position(|c| c.file_index == 1 && c.channel_index == 3) + .unwrap(); + app.remove_channel(b3); + app.add_channel(1, 3); + assert_eq!(app.tabs[0].selected_channels.len(), 20); + assert_eq!(app.tabs[0].total_channel_units(), 10); + } + + #[test] + fn moving_one_half_moves_the_pair() { + let mut app = pairing_app(); + app.toggle_stacked_mode(); + app.create_plot_area("P2".to_string()); + app.choose_compare_file(0, Some(1)); + app.add_channel(0, 1); // Boost pair in plot 1 + let p2 = app.tabs[0].plot_areas[1].id; + app.move_channel_to_plot(0, p2); + assert!(app.tabs[0].plot_areas[0].channel_indices.is_empty()); + assert_eq!(app.tabs[0].plot_areas[1].channel_indices, vec![0, 1]); + } + #[test] fn me442_custom_channel_log_dispatches_to_motorsport_electronics() { // Issue #95: a bare "Time" column with no ME221 fingerprint columns diff --git a/src/colormap.rs b/src/colormap.rs index bd3168d6..11b7a332 100644 --- a/src/colormap.rs +++ b/src/colormap.rs @@ -1,199 +1,99 @@ -//! Perceptually uniform colormaps for data visualization. +//! Colour maps for data: the single entry point for anything that colours a value by magnitude. //! -//! Two LUTs are bundled: -//! - **Viridis** - matplotlib default; Public Domain (CC0). Source: -//! -//! - **Turbo** - Google improved rainbow; Apache-2.0. Source: -//! -//! -//! Both LUTs are 256 stops of sRGB triples. `sample` does linear interpolation -//! between adjacent stops so output is smooth at any t in [0, 1]. +//! The maps are the theme's (`design/design-system/3-data-visualization.md` → Colour maps): +//! **Sequential** for magnitudes (RPM on the track map, hit counts, dead time) and **Diverging** +//! for signed values around a target (AFR vs target, corrections). Both interpolate in OKLab +//! between the token stops and follow Color blind mode (cividis-style, blue ↔ amber), never +//! Ultra intensity. The old Viridis and Turbo LUTs were removed: rainbows invent boundaries and +//! fail colour-blind users. use eframe::egui::Color32; +use crate::ui::theme::{self, Tokens}; + #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize, )] pub enum Colormap { #[default] - Viridis, - Turbo, -} - -impl Colormap { - pub fn label(self) -> &'static str { - match self { - Self::Viridis => "Viridis", - Self::Turbo => "Turbo", - } - } - - fn lut(self) -> &'static [[u8; 3]; 256] { - match self { - Self::Viridis => &VIRIDIS, - Self::Turbo => &TURBO, - } - } + Sequential, + Diverging, } -/// Sample the colormap at `t` (clamped to `[0.0, 1.0]`). Non-finite `t` -/// (NaN, ±inf) collapse to 0 so callers can pass raw normalized values -/// without pre-checking. -pub fn sample(map: Colormap, t: f32) -> Color32 { +/// Sample `map` at `t` (clamped to `[0.0, 1.0]`; non-finite `t` reads as 0, so callers can +/// pass raw normalized values without pre-checking). For `Diverging`, 0.5 is on target. +pub fn sample(tokens: &Tokens, map: Colormap, t: f32) -> Color32 { let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; - let lut = map.lut(); - let scaled = t * 255.0; - let i0 = scaled.floor() as usize; - let i1 = (i0 + 1).min(255); - let f = scaled - i0 as f32; - - let a = lut[i0]; - let b = lut[i1]; - let lerp = |x: u8, y: u8| -> u8 { (x as f32 + (y as f32 - x as f32) * f).round() as u8 }; - Color32::from_rgb(lerp(a[0], b[0]), lerp(a[1], b[1]), lerp(a[2], b[2])) + match map { + Colormap::Sequential => theme::seq(tokens, t), + Colormap::Diverging => theme::div(tokens, t), + } } -// --- Viridis LUT (256 stops) --- -// Source: matplotlib (CC0). Computed from the canonical _viridis_data. -#[rustfmt::skip] -static VIRIDIS: [[u8; 3]; 256] = [ - [68, 1, 84], [68, 2, 86], [69, 4, 87], [69, 5, 89], [70, 7, 90], [70, 8, 92], [70, 10, 93], [70, 11, 94], - [71, 13, 96], [71, 14, 97], [71, 16, 99], [71, 17, 100], [71, 19, 101], [72, 20, 103], [72, 22, 104], [72, 23, 105], - [72, 24, 106], [72, 26, 108], [72, 27, 109], [72, 28, 110], [72, 29, 111], [72, 31, 112], [72, 32, 113], [72, 33, 115], - [72, 35, 116], [72, 36, 117], [72, 37, 118], [72, 38, 119], [72, 40, 120], [72, 41, 121], [71, 42, 122], [71, 44, 122], - [71, 45, 123], [71, 46, 124], [71, 47, 125], [70, 48, 126], [70, 50, 126], [70, 51, 127], [70, 52, 128], [69, 53, 129], - [69, 55, 129], [69, 56, 130], [68, 57, 131], [68, 58, 131], [68, 59, 132], [67, 61, 132], [67, 62, 133], [66, 63, 133], - [66, 64, 134], [66, 65, 134], [65, 66, 135], [65, 68, 135], [64, 69, 136], [64, 70, 136], [63, 71, 136], [63, 72, 137], - [62, 73, 137], [62, 74, 137], [62, 76, 138], [61, 77, 138], [61, 78, 138], [60, 79, 138], [60, 80, 139], [59, 81, 139], - [59, 82, 139], [58, 83, 139], [58, 84, 140], [57, 85, 140], [57, 86, 140], [56, 88, 140], [56, 89, 140], [55, 90, 140], - [55, 91, 141], [54, 92, 141], [54, 93, 141], [53, 94, 141], [53, 95, 141], [52, 96, 141], [52, 97, 141], [51, 98, 141], - [51, 99, 141], [50, 100, 142], [50, 101, 142], [49, 102, 142], [49, 103, 142], [49, 104, 142], [48, 105, 142], [48, 106, 142], - [47, 107, 142], [47, 108, 142], [46, 109, 142], [46, 110, 142], [46, 111, 142], [45, 112, 142], [45, 113, 142], [44, 113, 142], - [44, 114, 142], [44, 115, 142], [43, 116, 142], [43, 117, 142], [42, 118, 142], [42, 119, 142], [42, 120, 142], [41, 121, 142], - [41, 122, 142], [41, 123, 142], [40, 124, 142], [40, 125, 142], [39, 126, 142], [39, 127, 142], [39, 128, 142], [38, 129, 142], - [38, 130, 142], [38, 130, 142], [37, 131, 142], [37, 132, 142], [37, 133, 142], [36, 134, 142], [36, 135, 142], [35, 136, 142], - [35, 137, 142], [35, 138, 141], [34, 139, 141], [34, 140, 141], [34, 141, 141], [33, 142, 141], [33, 143, 141], [33, 144, 141], - [33, 145, 140], [32, 146, 140], [32, 146, 140], [32, 147, 140], [31, 148, 140], [31, 149, 139], [31, 150, 139], [31, 151, 139], - [31, 152, 139], [31, 153, 138], [31, 154, 138], [30, 155, 138], [30, 156, 137], [30, 157, 137], [31, 158, 137], [31, 159, 136], - [31, 160, 136], [31, 161, 136], [31, 161, 135], [31, 162, 135], [32, 163, 134], [32, 164, 134], [33, 165, 133], [33, 166, 133], - [34, 167, 133], [34, 168, 132], [35, 169, 131], [36, 170, 131], [37, 171, 130], [37, 172, 130], [38, 173, 129], [39, 173, 129], - [40, 174, 128], [41, 175, 127], [42, 176, 127], [44, 177, 126], [45, 178, 125], [46, 179, 124], [47, 180, 124], [49, 181, 123], - [50, 182, 122], [52, 182, 121], [53, 183, 121], [55, 184, 120], [56, 185, 119], [58, 186, 118], [59, 187, 117], [61, 188, 116], - [63, 188, 115], [64, 189, 114], [66, 190, 113], [68, 191, 112], [70, 192, 111], [72, 193, 110], [74, 193, 109], [76, 194, 108], - [78, 195, 107], [80, 196, 106], [82, 197, 105], [84, 197, 104], [86, 198, 103], [88, 199, 101], [90, 200, 100], [92, 200, 99], - [94, 201, 98], [96, 202, 96], [99, 203, 95], [101, 203, 94], [103, 204, 92], [105, 205, 91], [108, 205, 90], [110, 206, 88], - [112, 207, 87], [115, 208, 86], [117, 208, 84], [119, 209, 83], [122, 209, 81], [124, 210, 80], [127, 211, 78], [129, 211, 77], - [132, 212, 75], [134, 213, 73], [137, 213, 72], [139, 214, 70], [142, 214, 69], [144, 215, 67], [147, 215, 65], [149, 216, 64], - [152, 216, 62], [155, 217, 60], [157, 217, 59], [160, 218, 57], [162, 218, 55], [165, 219, 54], [168, 219, 52], [170, 220, 50], - [173, 220, 48], [176, 221, 47], [178, 221, 45], [181, 222, 43], [184, 222, 41], [186, 222, 40], [189, 223, 38], [192, 223, 37], - [194, 223, 35], [197, 224, 33], [200, 224, 32], [202, 225, 31], [205, 225, 29], [208, 225, 28], [210, 226, 27], [213, 226, 26], - [216, 226, 25], [218, 227, 25], [221, 227, 24], [223, 227, 24], [226, 228, 24], [229, 228, 25], [231, 228, 25], [234, 229, 26], - [236, 229, 27], [239, 229, 28], [241, 229, 29], [244, 230, 30], [246, 230, 32], [248, 230, 33], [251, 231, 35], [253, 231, 37], -]; +/// `map` pre-sampled at 256 steps, for loops that colour many values (scatter bins, track +/// points): an OKLab sample costs a few `cbrt`/`powf` calls, a lookup costs an index. Build one +/// per frame or per cache rebuild from the live tokens; never keep it across a theme change. +pub struct ColormapLut([Color32; 256]); -// --- Turbo LUT (256 stops) --- -// Source: Google Turbo (Apache-2.0). Adapted from Mikhail Korobov's published -// 256-entry table. -#[rustfmt::skip] -static TURBO: [[u8; 3]; 256] = [ - [48, 18, 59], [50, 21, 67], [51, 24, 74], [52, 27, 81], [53, 30, 88], [54, 33, 95], [55, 36, 102], [56, 39, 109], - [57, 42, 115], [58, 45, 121], [59, 47, 128], [60, 50, 134], [61, 53, 139], [62, 56, 145], [63, 59, 151], [63, 62, 156], - [64, 64, 162], [65, 67, 167], [65, 70, 172], [66, 73, 177], [66, 75, 181], [67, 78, 186], [68, 81, 191], [68, 84, 195], - [68, 86, 199], [69, 89, 203], [69, 92, 207], [69, 94, 211], [70, 97, 214], [70, 100, 218], [70, 102, 221], [70, 105, 224], - [70, 107, 227], [71, 110, 230], [71, 113, 233], [71, 115, 235], [71, 118, 238], [71, 120, 240], [71, 123, 242], [70, 125, 244], - [70, 128, 246], [70, 130, 248], [70, 133, 250], [70, 135, 251], [69, 138, 252], [69, 140, 253], [68, 143, 254], [67, 145, 254], - [66, 148, 255], [65, 150, 255], [64, 153, 255], [62, 155, 254], [61, 158, 254], [59, 160, 253], [58, 163, 252], [56, 165, 251], - [55, 168, 250], [53, 171, 248], [51, 173, 247], [49, 175, 245], [47, 178, 244], [46, 180, 242], [44, 183, 240], [42, 185, 238], - [40, 188, 235], [39, 190, 233], [37, 192, 231], [35, 195, 228], [34, 197, 226], [32, 199, 223], [31, 201, 221], [30, 203, 218], - [28, 205, 216], [27, 208, 213], [26, 210, 210], [26, 212, 208], [25, 213, 205], [24, 215, 202], [24, 217, 200], [24, 219, 197], - [24, 221, 194], [24, 222, 192], [24, 224, 189], [25, 226, 187], [25, 227, 185], [26, 228, 182], [28, 230, 180], [29, 231, 178], - [31, 233, 175], [32, 234, 172], [34, 235, 170], [37, 236, 167], [39, 238, 164], [42, 239, 161], [44, 240, 158], [47, 241, 155], - [50, 242, 152], [53, 243, 148], [56, 244, 145], [60, 245, 142], [63, 246, 138], [67, 247, 135], [70, 248, 132], [74, 248, 128], - [78, 249, 125], [82, 250, 122], [85, 250, 118], [89, 251, 115], [93, 252, 111], [97, 252, 108], [101, 253, 105], [105, 253, 102], - [109, 254, 98], [113, 254, 95], [117, 254, 92], [121, 254, 89], [125, 255, 86], [128, 255, 83], [132, 255, 81], [136, 255, 78], - [139, 255, 75], [143, 255, 73], [146, 255, 71], [150, 254, 68], [153, 254, 66], [156, 254, 64], [159, 253, 63], [161, 253, 61], - [164, 252, 60], [167, 252, 58], [169, 251, 57], [172, 251, 56], [175, 250, 55], [177, 249, 54], [180, 248, 54], [183, 247, 53], - [185, 246, 53], [188, 245, 52], [190, 244, 52], [193, 243, 52], [195, 241, 52], [198, 240, 52], [200, 239, 52], [203, 237, 52], - [205, 236, 52], [208, 234, 52], [210, 233, 53], [212, 231, 53], [215, 229, 53], [217, 228, 54], [219, 226, 54], [221, 224, 55], - [223, 223, 55], [225, 221, 55], [227, 219, 56], [229, 217, 56], [231, 215, 57], [233, 213, 57], [235, 211, 57], [236, 209, 58], - [238, 207, 58], [239, 205, 58], [241, 203, 58], [242, 201, 58], [244, 199, 58], [245, 197, 58], [246, 195, 58], [247, 193, 58], - [248, 190, 57], [249, 188, 57], [250, 186, 57], [251, 184, 56], [251, 182, 55], [252, 179, 54], [252, 177, 54], [253, 174, 53], - [253, 172, 52], [254, 169, 51], [254, 167, 50], [254, 164, 49], [254, 161, 48], [254, 158, 47], [254, 155, 45], [254, 153, 44], - [254, 150, 43], [254, 147, 42], [254, 144, 41], [253, 141, 39], [253, 138, 38], [252, 135, 37], [252, 132, 35], [251, 129, 34], - [251, 126, 33], [250, 123, 31], [249, 120, 30], [249, 117, 29], [248, 114, 28], [247, 111, 26], [246, 108, 25], [245, 105, 24], - [244, 102, 23], [243, 99, 21], [242, 96, 20], [241, 93, 19], [240, 91, 18], [239, 88, 17], [237, 85, 16], [236, 83, 15], - [235, 80, 14], [234, 78, 13], [232, 75, 12], [231, 73, 12], [229, 71, 11], [228, 69, 10], [226, 67, 10], [225, 65, 9], - [223, 63, 8], [221, 61, 8], [220, 59, 7], [218, 57, 7], [216, 55, 6], [214, 53, 6], [212, 51, 5], [210, 49, 5], - [208, 47, 5], [206, 45, 4], [204, 43, 4], [202, 42, 4], [200, 40, 3], [197, 38, 3], [195, 37, 3], [193, 35, 2], - [190, 33, 2], [188, 32, 2], [185, 30, 2], [183, 29, 2], [180, 27, 1], [178, 26, 1], [175, 24, 1], [172, 23, 1], - [169, 22, 1], [167, 20, 1], [164, 19, 1], [161, 18, 1], [158, 16, 1], [155, 15, 1], [152, 14, 1], [149, 13, 1], - [146, 11, 1], [142, 10, 1], [139, 9, 2], [136, 8, 2], [133, 7, 2], [129, 6, 2], [126, 5, 2], [122, 4, 3], -]; +impl ColormapLut { + pub fn new(tokens: &Tokens, map: Colormap) -> Self { + Self(std::array::from_fn(|i| { + sample(tokens, map, i as f32 / 255.0) + })) + } + + /// The colour at `t`, with the same clamping as [`sample`] (non-finite reads as 0). + pub fn get(&self, t: f32) -> Color32 { + let t = if t.is_finite() { + t.clamp(0.0, 1.0) + } else { + 0.0 + }; + self.0[(t * 255.0).round() as usize] + } +} #[cfg(test)] mod tests { use super::*; #[test] - fn sample_clamps_out_of_range() { - let lo = sample(Colormap::Viridis, -1.0); - let zero = sample(Colormap::Viridis, 0.0); - assert_eq!(lo, zero); - - let hi = sample(Colormap::Viridis, 2.0); - let one = sample(Colormap::Viridis, 1.0); - assert_eq!(hi, one); - } - - #[test] - fn sample_handles_non_finite() { - let nan = sample(Colormap::Turbo, f32::NAN); - let zero = sample(Colormap::Turbo, 0.0); - assert_eq!(nan, zero); + fn lut_matches_sample_at_its_steps_and_ends() { + let t = ut::NIGHT; + for map in [Colormap::Sequential, Colormap::Diverging] { + let lut = ColormapLut::new(&t, map); + assert_eq!(lut.get(0.0), sample(&t, map, 0.0)); + assert_eq!(lut.get(1.0), sample(&t, map, 1.0)); + assert_eq!(lut.get(128.0 / 255.0), sample(&t, map, 128.0 / 255.0)); + assert_eq!(lut.get(f32::NAN), sample(&t, map, f32::NAN)); + assert_eq!(lut.get(-2.0), sample(&t, map, 0.0)); + } } + use crate::ui::theme::tokens as ut; #[test] - fn sample_endpoints_match_lut() { - let v0 = sample(Colormap::Viridis, 0.0); - assert_eq!(v0, Color32::from_rgb(68, 1, 84)); - let v1 = sample(Colormap::Viridis, 1.0); - assert_eq!(v1, Color32::from_rgb(253, 231, 37)); - - let t0 = sample(Colormap::Turbo, 0.0); - assert_eq!(t0, Color32::from_rgb(48, 18, 59)); - let t1 = sample(Colormap::Turbo, 1.0); - assert_eq!(t1, Color32::from_rgb(122, 4, 3)); + fn sample_clamps_out_of_range() { + let t = ut::NIGHT; + assert_eq!(sample(&t, Colormap::Sequential, -3.0), t.seq_0); + assert_eq!(sample(&t, Colormap::Sequential, 7.0), t.seq_6); + assert_eq!(sample(&t, Colormap::Diverging, 7.0), t.div_4); } #[test] - fn canonical_luts_match_source_hashes() { - assert_eq!(lut_hash(&VIRIDIS), 0x1b8e_5490_518c_939b); - assert_eq!(lut_hash(&TURBO), 0xcf9a_52c7_e29b_2626); + fn sample_handles_non_finite() { + let t = ut::NIGHT; + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(sample(&t, Colormap::Sequential, v), t.seq_0); + } } #[test] - fn sample_interpolates_between_stops() { - // At t = 0.5/255 we should land halfway between stops 0 and 1. - let v = sample(Colormap::Viridis, 0.5 / 255.0); - let a = VIRIDIS[0]; - let b = VIRIDIS[1]; - let expected = Color32::from_rgb( - ((a[0] as f32 + b[0] as f32) * 0.5).round() as u8, - ((a[1] as f32 + b[1] as f32) * 0.5).round() as u8, - ((a[2] as f32 + b[2] as f32) * 0.5).round() as u8, - ); - assert_eq!(v, expected); - } - - fn lut_hash(lut: &[[u8; 3]; 256]) -> u64 { - lut.iter() - .flatten() - .fold(0xcbf2_9ce4_8422_2325, |hash, byte| { - (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) - }) + fn diverging_midpoint_is_on_target() { + let t = ut::CVD; + assert_eq!(sample(&t, Colormap::Diverging, 0.5), t.div_2); } } diff --git a/src/ipc/handler.rs b/src/ipc/handler.rs index afca6b17..737c7323 100644 --- a/src/ipc/handler.rs +++ b/src/ipc/handler.rs @@ -121,15 +121,13 @@ impl UltraLogApp { let selected_channels: Vec = self .get_selected_channels() .iter() - .map(|c| SelectedChannelInfo { - file_id: c.file_index.to_string(), - channel_name: c.channel.name(), - color: format!( - "#{:02x}{:02x}{:02x}", - self.get_channel_color(c.color_index)[0], - self.get_channel_color(c.color_index)[1], - self.get_channel_color(c.color_index)[2] - ), + .map(|c| { + let [r, g, b, _] = self.series_style(c.color_index).0.to_array(); + SelectedChannelInfo { + file_id: c.file_index.to_string(), + channel_name: c.channel.name(), + color: format!("#{r:02x}{g:02x}{b:02x}"), + } }) .collect(); @@ -469,12 +467,13 @@ impl UltraLogApp { } fn handle_deselect_all_channels(&mut self) -> IpcResponse { + // Through the one removal path, so plot areas and the track-map colour slot follow. + // The compare choice (log B) stays: it is a log choice, not a channel. if let Some(tab_idx) = self.active_tab { - self.tabs[tab_idx] - .data_panel_state - .track_map - .clear_color_channel(); - self.tabs[tab_idx].selected_channels.clear(); + let tab = &mut self.tabs[tab_idx]; + for idx in (0..tab.selected_channels.len()).rev() { + tab.remove_selected_channel(idx); + } } IpcResponse::ok() } @@ -712,9 +711,15 @@ impl UltraLogApp { _ => return IpcResponse::error(format!("Invalid file ID: {}", file_id)), }; - let cursor_record = match self.get_cursor_record() { - Some(r) => r, - None => return IpcResponse::error("No cursor position set"), + // Every file is looked up at the cursor time (log B minus its offset), with no values + // outside its own span (R4): the same rule as the chart legend. + let Some(cursor_time) = self.get_cursor_time() else { + return IpcResponse::error("No cursor position set"); + }; + let Some(cursor_record) = self.record_at(file_idx, cursor_time) else { + return IpcResponse::error(format!( + "File {file_id} has no data at the cursor time {cursor_time:.3} s" + )); }; let file = &self.files[file_idx]; @@ -819,24 +824,25 @@ impl UltraLogApp { // Find channel indices first (while we only have immutable borrow) let file = &self.files[file_idx]; - let x_idx = file - .log - .channels - .iter() - .position(|c| c.name().eq_ignore_ascii_case(x_channel)); - let y_idx = file - .log - .channels - .iter() - .position(|c| c.name().eq_ignore_ascii_case(y_channel)); + let find = |name: &str| { + file.log + .channels + .iter() + .position(|c| c.name().eq_ignore_ascii_case(name)) + }; + let Some(x) = find(x_channel) else { + return IpcResponse::error(format!("Channel not found: {}", x_channel)); + }; + let Some(y) = find(y_channel) else { + return IpcResponse::error(format!("Channel not found: {}", y_channel)); + }; - // Switch to scatter plot view + // Scatter state lives on the tab, and the indices belong to this file, + // so the file's tab must be the active one before the state is written. + self.switch_to_file_tab(file_idx); self.set_active_tool(ActiveTool::ScatterPlot); - // Configure the scatter plot (now we can get mutable borrow) - if let Some(state) = self.get_scatter_plot_state_mut() - && let (Some(x), Some(y)) = (x_idx, y_idx) - { + if let Some(state) = self.get_scatter_plot_state_mut() { state.left.x_channel = Some(x); state.left.y_channel = Some(y); } @@ -1042,3 +1048,122 @@ impl UltraLogApp { ) } } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::parsers::aim::AimChannel; + use crate::parsers::types::Meta; + use crate::parsers::{Channel, EcuType, Log, Value}; + use crate::state::{LoadedFile, Tab}; + + fn file_with_channels(name: &str, channels: &[&str]) -> LoadedFile { + let log = Log { + meta: Meta::Empty, + channels: channels + .iter() + .map(|c| { + Channel::Aim(AimChannel { + name: c.to_string(), + unit: String::new(), + }) + }) + .collect(), + times: vec![0.0], + data: vec![channels.iter().map(|_| Value::Float(0.0)).collect()], + }; + LoadedFile::new(PathBuf::from(name), name.to_string(), EcuType::Aim, log) + } + + /// A log with one "RPM" channel at 10 Hz over 0..=`secs` s, value = time. + fn timed_file(name: &str, secs: usize) -> LoadedFile { + let times: Vec = (0..=secs * 10).map(|i| i as f64 * 0.1).collect(); + let log = Log { + meta: Meta::Empty, + channels: vec![Channel::Aim(AimChannel { + name: "RPM".to_string(), + unit: String::new(), + })], + data: times.iter().map(|&t| vec![Value::Float(t)]).collect(), + times, + }; + LoadedFile::new(PathBuf::from(name), name.to_string(), EcuType::Aim, log) + } + + #[test] + fn cursor_values_for_log_b_use_its_own_record() { + let mut app = UltraLogApp::default(); + app.files.push(timed_file("a.csv", 10)); + app.files.push(timed_file("b.csv", 5)); + app.tabs.push(Tab::new(0, "a.csv".to_string())); + app.active_tab = Some(0); + app.choose_compare_file(0, Some(1)); + app.set_compare_offset(0, 2.0); + app.handle_set_cursor(4.0); + + let value = |app: &UltraLogApp, file: &str| match app.handle_get_cursor_values(file) { + IpcResponse::Ok(Some(ResponseData::CursorValues(v))) => Ok(v[0].value), + IpcResponse::Error { message } => Err(message), + _ => Err("unexpected".into()), + }; + assert!((value(&app, "0").unwrap() - 4.0).abs() < 1e-9); + // Log B at tab time 4.0 is its file time 2.0. + assert!((value(&app, "1").unwrap() - 2.0).abs() < 1e-9); + // Past log B's end (5 + 2 = 7 s): an error, not log A's record read from B. + app.handle_set_cursor(9.0); + assert!(value(&app, "1").is_err()); + } + + #[test] + fn deselect_all_clears_plot_areas_and_keeps_log_b() { + let mut app = UltraLogApp::default(); + app.files.push(timed_file("a.csv", 10)); + app.files.push(timed_file("b.csv", 5)); + app.tabs.push(Tab::new(0, "a.csv".to_string())); + app.active_tab = Some(0); + app.choose_compare_file(0, Some(1)); + app.add_channel(0, 0); + assert_eq!(app.tabs[0].plot_areas[0].channel_indices, vec![0, 1]); + + app.handle_deselect_all_channels(); + assert!(app.tabs[0].selected_channels.is_empty()); + assert!(app.tabs[0].plot_areas[0].channel_indices.is_empty()); + assert_eq!(app.tabs[0].compare_file(), Some(1)); + } + + #[test] + fn show_scatter_plot_targets_the_requested_files_tab() { + let mut app = UltraLogApp::default(); + app.files + .push(file_with_channels("a.xrk", &["Speed", "RPM", "MAP"])); + app.files + .push(file_with_channels("b.xrk", &["Dwell", "Cut"])); + app.tabs.push(Tab::new(0, "a.xrk".to_string())); + app.tabs.push(Tab::new(1, "b.xrk".to_string())); + // The other file's tab is active, as after loading a second log. + app.active_tab = Some(1); + + let response = app.handle_show_scatter_plot("0", "RPM", "MAP"); + + assert!(matches!(response, IpcResponse::Ok(_))); + assert_eq!(app.active_tab, Some(0)); + assert_eq!(app.tabs[0].scatter_plot_state.left.x_channel, Some(1)); + assert_eq!(app.tabs[0].scatter_plot_state.left.y_channel, Some(2)); + assert_eq!(app.tabs[1].scatter_plot_state.left.x_channel, None); + } + + #[test] + fn show_scatter_plot_rejects_unknown_channel() { + let mut app = UltraLogApp::default(); + app.files.push(file_with_channels("a.xrk", &["RPM"])); + app.tabs.push(Tab::new(0, "a.xrk".to_string())); + app.active_tab = Some(0); + + let response = app.handle_show_scatter_plot("0", "RPM", "Boost"); + + assert!(matches!(response, IpcResponse::Error { .. })); + assert_eq!(app.tabs[0].scatter_plot_state.left.x_channel, None); + } +} diff --git a/src/lib.rs b/src/lib.rs index 016bd2da..124be815 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,13 +17,11 @@ //! - [`mod@i18n`] - Internationalization support //! - [`settings`] - User settings persistence //! - [`ui`] - User interface components -//! - `sidebar` - File list and view options -//! - `channels` - Channel selection and display +//! - `channels` - Selected channel cards //! - `chart` - Main chart rendering and legends //! - `timeline` - Timeline scrubber and playback controls //! - `menu` - Menu bar (Units, Help) //! - `toast` - Toast notification system -//! - `icons` - Custom icon drawing utilities #[macro_use] extern crate rust_i18n; diff --git a/src/parsers/aim.rs b/src/parsers/aim.rs index 705db017..05818f13 100644 --- a/src/parsers/aim.rs +++ b/src/parsers/aim.rs @@ -281,8 +281,10 @@ impl Aim { // Channel index is at offset 0 (4 bytes) // Skip various metadata fields - // Short name starts at offset ~36 (8 bytes null-padded) - let short_name_offset = content_start + 30; + // Short name is at byte 36 of the record (8 bytes null-padded), + // long name at byte 44 (24 bytes). Reading 6 bytes later cut the + // front off every name ("Master Clk" -> "Clk"). + let short_name_offset = content_start + 24; if short_name_offset + 8 > data.len() { break; } @@ -536,6 +538,21 @@ mod tests { } } + #[test] + fn channel_names_are_read_whole() { + // The long name starts at byte 44 of each record. Reading from + // byte 50 produced "Clk", "Time", "ime" in place of these names. + let data = std::fs::read("exampleLogs/aim/BMW_THill 5mi_BY_Race_a_1448.xrk") + .expect("AiM fixture is bundled"); + let channels = Aim::parse_channels(&data).expect("channels parse"); + let names: Vec<&str> = channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!( + &names[..4], + &["Master Clk", "Lap Time", "Predictive Time", "Best Run Diff"] + ); + assert!(names.contains(&"EngFanCurrent")); + } + #[test] fn test_find_pattern() { let data = b"hello = LazyLock::new(|| Regex::new(r"^\d{1,2}:\d{2}:\d{2}").expect("Invalid timestamp regex")); use super::types::{Channel, Log, Meta, Parseable, Value}; +use crate::analysis::tables::events::is_invalid_sample; /// Haltech channel types - comprehensive list from actual log files #[derive(AsRefStr, Clone, Debug, EnumString, Serialize, Default)] @@ -388,6 +389,14 @@ impl Parseable for Haltech { .map(|(idx, v)| { let v = v.trim(); let raw_value: f64 = v.parse().ok()?; + // NSP writes an i32 sentinel family (-2147483617, ...) + // for "no reading". Scaled by the channel conversion it + // becomes a plausible-looking huge number (lambda / 1000 + // gave a -31,568,009 AFR minimum), so treat it like a + // blank field and let the last known value fill it. + if is_invalid_sample(raw_value) { + return None; + } // Apply conversion based on channel type if available let converted = if let Some(Channel::Haltech(ch)) = channels.get(idx) { @@ -548,6 +557,29 @@ Log : 20250718 02:15:46 assert_eq!(log.channels[1].unit(), "kPa"); } + #[test] + fn no_reading_sentinel_carries_last_value() { + // NSP writes -2147483617 when the wideband has no reading. Scaled as + // lambda it showed up as a -2,147,483.6 λ minimum on the channel card. + let sample = r#"%DataLog% +DataLogVersion : 1.1 +Software : Haltech NSP +Channel : Wideband O2 Overall +ID : 1 +Type : AFR +DisplayMaxMin : 2000,0 +Log : 20250718 02:15:46 +14:15:46.000,-2147483617 +14:15:46.020,980 +14:15:46.040,-2147483637 +14:15:46.060,1010 +"#; + let log = Haltech.parse(sample).unwrap(); + let lambda: Vec = log.data.iter().map(|row| row[0].as_f64()).collect(); + // Before the first reading the column reads 0.0, then holds 0.98. + assert_eq!(lambda, vec![0.0, 0.98, 0.98, 1.01]); + } + #[test] fn test_channel_type_conversions() { // RPM: no conversion diff --git a/src/parsers/types.rs b/src/parsers/types.rs index 77b9f4e5..d741d17d 100644 --- a/src/parsers/types.rs +++ b/src/parsers/types.rs @@ -401,6 +401,34 @@ impl EcuType { EcuType::Unknown => "Unknown", } } + + /// The short source code shown in file tabs (`ecu` badge), at most three letters. + pub fn short_code(&self) -> &'static str { + match self { + EcuType::Haltech => "HAL", + EcuType::Aim => "AIM", + EcuType::BlueDriver => "BLU", + EcuType::DynamicEfi => "DEF", + EcuType::Emerald => "EMR", + EcuType::EcuMaster => "EMU", + EcuType::MegaSquirt => "MS", + EcuType::Mhd => "MHD", + EcuType::MotorsportElectronics => "ME", + EcuType::Msl => "MSL", + EcuType::RaceChrono => "RCH", + EcuType::Aem => "AEM", + EcuType::MaxxEcu => "MAX", + EcuType::MotEc => "MTC", + EcuType::Link => "LNK", + EcuType::Locomotive => "LOC", + EcuType::RomRaider => "ROM", + // Speeduino and rusEFI share the MLG format and this variant: name the format. + EcuType::Speeduino => "MLG", + EcuType::TorquePro => "TRQ", + EcuType::Woolich => "WRT", + EcuType::Unknown => "LOG", + } + } } #[cfg(test)] diff --git a/src/settings.rs b/src/settings.rs index a8b794c4..c58f640d 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; use crate::i18n::Language; use crate::state::{FontScale, TileProviderId}; +use crate::ui::export_sheet::ExportChoice; use crate::units::UnitPreferences; /// User settings that persist across sessions @@ -38,6 +39,18 @@ pub struct UserSettings { /// Use the colorblind-friendly chart palette #[serde(default)] pub color_blind_mode: bool, + /// Ultra intensity, 0..=100: how much neon the chrome wears (0 = Stealth, the default). + /// Never changes data colours or text contrast. The serde default comes from + /// `theme::DEFAULT_INTENSITY`, so a settings file from before 3.0.0 opens at the same + /// level as a new install. + #[serde(default = "default_ultra_intensity")] + pub ultra_intensity: u8, + /// High contrast theme: true black grounds, AAA text, intensity effects locked off. + #[serde(default)] + pub high_contrast: bool, + /// ExportDialog choices: last format, and last theme per format (PDF, Paper by default). + #[serde(default)] + pub export: ExportChoice, /// Normalize ECU-specific channel names to standardized names #[serde(default = "default_true")] pub field_normalization: bool, @@ -85,6 +98,10 @@ fn default_version() -> u32 { 1 } +fn default_ultra_intensity() -> u8 { + crate::ui::theme::DEFAULT_INTENSITY as u8 +} + fn default_show_grid() -> bool { true } @@ -116,6 +133,9 @@ impl Default for UserSettings { unit_preferences: UnitPreferences::default(), font_scale: FontScale::default(), color_blind_mode: false, + ultra_intensity: default_ultra_intensity(), + high_contrast: false, + export: ExportChoice::default(), field_normalization: default_true(), cursor_tracking: default_true(), auto_check_updates: default_true(), diff --git a/src/state.rs b/src/state.rs index d0a3d2af..a39ce94d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -11,6 +11,8 @@ use crate::analysis::tables::GeneratorKind; use crate::colormap::Colormap; use crate::laps::{GpsCoordSpec, LapInfo}; use crate::parsers::{Channel, EcuType, Log}; +use crate::ui::theme::Tokens; +use eframe::egui::Color32; // ============================================================================ // Constants @@ -42,39 +44,18 @@ pub const SUPPORTED_EXTENSIONS: &[&str] = &[ "csv", "log", "txt", "msl", "mlg", "llg", "llg5", "xrk", "drk", "lg1", "lg2", ]; -/// Color palette for chart lines (matches original theme) -pub const CHART_COLORS: &[[u8; 3]] = &[ - [113, 120, 78], // Olive green (primary) - [191, 78, 48], // Rust orange (accent) - [71, 108, 155], // Blue (info) - [159, 166, 119], // Sage green (success) - [253, 193, 73], // Amber (warning) - [135, 30, 28], // Dark red (error) - [246, 247, 235], // Cream - [100, 149, 237], // Cornflower blue - [255, 127, 80], // Coral - [144, 238, 144], // Light green -]; - -/// Colorblind-friendly palette (based on Wong's optimized palette) -/// Designed to be distinguishable for deuteranopia, protanopia, and tritanopia -pub const COLORBLIND_COLORS: &[[u8; 3]] = &[ - [0, 114, 178], // Blue - [230, 159, 0], // Orange - [0, 158, 115], // Bluish green - [204, 121, 167], // Reddish purple - [86, 180, 233], // Sky blue - [213, 94, 0], // Vermillion - [240, 228, 66], // Yellow - [0, 0, 0], // Black (for contrast on light backgrounds, shows as white on dark) - [136, 204, 238], // Light blue - [153, 153, 153], // Gray -]; - // ============================================================================ // Core Types // ============================================================================ +/// The lowest colour slot that no selected channel uses. Slots are unbounded: past the palette +/// `theme::series` repeats the colours with a dashed, then dotted stroke, so a stacked view with +/// many channels never puts two identical lines on screen. +pub fn next_color_index(selected: &[SelectedChannel]) -> usize { + let used: std::collections::HashSet = selected.iter().map(|c| c.color_index).collect(); + (0..).find(|i| !used.contains(i)).unwrap_or(0) +} + /// Source of [`LoadedFile::load_id`] nonces. static NEXT_LOAD_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -176,7 +157,7 @@ pub enum LoadingState { } /// Type of toast notification (determines color) -#[derive(Clone, Copy, Default)] +#[derive(Clone, Copy, Debug, Default)] pub enum ToastType { /// Informational message (blue) #[default] @@ -190,21 +171,24 @@ pub enum ToastType { } impl ToastType { - /// Get the background color for this toast type - pub fn color(&self) -> [u8; 3] { + /// The status colour of this toast type (border and, from Phase 4, its icon). + pub fn accent(&self, t: &Tokens) -> Color32 { match self { - ToastType::Info => [71, 108, 155], // Blue - ToastType::Success => [113, 120, 78], // Olive green - ToastType::Warning => [253, 193, 73], // Amber - ToastType::Error => [135, 30, 28], // Dark red + ToastType::Info => t.info, + ToastType::Success => t.success, + ToastType::Warning => t.warning, + ToastType::Error => t.danger, } } - /// Get the text color for this toast type - pub fn text_color(&self) -> [u8; 3] { + /// The soft status fill behind the toast text. Info has no soft token of its own; it uses + /// `cyan_soft`, the fill behind cyan (Night Drive's info colour). + pub fn fill(&self, t: &Tokens) -> Color32 { match self { - ToastType::Warning => [30, 30, 30], // Dark text for amber background - _ => [255, 255, 255], // White text for other backgrounds + ToastType::Info => t.cyan_soft, + ToastType::Success => t.success_soft, + ToastType::Warning => t.warning_soft, + ToastType::Error => t.danger_soft, } } } @@ -346,14 +330,14 @@ impl ActivePanel { } } - /// Get the icon character for this panel (using Unicode symbols) - /// Note: Activity bar draws custom icons, this is kept for reference + /// The Phosphor icon for this panel (activity rail) pub fn icon(&self) -> &'static str { + use egui_phosphor::regular as icon; match self { - ActivePanel::Files => "\u{1F4C1}", // Folder icon - ActivePanel::ToolProperties => "\u{1F3DB}", // Sliders icon - ActivePanel::Tools => "\u{1F527}", // Wrench icon - ActivePanel::Settings => "\u{2699}", // Gear icon + ActivePanel::Files => icon::FOLDER_SIMPLE, + ActivePanel::ToolProperties => icon::SLIDERS_HORIZONTAL, + ActivePanel::Tools => icon::CHART_LINE_UP, + ActivePanel::Settings => icon::GEAR_SIX, } } } @@ -667,11 +651,6 @@ impl PlotArea { pub fn channel_count(&self) -> usize { self.channel_indices.len() } - - /// Check if this plot area can accept more channels - pub fn has_capacity(&self) -> bool { - self.channel_indices.len() < MAX_CHANNELS_PER_PLOT - } } // ============================================================================ @@ -755,6 +734,9 @@ pub struct ColorSignature { pub data_address: usize, pub data_length: usize, pub colormap: Colormap, + /// `UltraLogApp::theme_generation` when the colours were built: the cache holds `Color32`s, + /// so a theme change (Color blind mode, High contrast) must rebuild it. + pub theme_generation: u64, pub requested_min: Option, pub requested_max: Option, } @@ -896,6 +878,8 @@ pub struct Tab { pub jump_to_time: Option, /// Plot areas for stacked mode (ordered top to bottom) pub plot_areas: Vec, + /// The second log this tab compares against (issue #98), if any. + pub compare: Option, /// Whether stacked plot mode is enabled pub stacked_mode: bool, /// Next available plot area ID (for unique identification) @@ -928,17 +912,412 @@ impl Tab { data_panel_state: DataPanelState::default(), jump_to_time: None, plot_areas: vec![default_plot], + compare: None, stacked_mode: false, next_plot_area_id: 1, } } + + /// Whether channels from `file_index` may be selected in this tab: the tab's own file + /// (log A) or its comparison file (log B). + pub fn accepts_file(&self, file_index: usize) -> bool { + file_index == self.file_index || self.compare_file() == Some(file_index) + } + + /// The comparison file (log B), if the tab compares two logs. + pub fn compare_file(&self) -> Option { + self.compare.as_ref().map(|c| c.file_index) + } + + /// Seconds added to log B's times to place them on the tab's time axis (0 for log A). + pub fn time_offset(&self, file_index: usize) -> f64 { + match &self.compare { + Some(c) if c.file_index == file_index => c.offset_s, + _ => 0.0, + } + } + + /// Which log a selected channel comes from. + pub fn log_role(&self, channel: &SelectedChannel) -> LogRole { + if self.compare_file() == Some(channel.file_index) { + LogRole::B + } else { + LogRole::A + } + } + + /// The other half of a compare pair: the channel of the other log with the same colour slot. + pub fn partner(&self, idx: usize) -> Option { + let channel = self.selected_channels.get(idx)?; + self.selected_channels + .iter() + .position(|other| self.is_partner(channel, other)) + } + + /// [`Tab::partner`] for a channel given by value (the chart works on copies). + pub fn partner_of(&self, channel: &SelectedChannel) -> Option<&SelectedChannel> { + self.selected_channels + .iter() + .find(|other| self.is_partner(channel, other)) + } + + fn is_partner(&self, a: &SelectedChannel, b: &SelectedChannel) -> bool { + self.compare.is_some() + && a.color_index == b.color_index + && self.log_role(a) != self.log_role(b) + } + + /// How many units the channels at `indices` use against the channel caps: a pair counts as + /// one (rule R2 in `docs/plans/2026-10-25-log-compare.md`), so a B channel whose partner is + /// also in `indices` is free. + pub fn channel_units(&self, indices: &[usize]) -> usize { + indices + .iter() + .filter(|&&idx| { + let Some(channel) = self.selected_channels.get(idx) else { + return false; + }; + self.log_role(channel) == LogRole::A + || self.partner(idx).is_none_or(|p| !indices.contains(&p)) + }) + .count() + } + + /// Units used by all selected channels. + pub fn total_channel_units(&self) -> usize { + let all: Vec = (0..self.selected_channels.len()).collect(); + self.channel_units(&all) + } + + /// Whether the plot area with `plot_id` can take another unit. + pub fn plot_has_capacity(&self, plot_id: usize) -> bool { + self.plot_areas + .iter() + .find(|p| p.id == plot_id) + .is_some_and(|p| self.channel_units(&p.channel_indices) < MAX_CHANNELS_PER_PLOT) + } + + /// Remove the selected channel at `idx`, keeping plot areas and the track map's colour slot + /// pointing at the right channels. The one removal path for any tab. + pub fn remove_selected_channel(&mut self, idx: usize) { + if idx >= self.selected_channels.len() { + return; + } + for plot in &mut self.plot_areas { + plot.channel_indices.retain(|&i| i != idx); + for i in &mut plot.channel_indices { + if *i > idx { + *i -= 1; + } + } + } + self.data_panel_state + .track_map + .remove_color_channel_slot(idx); + self.selected_channels.remove(idx); + } + + /// Remove every selected channel from `file_index`, highest index first. + pub fn remove_channels_of_file(&mut self, file_index: usize) { + for idx in (0..self.selected_channels.len()).rev() { + if self.selected_channels[idx].file_index == file_index { + self.remove_selected_channel(idx); + } + } + } + + /// Update this tab after file `index` was removed from the app's file list: drop its + /// channels, stop comparing against it, and shift every file index above it down by one. + /// Runs on every tab, whatever its own file, because log B can be any file. + pub fn remap_after_file_removed(&mut self, index: usize) { + self.remove_channels_of_file(index); + if self.compare_file() == Some(index) { + self.compare = None; + } + if self.file_index > index { + self.file_index -= 1; + } + if let Some(compare) = self.compare.as_mut() + && compare.file_index > index + { + compare.file_index -= 1; + } + for channel in &mut self.selected_channels { + if channel.file_index > index { + channel.file_index -= 1; + } + } + } +} + +/// Which log a selected channel comes from while its tab compares two logs (issue #98). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LogRole { + /// The tab's own file. + A, + /// The comparison file. + B, +} + +/// A tab's comparison log (issue #98, `docs/plans/2026-10-25-log-compare.md`). Tab time is log +/// A's time; log B's sample at file time `t` sits at `t + offset_s`. +#[derive(Clone, Debug, PartialEq)] +pub struct CompareState { + /// The comparison file (log B). + pub file_index: usize, + /// Seconds added to log B's times to place them on the tab's time axis. + pub offset_s: f64, + /// True while "Align at cursor" waits for the click on log B's matching point. + pub picking_alignment: bool, + /// The cursor time when "Align at cursor" started: the log-A event B is lined up with. Kept + /// apart from the live cursor, which playback or track-map hover can move before the click. + pub align_target: Option, + /// The channel list shows log B's channels (the A | B switch). + pub list_b: bool, +} + +impl CompareState { + pub fn new(file_index: usize) -> Self { + Self { + file_index, + offset_s: 0.0, + picking_alignment: false, + align_target: None, + list_b: false, + } + } +} + +/// The record at file time `t`: the nearest sample, the same rule as the tab cursor record, the +/// record counter and the track-map dot; or `None` outside the log's span (rule R4), so a log +/// never reports a value where it has no data. +pub fn record_at(times: &[f64], t: f64) -> Option { + let (&first, &last) = (times.first()?, times.last()?); + if !(first..=last).contains(&t) { + return None; + } + nearest_record(times, t) +} + +/// The log-A record an arrow key moves the cursor to, `step` records from `current`. Outside log +/// A's span (the tab range can be wider while comparing) the first step enters the span on the +/// near edge and a step away from it does nothing, so the cursor never jumps the wrong way. +pub fn arrow_step( + times: &[f64], + cursor_time: Option, + current: usize, + step: isize, +) -> Option { + let (&first, &last) = (times.first()?, times.last()?); + let last_idx = times.len() - 1; + match cursor_time { + Some(t) if t < first => (step > 0).then_some(0), + Some(t) if t > last => (step < 0).then_some(last_idx), + _ => Some(current.saturating_add_signed(step).min(last_idx)), + } +} + +/// The record nearest to `t` (clamped to the log's ends). The cursor record of log A. +pub fn nearest_record(times: &[f64], t: f64) -> Option { + if times.is_empty() { + return None; + } + let i = times.partition_point(|&x| x < t).min(times.len() - 1); + if i > 0 && (times[i] - t).abs() > (times[i - 1] - t).abs() { + Some(i - 1) + } else { + Some(i) + } +} + +/// The tab time range: log A's range joined with log B's range shifted by `offset_s`. +pub fn union_range( + a: Option<(f64, f64)>, + b: Option<(f64, f64)>, + offset_s: f64, +) -> Option<(f64, f64)> { + let b = b.map(|(b0, b1)| (b0 + offset_s, b1 + offset_s)); + match (a, b) { + (Some((a0, a1)), Some((b0, b1))) => Some((a0.min(b0), a1.max(b1))), + (a, None) => a, + (None, b) => b, + } } static NEXT_TAB_ID: AtomicU64 = AtomicU64::new(1); #[cfg(test)] mod tests { - use super::{Tab, TrackMapState}; + use super::{ + CompareState, LogRole, PlotArea, SelectedChannel, Tab, TrackMapState, arrow_step, + nearest_record, record_at, union_range, + }; + use crate::parsers::Channel; + use crate::parsers::aim::AimChannel; + + fn channel(file_index: usize, channel_index: usize, color_index: usize) -> SelectedChannel { + SelectedChannel { + file_index, + channel_index, + channel: Channel::Aim(AimChannel { + name: format!("ch{channel_index}"), + unit: String::new(), + }), + color_index, + } + } + + /// Tab on file 0 comparing against file 1: A0 (slot 0), B0 (slot 0, its pair), A1 (slot 1), + /// B-only (slot 2). + fn comparing_tab() -> Tab { + let mut tab = Tab::new(0, "a".to_string()); + tab.compare = Some(CompareState::new(1)); + tab.selected_channels = vec![ + channel(0, 0, 0), + channel(1, 0, 0), + channel(0, 1, 1), + channel(1, 5, 2), + ]; + tab.plot_areas[0].channel_indices = vec![0, 1, 2, 3]; + tab + } + + #[test] + fn record_at_is_none_outside_the_span() { + let times = [1.0, 2.0, 3.0]; + assert_eq!(record_at(×, 0.99), None); + assert_eq!(record_at(×, 1.0), Some(0)); + assert_eq!(record_at(×, 2.4), Some(1)); + assert_eq!(record_at(×, 2.6), Some(2)); + assert_eq!(record_at(×, 3.0), Some(2)); + assert_eq!(record_at(×, 3.01), None); + assert_eq!(record_at(&[], 1.0), None); + } + + #[test] + fn arrow_steps_enter_log_a_from_the_near_edge() { + let times = [0.0, 1.0, 2.0]; + // Inside the span: plain steps, clamped. + assert_eq!(arrow_step(×, Some(1.0), 1, -1), Some(0)); + assert_eq!(arrow_step(×, Some(1.0), 1, 10), Some(2)); + assert_eq!(arrow_step(×, Some(1.0), 1, -10), Some(0)); + // Before log A (cursor at -2 with a negative log B offset): Right enters, Left stays. + assert_eq!(arrow_step(×, Some(-2.0), 0, 1), Some(0)); + assert_eq!(arrow_step(×, Some(-2.0), 0, -1), None); + // After log A: Left enters at the last record, Right stays. + assert_eq!(arrow_step(×, Some(5.0), 2, -1), Some(2)); + assert_eq!(arrow_step(×, Some(5.0), 2, 1), None); + } + + #[test] + fn nearest_record_clamps_to_the_ends() { + let times = [1.0, 2.0, 3.0]; + assert_eq!(nearest_record(×, -5.0), Some(0)); + assert_eq!(nearest_record(×, 1.4), Some(0)); + assert_eq!(nearest_record(×, 1.6), Some(1)); + assert_eq!(nearest_record(×, 9.0), Some(2)); + assert_eq!(nearest_record(&[], 1.0), None); + } + + #[test] + fn union_range_shifts_log_b() { + assert_eq!( + union_range(Some((0.0, 10.0)), Some((0.0, 8.0)), -3.0), + Some((-3.0, 10.0)) + ); + assert_eq!( + union_range(Some((0.0, 10.0)), Some((0.0, 8.0)), 5.0), + Some((0.0, 13.0)) + ); + assert_eq!(union_range(Some((0.0, 1.0)), None, 5.0), Some((0.0, 1.0))); + assert_eq!(union_range(None, Some((0.0, 1.0)), 2.0), Some((2.0, 3.0))); + } + + #[test] + fn log_roles_partners_and_offset() { + let mut tab = comparing_tab(); + tab.compare.as_mut().unwrap().offset_s = 1.5; + assert_eq!(tab.log_role(&tab.selected_channels[0]), LogRole::A); + assert_eq!(tab.log_role(&tab.selected_channels[1]), LogRole::B); + assert_eq!(tab.partner(0), Some(1)); + assert_eq!(tab.partner(1), Some(0)); + assert_eq!(tab.partner(2), None); + assert_eq!(tab.partner(3), None); + assert_eq!(tab.time_offset(1), 1.5); + assert_eq!(tab.time_offset(0), 0.0); + assert!(tab.accepts_file(0) && tab.accepts_file(1) && !tab.accepts_file(2)); + } + + #[test] + fn a_pair_counts_as_one_unit() { + let tab = comparing_tab(); + // A0+B0 pair = 1, A1 = 1, B-only = 1. + assert_eq!(tab.total_channel_units(), 3); + // A B channel whose partner is in another plot counts on its own. + assert_eq!(tab.channel_units(&[1, 3]), 2); + + // Ten pairs fill a plot; the eleventh unit does not fit. + let mut full = Tab::new(0, "a".to_string()); + full.compare = Some(CompareState::new(1)); + for slot in 0..10 { + full.selected_channels.push(channel(0, slot, slot)); + full.selected_channels.push(channel(1, slot, slot)); + } + full.plot_areas[0].channel_indices = (0..20).collect(); + assert_eq!(full.total_channel_units(), 10); + assert!(!full.plot_has_capacity(0)); + full.remove_selected_channel(19); + assert!(!full.plot_has_capacity(0), "B removed: still 10 A units"); + full.remove_selected_channel(18); + assert!(full.plot_has_capacity(0)); + } + + #[test] + fn removing_a_channel_shifts_plot_areas_in_any_tab() { + let mut tab = comparing_tab(); + tab.stacked_mode = true; + tab.plot_areas = vec![PlotArea::new(0, "P1".into()), PlotArea::new(1, "P2".into())]; + tab.plot_areas[0].channel_indices = vec![0, 1]; + tab.plot_areas[1].channel_indices = vec![2, 3]; + tab.data_panel_state.track_map.color_channel = Some(3); + + tab.remove_channels_of_file(1); + + assert_eq!(tab.selected_channels.len(), 2); + assert_eq!(tab.plot_areas[0].channel_indices, vec![0]); + assert_eq!(tab.plot_areas[1].channel_indices, vec![1]); + assert_eq!(tab.data_panel_state.track_map.color_channel, None); + } + + #[test] + fn remap_after_file_removed_handles_b_below_and_above_a() { + // B (file 0) below A (file 2); remove file 1, which neither uses. + let mut tab = Tab::new(2, "a".to_string()); + tab.compare = Some(CompareState::new(0)); + tab.selected_channels = vec![channel(2, 0, 0), channel(0, 0, 0)]; + tab.plot_areas[0].channel_indices = vec![0, 1]; + tab.remap_after_file_removed(1); + assert_eq!(tab.file_index, 1); + assert_eq!(tab.compare_file(), Some(0)); + assert_eq!(tab.selected_channels[0].file_index, 1); + assert_eq!(tab.selected_channels[1].file_index, 0); + + // B (file 3) above A (file 0); remove file 1: only B shifts. + let mut tab = Tab::new(0, "a".to_string()); + tab.compare = Some(CompareState::new(3)); + tab.selected_channels = vec![channel(0, 0, 0), channel(3, 0, 0)]; + tab.plot_areas[0].channel_indices = vec![0, 1]; + tab.remap_after_file_removed(1); + assert_eq!(tab.file_index, 0); + assert_eq!(tab.compare_file(), Some(2)); + assert_eq!(tab.selected_channels[1].file_index, 2); + + // Removing log B itself drops its channels and stops comparing. + tab.remap_after_file_removed(2); + assert!(tab.compare.is_none()); + assert_eq!(tab.selected_channels.len(), 1); + assert_eq!(tab.plot_areas[0].channel_indices, vec![0]); + } #[test] fn track_map_color_slot_follows_channel_removals() { diff --git a/src/ui/about_window.rs b/src/ui/about_window.rs new file mode 100644 index 00000000..c60b0c4f --- /dev/null +++ b/src/ui/about_window.rs @@ -0,0 +1,145 @@ +//! About window (`design/HANDOFF-APP.md`: the lockup on `bg_void` with the redline): version, +//! licence, links and Check for Updates. + +use eframe::egui::{self, Align2, CornerRadius, RichText, Sense, vec2}; +use rust_i18n::t; + +use crate::app::UltraLogApp; +use crate::ui::components::{self, Kind, icon}; +use crate::ui::export_sheet; +use crate::ui::theme::tokens::{radius, space}; +use crate::ui::theme::{self, ExportTheme}; + +const WEBSITE: &str = "https://ultralog.co"; +const REPOSITORY: &str = "https://github.com/ClassicMiniDIY/UltraLog"; + +/// The dark lockup as a texture, uploaded once per context. +fn lockup_texture(ctx: &egui::Context) -> egui::TextureHandle { + let id = egui::Id::new("about_lockup_texture"); + if let Some(tex) = ctx.data(|d| d.get_temp::(id)) { + return tex; + } + let img = export_sheet::lockup(ExportTheme::Graphite); + let color = egui::ColorImage::from_rgba_unmultiplied( + [img.width() as usize, img.height() as usize], + img.as_raw(), + ); + let tex = ctx.load_texture("about_lockup", color, egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(id, tex.clone())); + tex +} + +impl UltraLogApp { + pub fn render_about_window(&mut self, ctx: &egui::Context) { + if !self.show_about { + return; + } + let mut open = true; + let mut check_updates = false; + let checking = matches!( + self.update_state, + crate::updater::UpdateState::Checking | crate::updater::UpdateState::Downloading + ); + let response = components::window(t!("about.title")) + .id(egui::Id::new("about_window")) + .open(&mut open) + .resizable(false) + .collapsible(false) + .default_width(400.0) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + let t = theme::current(ui.ctx()); + ui.spacing_mut().item_spacing.y = space::SPACE_2; + + // The lockup on bg_void. + let (band, _) = + ui.allocate_exact_size(vec2(ui.available_width(), 96.0), Sense::hover()); + ui.painter() + .rect_filled(band, CornerRadius::same(radius::RADIUS_MD), t.bg_void); + let tex = lockup_texture(ui.ctx()); + let [w, h] = tex.size_vec2().into(); + let height = 40.0; + ui.painter().image( + tex.id(), + egui::Rect::from_center_size(band.center(), vec2(height * w / h, height)), + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + theme::image_tint(1.0), + ); + + ui.add_space(space::SPACE_2); + ui.vertical_centered(|ui| { + ui.label(RichText::new(t!("about.tagline")).color(t.ink_muted)); + ui.label( + RichText::new(t!("menu.version", version = env!("CARGO_PKG_VERSION"))) + .text_style(egui::TextStyle::Monospace) + .color(t.ink), + ); + ui.label( + RichText::new(t!("about.license", license = env!("CARGO_PKG_LICENSE"))) + .text_style(egui::TextStyle::Small) + .color(t.ink_subtle), + ); + }); + ui.add_space(space::SPACE_2); + + let links = [ + (icon::GLOBE, t!("about.website"), WEBSITE.to_string()), + ( + icon::BOOK_OPEN, + t!("menu.documentation"), + format!("{REPOSITORY}/wiki"), + ), + ( + icon::GITHUB_LOGO, + t!("about.source"), + REPOSITORY.to_string(), + ), + ( + icon::BUG, + t!("menu.report_issue"), + format!("{REPOSITORY}/issues"), + ), + ( + icon::HEART, + t!("menu.support_development"), + "https://github.com/sponsors/ClassicMiniDIY".to_string(), + ), + ]; + ui.horizontal_wrapped(|ui| { + for (glyph, label, url) in links { + let link = components::button(ui, Kind::Ghost, Some(glyph), label) + .on_hover_text(&url); + if link.clicked() { + let _ = open::that(&url); + } + } + }); + + ui.separator(); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let label = if checking { + t!("menu.checking_for_updates") + } else { + t!("menu.check_for_updates") + }; + ui.add_enabled_ui(!checking, |ui| { + let check = components::button( + ui, + Kind::Secondary, + Some(icon::ARROWS_CLOCKWISE), + label, + ); + if check.clicked() { + check_updates = true; + } + }); + }); + }); + components::finish_window(ctx, response.as_ref().map(|r| &r.response), &mut open); + self.show_about = open; + if check_updates { + self.start_update_check(); + } + } +} diff --git a/src/ui/activity_bar.rs b/src/ui/activity_bar.rs index 9de23da8..3b5e7090 100644 --- a/src/ui/activity_bar.rs +++ b/src/ui/activity_bar.rs @@ -1,246 +1,83 @@ -//! Activity bar component - VS Code-style vertical icon strip for panel navigation. +//! ActivityRail: the vertical bar that switches the side panel +//! (`design/design-system/components/ActivityRail/README.md`). +//! +//! 20px outline icons at `ink_subtle`; hover `ink` on `bg_hover`; active `cyan` on `cyan_soft` +//! with a 2px cyan indicator at the rail's left edge. Settings is pinned to the bottom. -use eframe::egui; +use eframe::egui::{self, Align2, CornerRadius, Rect, Sense, pos2, vec2}; use crate::app::UltraLogApp; use crate::state::ActivePanel; +use crate::ui::theme::tokens::{radius, space, stroke}; /// Width of the activity bar in pixels pub const ACTIVITY_BAR_WIDTH: f32 = 48.0; -/// Size of icons in the activity bar -const ICON_SIZE: f32 = 24.0; +/// Size of the square hit area behind each icon (px) +const BUTTON_SIZE: f32 = 36.0; -/// Padding around icons (reserved for future use) -#[allow(dead_code)] -const ICON_PADDING: f32 = 12.0; +/// Icon size (px) +const ICON_SIZE: f32 = 20.0; impl UltraLogApp { /// Render the activity bar (vertical icon strip on the far left) pub fn render_activity_bar(&mut self, ui: &mut egui::Ui) { - ui.vertical(|ui| { - ui.add_space(8.0); - - // Render each panel icon - for panel in [ - ActivePanel::Files, - ActivePanel::ToolProperties, - ActivePanel::Tools, - ActivePanel::Settings, - ] { - let is_selected = self.active_panel == panel; - self.render_activity_icon(ui, panel, is_selected); - ui.add_space(4.0); - } + ui.spacing_mut().item_spacing.y = space::SPACE_1; + ui.add_space(space::SPACE_2); + for panel in [ + ActivePanel::Files, + ActivePanel::ToolProperties, + ActivePanel::Tools, + ] { + self.render_activity_icon(ui, panel); + } + ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| { + ui.add_space(space::SPACE_2); + self.render_activity_icon(ui, ActivePanel::Settings); }); } /// Render a single activity bar icon button - fn render_activity_icon(&mut self, ui: &mut egui::Ui, panel: ActivePanel, is_selected: bool) { - let button_size = egui::vec2(ACTIVITY_BAR_WIDTH - 8.0, ACTIVITY_BAR_WIDTH - 8.0); - - // Colors - let bg_color = if is_selected { - egui::Color32::from_rgb(60, 60, 60) - } else { - egui::Color32::TRANSPARENT - }; - - let icon_color = if is_selected { - egui::Color32::WHITE - } else { - egui::Color32::from_rgb(150, 150, 150) - }; - - let hover_bg = egui::Color32::from_rgb(50, 50, 50); - - // Selected indicator bar on the left - let indicator_color = egui::Color32::from_rgb(113, 120, 78); // Olive green accent - - let (rect, response) = ui.allocate_exact_size(button_size, egui::Sense::click()); - - if response.clicked() { - self.active_panel = panel; - } - - let is_hovered = response.hovered(); - - // Draw background - let final_bg = if is_hovered && !is_selected { - hover_bg + fn render_activity_icon(&mut self, ui: &mut egui::Ui, panel: ActivePanel) { + let t = self.tokens; + let selected = self.active_panel == panel; + let (row, response) = + ui.allocate_exact_size(vec2(ACTIVITY_BAR_WIDTH, BUTTON_SIZE), Sense::click()); + let button = Rect::from_center_size(row.center(), vec2(BUTTON_SIZE, BUTTON_SIZE)); + let hovered = response.hovered(); + + let (fill, color) = if selected { + (Some(t.cyan_soft), t.cyan) + } else if hovered { + (Some(t.bg_hover), t.ink) } else { - bg_color + (None, t.ink_subtle) }; - - if final_bg != egui::Color32::TRANSPARENT { - ui.painter() - .rect_filled(rect, egui::CornerRadius::same(4), final_bg); + let painter = ui.painter(); + if let Some(fill) = fill { + painter.rect_filled(button, CornerRadius::same(radius::RADIUS_MD), fill); } - - // Draw selection indicator bar on left edge - if is_selected { - let indicator_rect = - egui::Rect::from_min_size(rect.left_top(), egui::vec2(3.0, rect.height())); - ui.painter() - .rect_filled(indicator_rect, egui::CornerRadius::ZERO, indicator_color); + if selected { + let indicator = Rect::from_min_size( + pos2(row.left(), button.top() + space::SPACE_1), + vec2(stroke::STROKE_INDICATOR, BUTTON_SIZE - 2.0 * space::SPACE_1), + ); + painter.rect_filled(indicator, CornerRadius::ZERO, t.cyan); } + painter.text( + button.center(), + Align2::CENTER_CENTER, + panel.icon(), + egui::FontId::proportional(ICON_SIZE), + color, + ); - // Draw the icon - let center = rect.center(); - self.draw_panel_icon(ui, center, ICON_SIZE, icon_color, panel); - - // Tooltip on hover - if is_hovered { - response.on_hover_text(panel.name()); + if response.clicked() { + self.active_panel = panel; } - } - - /// Draw the icon for a specific panel type - fn draw_panel_icon( - &self, - ui: &egui::Ui, - center: egui::Pos2, - size: f32, - color: egui::Color32, - panel: ActivePanel, - ) { - let painter = ui.painter(); - let half = size / 2.0; - - match panel { - ActivePanel::Files => { - // Folder icon - let folder_width = size * 0.9; - let folder_height = size * 0.7; - let tab_width = folder_width * 0.4; - let tab_height = folder_height * 0.15; - - // Main folder body - let body_rect = egui::Rect::from_center_size( - egui::pos2(center.x, center.y + tab_height / 2.0), - egui::vec2(folder_width, folder_height - tab_height), - ); - painter.rect_stroke( - body_rect, - egui::CornerRadius::same(2), - egui::Stroke::new(1.5, color), - egui::StrokeKind::Outside, - ); - - // Folder tab - let tab_rect = egui::Rect::from_min_size( - egui::pos2(body_rect.left() + 2.0, body_rect.top() - tab_height), - egui::vec2(tab_width, tab_height + 2.0), - ); - painter.rect_filled(tab_rect, egui::CornerRadius::same(1), color); - } - ActivePanel::ToolProperties => { - // Horizontal sliders icon (representing tool properties/controls) - let stroke = egui::Stroke::new(1.5, color); - let slider_height = size * 0.8; - let slider_width = size * 0.7; - let knob_size = size * 0.15; - - // Three horizontal sliders - let slider_spacing = slider_height / 3.0; - let start_y = center.y - slider_height / 2.0 + slider_spacing; - let left = center.x - slider_width / 2.0; - let right = center.x + slider_width / 2.0; - - for i in 0..3 { - let y = start_y + (i as f32) * slider_spacing; - - // Draw slider track - painter.line_segment([egui::pos2(left, y), egui::pos2(right, y)], stroke); - - // Draw slider knob at different positions - let knob_positions = [0.3, 0.7, 0.5]; // Different positions for visual variety - let knob_x = left + slider_width * knob_positions[i]; - painter.circle_filled(egui::pos2(knob_x, y), knob_size, color); - } - } - ActivePanel::Tools => { - // Line chart icon (for analysis/computed channels) - let stroke = egui::Stroke::new(2.0, color); - let chart_width = size * 0.8; - let chart_height = size * 0.6; - let left = center.x - chart_width / 2.0; - let right = center.x + chart_width / 2.0; - let top = center.y - chart_height / 2.0; - let bottom = center.y + chart_height / 2.0; - - // Draw axes - painter.line_segment([egui::pos2(left, top), egui::pos2(left, bottom)], stroke); - painter.line_segment( - [egui::pos2(left, bottom), egui::pos2(right, bottom)], - stroke, - ); - - // Draw a line chart curve - let points = [ - egui::pos2(left + chart_width * 0.1, bottom - chart_height * 0.2), - egui::pos2(left + chart_width * 0.3, bottom - chart_height * 0.6), - egui::pos2(left + chart_width * 0.5, bottom - chart_height * 0.4), - egui::pos2(left + chart_width * 0.7, bottom - chart_height * 0.8), - egui::pos2(left + chart_width * 0.9, bottom - chart_height * 0.5), - ]; - - for i in 0..points.len() - 1 { - painter.line_segment([points[i], points[i + 1]], stroke); - } - } - ActivePanel::Settings => { - // Gear/cog icon with rectangular teeth - let body_radius = half * 0.55; - let tooth_outer = half * 0.85; - let tooth_width = 0.35; // Width of each tooth in radians - let teeth = 6; - - // Draw the gear body (filled circle) - painter.circle_stroke(center, body_radius, egui::Stroke::new(2.0, color)); - - // Draw rectangular teeth - for i in 0..teeth { - let angle = (i as f32) * std::f32::consts::TAU / teeth as f32; - - // Calculate the four corners of each tooth - let angle_left = angle - tooth_width / 2.0; - let angle_right = angle + tooth_width / 2.0; - - // Inner corners (on the body circle) - let inner_left = egui::pos2( - center.x + body_radius * angle_left.cos(), - center.y + body_radius * angle_left.sin(), - ); - let inner_right = egui::pos2( - center.x + body_radius * angle_right.cos(), - center.y + body_radius * angle_right.sin(), - ); - - // Outer corners (extending outward) - let outer_left = egui::pos2( - center.x + tooth_outer * angle_left.cos(), - center.y + tooth_outer * angle_left.sin(), - ); - let outer_right = egui::pos2( - center.x + tooth_outer * angle_right.cos(), - center.y + tooth_outer * angle_right.sin(), - ); - - // Draw the tooth as a filled polygon - let tooth_points = vec![inner_left, outer_left, outer_right, inner_right]; - painter.add(egui::Shape::convex_polygon( - tooth_points, - color, - egui::Stroke::NONE, - )); - } - - // Draw center hole - let hole_radius = half * 0.2; - painter.circle_filled(center, hole_radius, egui::Color32::from_rgb(30, 30, 30)); - painter.circle_stroke(center, hole_radius, egui::Stroke::new(1.5, color)); - } + if hovered { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } + response.on_hover_text(panel.name()); } } diff --git a/src/ui/analysis_panel.rs b/src/ui/analysis_panel.rs index 741119cd..3cfbacf5 100644 --- a/src/ui/analysis_panel.rs +++ b/src/ui/analysis_panel.rs @@ -13,7 +13,8 @@ use crate::computed::{ComputedChannel, ComputedChannelTemplate}; use crate::normalize::sort_channels_by_priority; use crate::parsers::Channel; use crate::parsers::types::ComputedChannelInfo; -use crate::state::{CHART_COLORS, SelectedChannel}; +use crate::state::SelectedChannel; +use crate::ui::components::{self, Kind}; /// Info about an analyzer for display (avoids borrow issues) struct AnalyzerInfo { @@ -54,7 +55,12 @@ impl UltraLogApp { let mut open = true; - egui::Window::new(t!("analysis.window_title")) + let window_response = components::window(t!("analysis.window_title")) + // First open is centered; egui's default (0, 0) put the title bar + // over the menu bar. The user can still drag it, and egui keeps + // the moved position after that. + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) .open(&mut open) .resizable(true) .default_width(550.0) @@ -69,13 +75,13 @@ impl UltraLogApp { ui.add_space(40.0); ui.label( egui::RichText::new(t!("analysis.no_file_loaded")) - .color(egui::Color32::GRAY) - .size(16.0), + .color(self.tokens.ink_subtle) + .heading(), ); ui.add_space(8.0); ui.label( egui::RichText::new(t!("analysis.load_file_help")) - .color(egui::Color32::GRAY) + .color(self.tokens.ink_subtle) .small(), ); ui.add_space(40.0); @@ -93,6 +99,11 @@ impl UltraLogApp { } }); + components::finish_window( + ctx, + window_response.as_ref().map(|r| &r.response), + &mut open, + ); if !open { self.show_analysis_panel = false; } @@ -121,12 +132,12 @@ impl UltraLogApp { let text = if is_selected { text.strong() } else { - text.color(egui::Color32::GRAY) + text.color(self.tokens.ink_muted) }; let btn = egui::Button::new(text) .fill(if is_selected { - egui::Color32::from_rgb(60, 70, 60) + self.tokens.bg_active } else { egui::Color32::TRANSPARENT }) @@ -224,7 +235,7 @@ impl UltraLogApp { count = results.len() )) .strong() - .size(15.0), + .heading(), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -281,7 +292,7 @@ impl UltraLogApp { filtered_infos.len() )) .strong() - .size(15.0), + .heading(), ); }); @@ -289,7 +300,8 @@ impl UltraLogApp { if filtered_infos.is_empty() { ui.label( - egui::RichText::new(t!("analysis.no_analyzers")).color(egui::Color32::GRAY), + egui::RichText::new(t!("analysis.no_analyzers")) + .color(self.tokens.ink_subtle), ); } else { // Render analyzer cards @@ -366,6 +378,7 @@ impl UltraLogApp { channel_display_names: &[String], ) -> Option<(String, AnalyzerAction)> { let mut action: Option = None; + let t = crate::ui::theme::current(ui.ctx()); let mut new_config = info.config.clone(); // Get parameter definitions for this analyzer @@ -380,33 +393,36 @@ impl UltraLogApp { ); let card_bg = if channels_available { - egui::Color32::from_rgb(40, 45, 40) + t.bg_raised } else { - egui::Color32::from_rgb(45, 45, 45) + t.bg_field }; egui::Frame::NONE .fill(card_bg) + .stroke(egui::Stroke::new(1.0, t.line)) .corner_radius(6) .inner_margin(10.0) .outer_margin(egui::Margin::symmetric(0, 2)) .show(ui, |ui| { - // Header row with name and Run button + // Header row with name and Run buttons. The description goes on + // its own full-width row below: inside this row it wrapped + // under the right-aligned buttons and the two overlapped. ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.label(egui::RichText::new(&info.name).strong().size(14.0)); - ui.label( - egui::RichText::new(&info.description) - .color(egui::Color32::GRAY) - .size(12.0), - ); - }); + ui.label(egui::RichText::new(&info.name).strong().heading()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // "Run & Chart" button (primary action) - let run_chart_btn = egui::Button::new(t!("analysis.run_and_chart")) - .fill(egui::Color32::from_rgb(60, 100, 60)); - let run_chart_response = ui.add_enabled(channels_available, run_chart_btn); + let run_chart_response = ui + .add_enabled_ui(channels_available, |ui| { + components::button( + ui, + Kind::Primary, + None, + t!("analysis.run_and_chart"), + ) + }) + .inner; if run_chart_response.clicked() { action = Some(AnalyzerAction::RunAndChart); @@ -436,6 +452,12 @@ impl UltraLogApp { }); }); + ui.label( + egui::RichText::new(&info.description) + .color(t.ink_subtle) + .small(), + ); + ui.add_space(6.0); // Parameter configuration @@ -445,7 +467,7 @@ impl UltraLogApp { ui.horizontal(|ui| { let label_response = ui.label( egui::RichText::new(¶m.label) - .color(egui::Color32::GRAY) + .color(t.ink_subtle) .small(), ); @@ -609,9 +631,10 @@ impl UltraLogApp { _index: usize, ) -> Option { let mut action: Option = None; + let t = crate::ui::theme::current(ui.ctx()); egui::Frame::NONE - .fill(egui::Color32::from_rgb(35, 50, 55)) + .fill(t.bg_raised) .corner_radius(6) .inner_margin(8.0) .outer_margin(egui::Margin::symmetric(0, 2)) @@ -623,7 +646,7 @@ impl UltraLogApp { if !result.unit.is_empty() { ui.label( egui::RichText::new(format!("({})", result.unit)) - .color(egui::Color32::GRAY) + .color(t.ink_subtle) .small(), ); } @@ -648,7 +671,7 @@ impl UltraLogApp { mean, result.values.len() )) - .color(egui::Color32::GRAY) + .color(t.ink_subtle) .small(), ); } @@ -725,15 +748,7 @@ impl UltraLogApp { let base_channel_count = file.log.channels.len(); let virtual_channel_index = base_channel_count + computed_channels.len() - 1; - // Find next available color - let used_colors: std::collections::HashSet = self.tabs[tab_idx] - .selected_channels - .iter() - .map(|c| c.color_index) - .collect(); - let color_index = (0..CHART_COLORS.len()) - .find(|&i| !used_colors.contains(&i)) - .unwrap_or(0); + let color_index = crate::state::next_color_index(&self.tabs[tab_idx].selected_channels); // Create the channel enum variant let channel = Channel::Computed(ComputedChannelInfo { diff --git a/src/ui/channels.rs b/src/ui/channels.rs index cfc029cf..f660b4da 100644 --- a/src/ui/channels.rs +++ b/src/ui/channels.rs @@ -2,267 +2,26 @@ use eframe::egui; +use rust_i18n::t; + use crate::app::UltraLogApp; -use crate::normalize::{normalize_channel_name_with_custom, sort_channels_by_priority}; -use crate::state::MAX_CHANNELS; +use crate::normalize::normalize_channel_name_with_custom; +use crate::ui::components::{self, icon}; +use crate::ui::fonts::{self, style}; +use crate::ui::plot_style; +use crate::ui::theme::{ + SeriesStroke, + tokens::{radius, space, stroke}, +}; impl UltraLogApp { - /// Render channel selection panel - fills available space - pub fn render_channel_selection(&mut self, ui: &mut egui::Ui) { - // Pre-compute scaled font sizes - let font_14 = self.scaled_font(14.0); - let font_16 = self.scaled_font(16.0); - let font_18 = self.scaled_font(18.0); - - ui.label(egui::RichText::new("Channels").heading().size(font_18)); - ui.separator(); - - // Get active tab info - let tab_info = self.active_tab.and_then(|tab_idx| { - let tab = &self.tabs[tab_idx]; - if tab.file_index < self.files.len() { - Some(( - tab.file_index, - tab.channel_search.clone(), - tab.selected_channels.len(), - )) - } else { - None - } - }); - - if let Some((file_index, current_search, selected_count)) = tab_info { - let channel_count = self.files[file_index].log.channels.len(); - - // Computed Channels button - if ui - .button(egui::RichText::new("+ Computed Channels").size(font_14)) - .on_hover_text("Create virtual channels from mathematical formulas") - .clicked() - { - self.show_computed_channels_manager = true; - } - - ui.add_space(4.0); - - // Search box - use a temporary string that we'll update - let mut search_text = current_search; - let mut search_changed = false; - ui.horizontal(|ui| { - ui.label(egui::RichText::new("Search:").size(font_14)); - let response = ui - .add(egui::TextEdit::singleline(&mut search_text).desired_width(f32::INFINITY)); - search_changed = response.changed(); - }); - - // Defer the set_channel_search call to avoid borrow issues - if search_changed { - self.set_channel_search(search_text.clone()); - } - - ui.add_space(5.0); - - // Channel count - ui.label( - egui::RichText::new(format!( - "Selected: {} / {} | Total: {}", - selected_count, MAX_CHANNELS, channel_count - )) - .size(font_14), - ); - - ui.separator(); - - // Channel list - use all remaining vertical space - let search_lower = search_text.to_lowercase(); - let mut channel_to_add: Option<(usize, usize)> = None; - let mut channel_to_remove: Option = None; - - // Sort channels: normalized fields first, then alphabetically - // Collect channel names upfront to avoid borrow issues - let file = &self.files[file_index]; - let sorted_channels = sort_channels_by_priority( - file.log.channels.len(), - |idx| file.log.channels[idx].name(), - self.field_normalization, - Some(&self.custom_normalizations), - ); - - // Get original names for all channels (needed for search) - let channel_names: Vec = (0..file.log.channels.len()) - .map(|idx| file.log.channels[idx].name()) - .collect(); - - // Use cached channel data flags from the loaded file - let channels_with_data = &file.channels_with_data; - - // Split channels into two groups: with data and without data - let (channels_with, channels_without): (Vec<_>, Vec<_>) = sorted_channels - .into_iter() - .partition(|(idx, _, _)| channels_with_data[*idx]); - - // Get selected channels for comparison - let selected_channels = self.get_selected_channels().to_vec(); - - // Count how many channels match search in each group - let count_matching = |channels: &[(usize, String, bool)]| -> usize { - if search_lower.is_empty() { - channels.len() - } else { - channels - .iter() - .filter(|(idx, display_name, _)| { - let original_name = &channel_names[*idx]; - original_name.to_lowercase().contains(&search_lower) - || display_name.to_lowercase().contains(&search_lower) - }) - .count() - } - }; - - let with_data_count = count_matching(&channels_with); - let without_data_count = count_matching(&channels_without); - - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .show(ui, |ui| { - ui.set_width(ui.available_width()); - - // Helper closure to render a channel item - let render_channel = - |ui: &mut egui::Ui, - channel_index: usize, - display_name: &str, - channel_to_add: &mut Option<(usize, usize)>, - channel_to_remove: &mut Option| { - let original_name = &channel_names[channel_index]; - - // Filter by search (search both original and normalized names) - if !search_lower.is_empty() - && !original_name.to_lowercase().contains(&search_lower) - && !display_name.to_lowercase().contains(&search_lower) - { - return; - } - - // Check if already selected and get its index in selected_channels - let selected_idx = selected_channels.iter().position(|c| { - c.file_index == file_index && c.channel_index == channel_index - }); - let is_selected = selected_idx.is_some(); - - // Build the label with checkmark prefix if selected - let label_text = if is_selected { - format!("[*] {}", display_name) - } else { - format!("[ ] {}", display_name) - }; - - let response = ui.selectable_label(is_selected, label_text); - - if response.clicked() { - if let Some(idx) = selected_idx { - // Already selected - remove it - *channel_to_remove = Some(idx); - } else { - // Not selected - add it - *channel_to_add = Some((file_index, channel_index)); - } - } - if response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - }; - - // Section 1: Channels with Data - if with_data_count > 0 || search_lower.is_empty() { - let header = egui::CollapsingHeader::new( - egui::RichText::new(format!( - "📊 Channels with Data ({})", - channels_with.len() - )) - .strong() - .size(font_16), - ) - .default_open(true) - .show(ui, |ui| { - for (channel_index, display_name, _is_normalized) in &channels_with { - render_channel( - ui, - *channel_index, - display_name, - &mut channel_to_add, - &mut channel_to_remove, - ); - } - }); - - // Show count of filtered results when searching - if !search_lower.is_empty() && header.body_returned.is_some() { - // Already showing filtered results inside - } - } - - ui.add_space(5.0); - - // Section 2: Channels without Data - if without_data_count > 0 || search_lower.is_empty() { - egui::CollapsingHeader::new( - egui::RichText::new(format!( - "📭 Empty Channels ({})", - channels_without.len() - )) - .color(egui::Color32::GRAY) - .size(font_16), - ) - .default_open(false) // Collapsed by default - .show(ui, |ui| { - for (channel_index, display_name, _is_normalized) in &channels_without { - render_channel( - ui, - *channel_index, - display_name, - &mut channel_to_add, - &mut channel_to_remove, - ); - } - }); - } - }); - - // Handle deferred channel removal (must happen before addition to keep indices valid) - if let Some(idx) = channel_to_remove { - self.remove_channel(idx); - } - - // Handle deferred channel addition - if let Some((file_idx, channel_idx)) = channel_to_add { - self.add_channel(file_idx, channel_idx); - } - } else { - ui.centered_and_justified(|ui| { - ui.label( - egui::RichText::new("Select a file to view channels") - .italics() - .color(egui::Color32::GRAY) - .size(font_16), - ); - }); - } - } - /// Render selected channel cards pub fn render_selected_channels(&mut self, ui: &mut egui::Ui) { - // Pre-compute scaled font sizes - let font_12 = self.scaled_font(12.0); - let font_14 = self.scaled_font(14.0); - let font_16 = self.scaled_font(16.0); - let font_18 = self.scaled_font(18.0); - + let t = self.tokens; ui.label( egui::RichText::new("Selected Channels") - .heading() - .size(font_18), + .text_style(fonts::text_style(style::TITLE)) + .color(t.ink), ); ui.separator(); @@ -274,6 +33,8 @@ impl UltraLogApp { // Pre-compute all display data to avoid borrow conflicts in closure struct ChannelCardData { color: egui::Color32, + stroke: SeriesStroke, + unit: String, display_name: String, is_computed: bool, min_str: Option, @@ -282,13 +43,29 @@ impl UltraLogApp { max_record: Option, min_time: Option, max_time: Option, + file_index: usize, + /// "A" / "B" while the tab compares two logs. + tag: Option<&'static str>, + /// Log B's file name, shown under the name on B cards. + source: Option, + /// A log-A card with no partner while comparing ("not in B"). + not_in_b: bool, + has_partner: bool, } + let tab = self.active_tab.map(|idx| &self.tabs[idx]); + let mut channel_cards: Vec = Vec::with_capacity(selected_channels.len()); - for selected in &selected_channels { - let color = self.get_channel_color(selected.color_index); - let color32 = egui::Color32::from_rgb(color[0], color[1], color[2]); + for (idx, selected) in selected_channels.iter().enumerate() { + let (color32, series_stroke) = self.channel_style(selected); + let tag = self.log_tag(selected); + let has_partner = tab.is_some_and(|t| t.partner(idx).is_some()); + let source = (tag == Some("B")) + .then(|| self.files.get(selected.file_index).map(|f| f.name.clone())) + .flatten(); + let not_in_b = tag == Some("A") && !has_partner && !selected.channel.is_computed(); + let mut card_unit = String::new(); // Get display name let channel_name = selected.channel.name(); @@ -351,15 +128,11 @@ impl UltraLogApp { self.unit_preferences.convert_value(min_val, source_unit); let (conv_max, _) = self.unit_preferences.convert_value(max_val, source_unit); - let unit_str = if display_unit.is_empty() { - String::new() - } else { - format!(" {}", display_unit) - }; + card_unit = display_unit.to_string(); ( - Some(format!("{:.1}{}", conv_min, unit_str)), - Some(format!("{:.1}{}", conv_max, unit_str)), + Some(format!("{conv_min:.1}")), + Some(format!("{conv_max:.1}")), Some(min_idx), Some(max_idx), times.get(min_idx).copied(), @@ -375,6 +148,8 @@ impl UltraLogApp { channel_cards.push(ChannelCardData { color: color32, + stroke: series_stroke, + unit: card_unit, display_name, is_computed: selected.channel.is_computed(), min_str, @@ -383,137 +158,173 @@ impl UltraLogApp { max_record, min_time, max_time, + file_index: selected.file_index, + tag, + source, + not_in_b, + has_partner, }); } let mut channel_to_remove: Option = None; - let mut jump_to: Option<(usize, f64)> = None; // (record, time) + let mut pair_to_remove: Option = None; + // (file, record in that file, time in that file's own time) + let mut jump_to: Option<(usize, usize, f64)> = None; egui::ScrollArea::horizontal().show(ui, |ui| { - ui.horizontal(|ui| { + ui.horizontal_top(|ui| { for (i, card) in channel_cards.iter().enumerate() { - egui::Frame::NONE - .fill(egui::Color32::from_rgb(40, 40, 40)) - .stroke(egui::Stroke::new(2.0, card.color)) - .corner_radius(5) - .inner_margin(10.0) + let frame = egui::Frame::NONE + .fill(t.bg_raised) + .stroke(egui::Stroke::new(stroke::STROKE_HAIRLINE, t.line)) + .corner_radius(egui::CornerRadius::same(radius::RADIUS_MD)) + .inner_margin(egui::Margin { + left: 12, + right: 6, + top: 10, + bottom: 8, + }) .show(ui, |ui| { - // Use horizontal layout with content on left, close button on right - // Align to top so cards with different heights don't stair-step - ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { - // Main content column - ui.vertical(|ui| { - ui.horizontal(|ui| { - // Show computed channel indicator - if card.is_computed { - ui.label( - egui::RichText::new("ƒ") - .color(egui::Color32::from_rgb(150, 200, 255)) - .strong(), - ) - .on_hover_text("Computed channel (formula-based)"); - } + ui.vertical(|ui| { + ui.horizontal(|ui| { + // Series swatch: the line glyph, dashed when the line is + let (sw, _) = ui.allocate_exact_size( + egui::vec2(14.0, 14.0), + egui::Sense::hover(), + ); + plot_style::swatch( + ui.painter(), + sw.left_center(), + sw.width(), + card.color, + card.stroke, + ); + if card.is_computed { ui.label( - egui::RichText::new(&card.display_name) - .strong() - .color(card.color) - .size(font_14), - ); - }); - - // Show min with jump button - if let Some(min_str) = &card.min_str { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new("Min:") - .color(egui::Color32::GRAY) - .size(font_12), - ); - ui.label( - egui::RichText::new(min_str) - .color(egui::Color32::LIGHT_GRAY) - .size(font_14), - ); - if let (Some(record), Some(time)) = - (card.min_record, card.min_time) - { - let btn = ui - .small_button("⏵") - .on_hover_text("Jump to minimum"); - if btn.clicked() { - jump_to = Some((record, time)); - } - if btn.hovered() { - ui.ctx().set_cursor_icon( - egui::CursorIcon::PointingHand, - ); - } - } - }); - } - - // Show max with jump button - if let Some(max_str) = &card.max_str { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new("Max:") - .color(egui::Color32::GRAY) - .size(font_12), - ); - ui.label( - egui::RichText::new(max_str) - .color(egui::Color32::LIGHT_GRAY) - .size(font_14), - ); - if let (Some(record), Some(time)) = - (card.max_record, card.max_time) - { - let btn = ui - .small_button("⏵") - .on_hover_text("Jump to maximum"); - if btn.clicked() { - jump_to = Some((record, time)); - } - if btn.hovered() { - ui.ctx().set_cursor_icon( - egui::CursorIcon::PointingHand, - ); - } - } - }); + egui::RichText::new("\u{0192}") + .family(egui::FontFamily::Name( + fonts::DISPLAY_BOLD_ITALIC.into(), + )) + .color(t.magenta), + ) + .on_hover_text("Computed channel (formula-based)"); } - }); - - // Close button in top right - ui.add_space(8.0); - ui.vertical(|ui| { - let close_btn = ui.add( - egui::Button::new( - egui::RichText::new("x") - .size(font_12) - .color(egui::Color32::from_rgb(150, 150, 150)), + if let Some(tag) = card.tag { + ui.label( + egui::RichText::new(tag) + .text_style(fonts::text_style(style::DATA)) + .color(t.ink_muted), ) - .fill(egui::Color32::TRANSPARENT) - .stroke(egui::Stroke::NONE) - .corner_radius(2.0), + .on_hover_text( + card.source.as_deref().unwrap_or(&card.display_name), + ); + } + ui.label( + egui::RichText::new(&card.display_name) + .text_style(egui::TextStyle::Heading) + .color(t.ink), ); - if close_btn.clicked() { - channel_to_remove = Some(i); + ui.add_space(space::SPACE_4); + if card.has_partner + && components::icon_button( + ui, + icon::TRASH, + t!("compare.remove_pair"), + ) + .clicked() + { + pair_to_remove = Some(i); } - if close_btn.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + if components::icon_button(ui, icon::X, "Remove channel") + .clicked() + { + channel_to_remove = Some(i); } }); + if let Some(source) = &card.source { + ui.label( + egui::RichText::new(source) + .text_style(fonts::text_style(style::DATA_SM)) + .color(t.ink_subtle), + ); + } else if card.not_in_b { + ui.label( + egui::RichText::new(t!("compare.not_in_b")) + .text_style(fonts::text_style(style::DATA_SM)) + .color(t.ink_subtle), + ); + } + + for (label, value, record, time, hint) in [ + ( + "Min", + &card.min_str, + card.min_record, + card.min_time, + "Jump to minimum", + ), + ( + "Max", + &card.max_str, + card.max_record, + card.max_time, + "Jump to maximum", + ), + ] { + let Some(value) = value else { continue }; + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(label) + .text_style(fonts::text_style(style::LABEL)) + .color(t.ink_subtle), + ); + ui.label( + egui::RichText::new(value) + .text_style(egui::TextStyle::Monospace) + .color(t.ink), + ); + if !card.unit.is_empty() { + ui.label( + egui::RichText::new(&card.unit) + .text_style(fonts::text_style(style::DATA_SM)) + .color(t.ink_subtle), + ); + } + if let (Some(record), Some(time)) = (record, time) + && components::icon_button(ui, icon::CARET_RIGHT, hint) + .clicked() + { + jump_to = Some((card.file_index, record, time)); + } + }); + } }); }); - ui.add_space(5.0); + // 2px series strip along the top edge + let r = frame.response.rect; + ui.painter().rect_filled( + egui::Rect::from_min_size( + r.left_top(), + egui::vec2(r.width(), stroke::STROKE_INDICATOR), + ), + egui::CornerRadius { + nw: radius::RADIUS_MD, + ne: radius::RADIUS_MD, + sw: 0, + se: 0, + }, + card.color, + ); + + ui.add_space(space::SPACE_1); } }); }); // Handle jump to min/max - if let Some((record, time)) = jump_to { + if let Some((file_index, record, time)) = jump_to { + let (time, record) = self.cursor_for_file_sample(file_index, record, time); self.set_cursor_time(Some(time)); self.set_cursor_record(Some(record)); // Request the chart to center on this time @@ -523,7 +334,9 @@ impl UltraLogApp { self.last_frame_time = None; } - if let Some(index) = channel_to_remove { + if let Some(index) = pair_to_remove { + self.remove_pair(index); + } else if let Some(index) = channel_to_remove { self.remove_channel(index); } @@ -531,8 +344,7 @@ impl UltraLogApp { ui.label( egui::RichText::new("Click channels to add them to the chart") .italics() - .color(egui::Color32::GRAY) - .size(font_16), + .color(t.ink_subtle), ); } } diff --git a/src/ui/chart.rs b/src/ui/chart.rs index 387d4c05..af0e379c 100644 --- a/src/ui/chart.rs +++ b/src/ui/chart.rs @@ -1,15 +1,18 @@ //! Chart rendering and data processing utilities. use eframe::egui; -use egui_plot::{Line, Plot, PlotBounds, PlotPoints, VLine}; +use egui_plot::{Plot, PlotBounds}; use rust_i18n::t; use crate::app::UltraLogApp; use crate::normalize::normalize_channel_name_with_custom; use crate::state::{ - CHART_COLORS, COLORBLIND_COLORS, DownsampleViewKey, MAX_CHART_POINTS, MIN_PLOT_HEIGHT, - PLOT_RESIZE_HANDLE_HEIGHT, PlotArea, SelectedChannel, + DownsampleViewKey, MAX_CHART_POINTS, MIN_PLOT_HEIGHT, PLOT_RESIZE_HANDLE_HEIGHT, PlotArea, + SelectedChannel, }; +use crate::ui::components::{self, icon}; +use crate::ui::fonts::{self, style}; +use crate::ui::plot_style::{self, PlotStyle, SeriesRow}; /// Sensitivity multiplier for scroll-to-zoom (higher = faster zoom per scroll tick). /// Shared between the cursor-tracking pre-show helper and the post-show closure. @@ -18,6 +21,15 @@ const SCROLL_ZOOM_SENSITIVITY: f64 = 0.003; impl UltraLogApp { /// Render the main chart with cached downsampled data pub fn render_chart(&mut self, ui: &mut egui::Ui) { + // "Align at cursor" handles its own Esc: the global shortcuts skip a frame where a + // widget (the Align button) still has focus, and their Esc stops playback. + if self.picking_alignment() + && ui.input(|i| i.key_pressed(egui::Key::Escape)) + && let Some(tab_idx) = self.active_tab + { + self.cancel_alignment(tab_idx); + } + // Check if stacked mode is enabled let stacked_mode = self .active_tab @@ -83,8 +95,8 @@ impl UltraLogApp { ui.centered_and_justified(|ui| { ui.label( egui::RichText::new(t!("chart.select_channels")) - .size(self.scaled_font(20.0)) - .color(egui::Color32::GRAY), + .heading() + .color(self.tokens.ink_subtle), ); }); return; @@ -96,66 +108,30 @@ impl UltraLogApp { let viewport = self.chart_last_x_bounds.get(&0).copied(); let chart_points: Vec>> = selected_channels .iter() - .map(|selected| { - self.compute_viewport_points( - selected.file_index, - selected.channel_index, - 0, - viewport, - ) - }) + .map(|selected| self.channel_plot_points(selected, 0, viewport)) .collect(); - // Pre-compute legend names with current values at cursor position - let use_normalization = self.field_normalization; - let custom_mappings = &self.custom_normalizations; - let legend_names: Vec = selected_channels - .iter() - .map(|selected| { - let original_name = selected.channel.name(); - let base_name = if use_normalization { - normalize_channel_name_with_custom(&original_name, Some(custom_mappings)) - } else { - original_name - }; - if let Some(record) = self.get_cursor_record() { - if let Some(value) = self.get_value_at_record( - selected.file_index, - selected.channel_index, - record, - ) { - let source_unit = selected.channel.unit(); - let (converted_value, display_unit) = - self.unit_preferences.convert_value(value, source_unit); - if display_unit.is_empty() { - format!("{}: {:.2}", base_name, converted_value) - } else { - format!("{}: {:.2} {}", base_name, converted_value, display_unit) - } - } else { - base_name - } - } else { - base_name - } - }) - .collect(); + let rows = self.series_rows(&selected_channels); // Prepare data for the plot closure (can't borrow self mutably inside) let chart_points = &chart_points; - let files = &self.files; - // selected_channels already defined at top of function from get_selected_channels() let cursor_time = self.get_cursor_time(); let cursor_tracking = self.cursor_tracking; let view_window = self.current_view_window; let time_range = self.get_time_range(); - let color_blind_mode = self.color_blind_mode; let chart_interacted = self.get_chart_interacted(); let initial_view_seconds = self.initial_view_seconds; let jump_to_time = self.get_jump_to_time(); let scroll_to_zoom = self.scroll_to_zoom; let show_grid = self.show_grid; - let grid_color = grid_color_with_opacity(ui, self.grid_opacity); + let grid_color = grid_color_with_opacity(self.tokens.plot_grid_major, self.grid_opacity); + let plot_id = egui::Id::new("log_chart"); + let tokens = self.tokens; + let style = PlotStyle { + tokens: &tokens, + cvd_like: self.color_blind_mode || self.high_contrast, + solo: plot_style::solo_memory(ui.ctx(), plot_id, None), + }; // Read scroll input before plot consumes it (for scroll-to-zoom mode) let scroll_delta_y = if scroll_to_zoom && !cursor_tracking { @@ -171,7 +147,6 @@ impl UltraLogApp { // Build the plot - X-axis zoom only, Y fixed // When scroll_to_zoom is enabled, disable scroll-to-pan so we handle scroll as zoom let plot = Plot::new("log_chart") - .legend(egui_plot::Legend::default()) .y_axis_label("") // Hide Y axis label since values are normalized .show_axes([true, false]) // Show X axis (time), hide Y axis (normalized 0-1) .show_grid([show_grid, show_grid]) @@ -180,140 +155,109 @@ impl UltraLogApp { .allow_drag([!cursor_tracking, false]) // Only allow X-axis drag, never Y .allow_scroll([!cursor_tracking && !scroll_to_zoom, false]); // Disable scroll-pan when scroll-to-zoom enabled - let response = plot.show(ui, |plot_ui| { - // Get current bounds - let current_bounds = plot_ui.plot_bounds(); - let mut x_min = current_bounds.min()[0]; - let mut x_max = current_bounds.max()[0]; - - // Handle jump-to-time request (from min/max jump buttons) - if let (Some(jump_time), Some((min_t, max_t))) = (jump_to_time, time_range) { - // Center the view on the jump target time - let current_width = (x_max - x_min).max(view_window); - let half_width = current_width / 2.0; - x_min = (jump_time - half_width).max(min_t); - x_max = (jump_time + half_width).min(max_t); - // Adjust if we hit a boundary - if x_max - x_min < current_width { - if x_min == min_t { - x_max = (min_t + current_width).min(max_t); - } else { - x_min = (max_t - current_width).max(min_t); + let response = with_plot_bg(ui, tokens.plot_bg, |ui| { + plot.show(ui, |plot_ui| { + // Get current bounds + let current_bounds = plot_ui.plot_bounds(); + let mut x_min = current_bounds.min()[0]; + let mut x_max = current_bounds.max()[0]; + + // Handle jump-to-time request (from min/max jump buttons) + if let (Some(jump_time), Some((min_t, max_t))) = (jump_to_time, time_range) { + // Center the view on the jump target time + let current_width = (x_max - x_min).max(view_window); + let half_width = current_width / 2.0; + x_min = (jump_time - half_width).max(min_t); + x_max = (jump_time + half_width).min(max_t); + // Adjust if we hit a boundary + if x_max - x_min < current_width { + if x_min == min_t { + x_max = (min_t + current_width).min(max_t); + } else { + x_min = (max_t - current_width).max(min_t); + } } - } - } else if cursor_tracking { - // In cursor tracking mode, center on cursor - if let (Some(cursor), Some((min_t, max_t))) = (cursor_time, time_range) { - let half_window = view_window / 2.0; - x_min = (cursor - half_window).max(min_t); - x_max = (cursor + half_window).min(max_t); - } - } else if let Some((min_t, max_t)) = time_range { - let data_width = max_t - min_t; - - // If chart hasn't been interacted with yet, use initial zoomed view - if !chart_interacted && data_width > initial_view_seconds { - // Show initial view window starting from the beginning - x_min = min_t; - x_max = min_t + initial_view_seconds; - } else { - // Clamp X bounds to data range - prevent zooming out beyond data - let current_width = x_max - x_min; + } else if cursor_tracking { + // In cursor tracking mode, center on cursor + if let (Some(cursor), Some((min_t, max_t))) = (cursor_time, time_range) { + let half_window = view_window / 2.0; + x_min = (cursor - half_window).max(min_t); + x_max = (cursor + half_window).min(max_t); + } + } else if let Some((min_t, max_t)) = time_range { + let data_width = max_t - min_t; - // Don't allow view wider than data range - if current_width > data_width { + // If chart hasn't been interacted with yet, use initial zoomed view + if !chart_interacted && data_width > initial_view_seconds { + // Show initial view window starting from the beginning x_min = min_t; - x_max = max_t; + x_max = min_t + initial_view_seconds; } else { - // Keep view within data bounds - if x_min < min_t { + // Clamp X bounds to data range - prevent zooming out beyond data + let current_width = x_max - x_min; + + // Don't allow view wider than data range + if current_width > data_width { x_min = min_t; - x_max = min_t + current_width; - } - if x_max > max_t { x_max = max_t; - x_min = max_t - current_width; + } else { + // Keep view within data bounds + if x_min < min_t { + x_min = min_t; + x_max = min_t + current_width; + } + if x_max > max_t { + x_max = max_t; + x_min = max_t - current_width; + } } } } - } - - // Apply scroll-to-zoom: use scroll delta to zoom centered on pointer - if scroll_to_zoom - && scroll_delta_y.abs() > 0.1 - && let Some((min_t, max_t)) = time_range - { - let zoom_factor = - (1.0 - scroll_delta_y as f64 * SCROLL_ZOOM_SENSITIVITY).clamp(0.8, 1.25); - let width = x_max - x_min; - let new_width = (width * zoom_factor).clamp(0.01, max_t - min_t); - - // Zoom around pointer position if hovering, otherwise center - let center = plot_ui - .pointer_coordinate() - .map(|p| p.x.clamp(x_min, x_max)) - .unwrap_or((x_min + x_max) / 2.0); - let ratio = if width > 0.0 { - (center - x_min) / width - } else { - 0.5 - }; - - x_min = center - new_width * ratio; - x_max = center + new_width * (1.0 - ratio); - - // Clamp to data range - if x_min < min_t { - x_min = min_t; - x_max = (min_t + new_width).min(max_t); - } - if x_max > max_t { - x_max = max_t; - x_min = (max_t - new_width).max(min_t); - } - } - - // Always enforce bounds: X clamped to data, Y fixed to normalized range - let new_bounds = PlotBounds::from_min_max([x_min, Y_MIN], [x_max, Y_MAX]); - plot_ui.set_plot_bounds(new_bounds); - - // Draw channel data lines with values in legend - for (i, selected) in selected_channels.iter().enumerate() { - if selected.file_index >= files.len() { - continue; - } - if let Some(points) = chart_points.get(i).and_then(|p| p.as_ref()) { - let plot_points: PlotPoints = points.iter().copied().collect(); - let palette = if color_blind_mode { - COLORBLIND_COLORS + // Apply scroll-to-zoom: use scroll delta to zoom centered on pointer + if scroll_to_zoom + && scroll_delta_y.abs() > 0.1 + && let Some((min_t, max_t)) = time_range + { + let zoom_factor = + (1.0 - scroll_delta_y as f64 * SCROLL_ZOOM_SENSITIVITY).clamp(0.8, 1.25); + let width = x_max - x_min; + let new_width = (width * zoom_factor).clamp(0.01, max_t - min_t); + + // Zoom around pointer position if hovering, otherwise center + let center = plot_ui + .pointer_coordinate() + .map(|p| p.x.clamp(x_min, x_max)) + .unwrap_or((x_min + x_max) / 2.0); + let ratio = if width > 0.0 { + (center - x_min) / width } else { - CHART_COLORS + 0.5 }; - let color = palette[selected.color_index % palette.len()]; - // Use legend name with value if available - let name = &legend_names[i]; + x_min = center - new_width * ratio; + x_max = center + new_width * (1.0 - ratio); - plot_ui.line( - Line::new(name.clone(), plot_points) - .color(egui::Color32::from_rgb(color[0], color[1], color[2])) - .width(1.5), - ); + // Clamp to data range + if x_min < min_t { + x_min = min_t; + x_max = (min_t + new_width).min(max_t); + } + if x_max > max_t { + x_max = max_t; + x_min = (max_t - new_width).max(min_t); + } } - } - // Draw vertical cursor line - if let Some(time) = cursor_time { - plot_ui.vline( - VLine::new("Cursor", time) - .color(egui::Color32::from_rgb(0, 255, 255)) // Cyan cursor - .width(2.0), - ); - } + // Always enforce bounds: X clamped to data, Y fixed to normalized range + let new_bounds = PlotBounds::from_min_max([x_min, Y_MIN], [x_max, Y_MAX]); + plot_ui.set_plot_bounds(new_bounds); + + plot_style::draw_series(plot_ui, &style, &rows, chart_points, cursor_time); - // Return pointer position if hovering for click detection - plot_ui.pointer_coordinate() + // Return pointer position if hovering for click detection + plot_ui.pointer_coordinate() + }) }); // Remember the X-axis bounds we just rendered so the next frame can @@ -321,6 +265,15 @@ impl UltraLogApp { let final_bounds = response.transform.bounds(); self.chart_last_x_bounds .insert(0, (final_bounds.min()[0], final_bounds.max()[0])); + self.paint_plot_overlays( + ui, + &response, + &style, + &rows, + chart_points, + cursor_time, + plot_id, + ); // Detect user interaction with chart (drag, zoom, scroll) // This marks the chart as "interacted" so we stop using the initial zoomed view @@ -340,24 +293,47 @@ impl UltraLogApp { self.set_chart_interacted(true); } - // Handle click on chart to set cursor position - if response.response.clicked() - && let Some(pos) = response.inner - { - let clicked_time = pos.x; - // Clamp to time range - if let Some((min, max)) = self.get_time_range() { - // Stop playback when user clicks on chart - self.is_playing = false; - self.last_frame_time = None; - - let clamped_time = clicked_time.clamp(min, max); - self.set_cursor_time(Some(clamped_time)); - let record = self.find_record_at_time(clamped_time); - self.set_cursor_record(record); - // Force repaint to update legend values immediately - ui.ctx().request_repaint(); + self.handle_chart_click(ui, &response.response, response.inner); + } + + /// A click on a plot (single or stacked mode): finishes "Align at cursor" while it waits, + /// otherwise sets the cursor. Shows a crosshair over the plot while aligning. + fn handle_chart_click( + &mut self, + ui: &egui::Ui, + response: &egui::Response, + pointer: Option, + ) { + let picking = self.picking_alignment(); + if picking && response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if !response.clicked() { + return; + } + let Some(pos) = pointer else { + return; + }; + let clicked_time = pos.x; + if picking { + if let Some(tab_idx) = self.active_tab { + self.finish_alignment(tab_idx, clicked_time); } + ui.ctx().request_repaint(); + return; + } + // Clamp to time range + if let Some((min, max)) = self.get_time_range() { + // Stop playback when user clicks on chart + self.is_playing = false; + self.last_frame_time = None; + + let clamped_time = clicked_time.clamp(min, max); + self.set_cursor_time(Some(clamped_time)); + let record = self.find_record_at_time(clamped_time); + self.set_cursor_record(record); + // Force repaint to update legend values immediately + ui.ctx().request_repaint(); } } @@ -369,8 +345,8 @@ impl UltraLogApp { ui.centered_and_justified(|ui| { ui.label( egui::RichText::new("No active tab") - .size(self.scaled_font(20.0)) - .color(egui::Color32::GRAY), + .heading() + .color(self.tokens.ink_subtle), ); }); return; @@ -383,8 +359,8 @@ impl UltraLogApp { ui.centered_and_justified(|ui| { ui.label( egui::RichText::new(t!("chart.select_channels")) - .size(self.scaled_font(20.0)) - .color(egui::Color32::GRAY), + .heading() + .color(self.tokens.ink_subtle), ); }); return; @@ -429,6 +405,8 @@ impl UltraLogApp { .filter_map(|&idx| self.tabs[tab_idx].selected_channels.get(idx).cloned()) .collect(); + let plot_has_capacity = self.tabs[tab_idx].plot_has_capacity(plot_area.id); + if plot_channels.is_empty() { // Empty plot area with drop zone let (rect, response) = ui.allocate_exact_size( @@ -438,7 +416,7 @@ impl UltraLogApp { // Check for dropped channel if let Some(payload) = response.dnd_release_payload::<(usize, usize)>() - && plot_area.has_capacity() + && plot_has_capacity { let (dropped_file_idx, dropped_channel_idx) = *payload; self.add_channel_to_plot( @@ -451,10 +429,10 @@ impl UltraLogApp { // Highlight if hovering with drag payload let is_drop_target = response.dnd_hover_payload::<(usize, usize)>().is_some(); - let stroke_color = if is_drop_target && plot_area.has_capacity() { - egui::Color32::from_rgb(71, 108, 155) + let stroke_color = if is_drop_target && plot_has_capacity { + self.tokens.cyan } else { - egui::Color32::from_gray(100) + self.tokens.line_control }; let stroke_width = if is_drop_target { 2.0 } else { 1.0 }; @@ -467,18 +445,16 @@ impl UltraLogApp { ui.put( rect, egui::Label::new( - egui::RichText::new( - if is_drop_target && plot_area.has_capacity() { - "Drop channel here" - } else { - "No channels in this plot" - }, - ) + egui::RichText::new(if is_drop_target && plot_has_capacity { + "Drop channel here" + } else { + "No channels in this plot" + }) .italics() .color(if is_drop_target { - egui::Color32::WHITE + self.tokens.ink } else { - egui::Color32::GRAY + self.tokens.ink_subtle }), ), ); @@ -518,59 +494,17 @@ impl UltraLogApp { let viewport = self.chart_last_x_bounds.get(&plot_area_id).copied(); let chart_points: Vec>> = channels .iter() - .map(|selected| { - self.compute_viewport_points( - selected.file_index, - selected.channel_index, - plot_area_id, - viewport, - ) - }) + .map(|selected| self.channel_plot_points(selected, plot_area_id, viewport)) .collect(); - // Build legend names with values - let use_normalization = self.field_normalization; - let custom_mappings = &self.custom_normalizations; - let legend_names: Vec = channels - .iter() - .map(|selected| { - let original_name = selected.channel.name(); - let base_name = if use_normalization { - normalize_channel_name_with_custom(&original_name, Some(custom_mappings)) - } else { - original_name - }; - if let Some(record) = self.get_cursor_record() { - if let Some(value) = self.get_value_at_record( - selected.file_index, - selected.channel_index, - record, - ) { - let source_unit = selected.channel.unit(); - let (converted_value, display_unit) = - self.unit_preferences.convert_value(value, source_unit); - if display_unit.is_empty() { - format!("{}: {:.2}", base_name, converted_value) - } else { - format!("{}: {:.2} {}", base_name, converted_value, display_unit) - } - } else { - base_name - } - } else { - base_name - } - }) - .collect(); + let rows = self.series_rows(channels); // Prepare data for plot let chart_points = &chart_points; - let files = &self.files; let cursor_time = self.get_cursor_time(); let cursor_tracking = self.cursor_tracking; let view_window = self.current_view_window; let time_range = self.get_time_range(); - let color_blind_mode = self.color_blind_mode; let chart_interacted = self.get_chart_interacted(); let initial_view_seconds = self.initial_view_seconds; let jump_to_time = self.get_jump_to_time(); @@ -580,12 +514,18 @@ impl UltraLogApp { const Y_MAX: f64 = 1.05; let show_grid = self.show_grid; - let grid_color = grid_color_with_opacity(ui, self.grid_opacity); + let grid_color = grid_color_with_opacity(self.tokens.plot_grid_major, self.grid_opacity); + let plot_id = egui::Id::new(("plot_area", plot_area_id)); + let tokens = self.tokens; + let style = PlotStyle { + tokens: &tokens, + cvd_like: self.color_blind_mode || self.high_contrast, + solo: plot_style::solo_memory(ui.ctx(), plot_id, None), + }; // Build plot with fixed height let plot = Plot::new(format!("plot_{}", plot_area_id)) .height(height) - .legend(egui_plot::Legend::default()) .y_axis_label("") .show_axes([true, false]) .show_grid([show_grid, show_grid]) @@ -594,94 +534,65 @@ impl UltraLogApp { .allow_drag([!cursor_tracking, false]) .allow_scroll([!cursor_tracking, false]); - let response = plot.show(ui, |plot_ui| { - // Get current bounds - let current_bounds = plot_ui.plot_bounds(); - let mut x_min = current_bounds.min()[0]; - let mut x_max = current_bounds.max()[0]; - - // Handle jump-to-time request - if let (Some(jump_time), Some((min_t, max_t))) = (jump_to_time, time_range) { - let current_width = (x_max - x_min).max(view_window); - let half_width = current_width / 2.0; - x_min = (jump_time - half_width).max(min_t); - x_max = (jump_time + half_width).min(max_t); - if x_max - x_min < current_width { - if x_min == min_t { - x_max = (min_t + current_width).min(max_t); - } else { - x_min = (max_t - current_width).max(min_t); + let response = with_plot_bg(ui, tokens.plot_bg, |ui| { + plot.show(ui, |plot_ui| { + // Get current bounds + let current_bounds = plot_ui.plot_bounds(); + let mut x_min = current_bounds.min()[0]; + let mut x_max = current_bounds.max()[0]; + + // Handle jump-to-time request + if let (Some(jump_time), Some((min_t, max_t))) = (jump_to_time, time_range) { + let current_width = (x_max - x_min).max(view_window); + let half_width = current_width / 2.0; + x_min = (jump_time - half_width).max(min_t); + x_max = (jump_time + half_width).min(max_t); + if x_max - x_min < current_width { + if x_min == min_t { + x_max = (min_t + current_width).min(max_t); + } else { + x_min = (max_t - current_width).max(min_t); + } } - } - } else if cursor_tracking { - if let (Some(cursor), Some((min_t, max_t))) = (cursor_time, time_range) { - let half_window = view_window / 2.0; - x_min = (cursor - half_window).max(min_t); - x_max = (cursor + half_window).min(max_t); - } - } else if let Some((min_t, max_t)) = time_range { - let data_width = max_t - min_t; - - if !chart_interacted && data_width > initial_view_seconds { - x_min = min_t; - x_max = min_t + initial_view_seconds; - } else { - let current_width = x_max - x_min; + } else if cursor_tracking { + if let (Some(cursor), Some((min_t, max_t))) = (cursor_time, time_range) { + let half_window = view_window / 2.0; + x_min = (cursor - half_window).max(min_t); + x_max = (cursor + half_window).min(max_t); + } + } else if let Some((min_t, max_t)) = time_range { + let data_width = max_t - min_t; - if current_width > data_width { + if !chart_interacted && data_width > initial_view_seconds { x_min = min_t; - x_max = max_t; + x_max = min_t + initial_view_seconds; } else { - if x_min < min_t { + let current_width = x_max - x_min; + + if current_width > data_width { x_min = min_t; - x_max = min_t + current_width; - } - if x_max > max_t { x_max = max_t; - x_min = max_t - current_width; + } else { + if x_min < min_t { + x_min = min_t; + x_max = min_t + current_width; + } + if x_max > max_t { + x_max = max_t; + x_min = max_t - current_width; + } } } } - } - - // Set bounds - let new_bounds = PlotBounds::from_min_max([x_min, Y_MIN], [x_max, Y_MAX]); - plot_ui.set_plot_bounds(new_bounds); - // Draw channel lines - for (i, selected) in channels.iter().enumerate() { - if selected.file_index >= files.len() { - continue; - } - - if let Some(points) = chart_points.get(i).and_then(|p| p.as_ref()) { - let plot_points: PlotPoints = points.iter().copied().collect(); - let palette = if color_blind_mode { - COLORBLIND_COLORS - } else { - CHART_COLORS - }; - let color = palette[selected.color_index % palette.len()]; - let name = &legend_names[i]; - - plot_ui.line( - Line::new(name.clone(), plot_points) - .color(egui::Color32::from_rgb(color[0], color[1], color[2])) - .width(1.5), - ); - } - } + // Set bounds + let new_bounds = PlotBounds::from_min_max([x_min, Y_MIN], [x_max, Y_MAX]); + plot_ui.set_plot_bounds(new_bounds); - // Draw cursor line - if let Some(time) = cursor_time { - plot_ui.vline( - VLine::new("Cursor", time) - .color(egui::Color32::from_rgb(0, 255, 255)) - .width(2.0), - ); - } + plot_style::draw_series(plot_ui, &style, &rows, chart_points, cursor_time); - plot_ui.pointer_coordinate() + plot_ui.pointer_coordinate() + }) }); // Save the bounds we just rendered so the next frame's downsample @@ -689,6 +600,15 @@ impl UltraLogApp { let final_bounds = response.transform.bounds(); self.chart_last_x_bounds .insert(plot_area_id, (final_bounds.min()[0], final_bounds.max()[0])); + self.paint_plot_overlays( + ui, + &response, + &style, + &rows, + chart_points, + cursor_time, + plot_id, + ); // Detect interaction if response.response.dragged() @@ -705,21 +625,7 @@ impl UltraLogApp { self.set_chart_interacted(true); } - // Handle click - if response.response.clicked() - && let Some(pos) = response.inner - { - let clicked_time = pos.x; - if let Some((min, max)) = self.get_time_range() { - self.is_playing = false; - self.last_frame_time = None; - let clamped_time = clicked_time.clamp(min, max); - self.set_cursor_time(Some(clamped_time)); - let record = self.find_record_at_time(clamped_time); - self.set_cursor_record(record); - ui.ctx().request_repaint(); - } - } + self.handle_chart_click(ui, &response.response, response.inner); // Handle dropped channel on the plot if let Some(payload) = response.response.dnd_release_payload::<(usize, usize)>() { @@ -736,7 +642,7 @@ impl UltraLogApp { ui.painter().rect_stroke( response.response.rect, egui::CornerRadius::same(4), - egui::Stroke::new(3.0, egui::Color32::from_rgb(71, 108, 155)), + egui::Stroke::new(2.0, self.tokens.cyan), egui::StrokeKind::Outside, ); } @@ -749,21 +655,31 @@ impl UltraLogApp { plot_area: &PlotArea, plot_idx: usize, ) { - let font_14 = self.scaled_font(14.0); - // Collapse/expand icon (custom drawn triangle) let (rect, response) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::click()); let center = rect.center(); let color = if response.hovered() { - egui::Color32::WHITE + self.tokens.ink } else { - egui::Color32::LIGHT_GRAY + self.tokens.ink_muted }; if plot_area.collapsed { - crate::ui::icons::draw_triangle_right(ui, center, 12.0, color); + ui.painter().text( + center, + egui::Align2::CENTER_CENTER, + icon::CARET_RIGHT, + egui::FontId::proportional(14.0), + color, + ); } else { - crate::ui::icons::draw_triangle_down(ui, center, 12.0, color); + ui.painter().text( + center, + egui::Align2::CENTER_CENTER, + icon::CARET_DOWN, + egui::FontId::proportional(14.0), + color, + ); } if response.clicked() @@ -776,7 +692,11 @@ impl UltraLogApp { } // Plot title - ui.label(egui::RichText::new(&plot_area.name).strong().size(font_14)); + ui.label( + egui::RichText::new(&plot_area.name) + .text_style(egui::TextStyle::Heading) + .color(self.tokens.ink), + ); // Channel count ui.label( @@ -785,13 +705,13 @@ impl UltraLogApp { plot_area.channel_count(), 10 // MAX_CHANNELS_PER_PLOT )) - .color(egui::Color32::GRAY) - .size(font_14), + .text_style(fonts::text_style(style::DATA_SM)) + .color(self.tokens.ink_subtle), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Delete button - if ui.button("🗑").on_hover_text("Delete plot area").clicked() { + if components::icon_button(ui, icon::TRASH, "Delete plot area").clicked() { self.delete_plot_area(plot_area.id); } }); @@ -810,12 +730,18 @@ impl UltraLogApp { ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::click()); let center = rect.center(); let color = if response.hovered() { - egui::Color32::WHITE + self.tokens.ink } else { - egui::Color32::GRAY + self.tokens.ink_muted }; - crate::ui::icons::draw_triangle_right(ui, center, 12.0, color); + ui.painter().text( + center, + egui::Align2::CENTER_CENTER, + icon::CARET_RIGHT, + egui::FontId::proportional(14.0), + color, + ); if response.clicked() && let Some(tab_idx) = self.active_tab @@ -825,7 +751,7 @@ impl UltraLogApp { if response.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } - ui.label(egui::RichText::new(&plot_area.name).color(egui::Color32::GRAY)); + ui.label(egui::RichText::new(&plot_area.name).color(self.tokens.ink_muted)); }); } @@ -838,9 +764,9 @@ impl UltraLogApp { // Visual indicator let color = if response.hovered() || response.dragged() { - egui::Color32::from_rgb(100, 150, 255) + self.tokens.cyan } else { - egui::Color32::from_rgb(60, 60, 60) + self.tokens.line }; ui.painter().rect_filled(rect, 2.0, color); @@ -853,6 +779,81 @@ impl UltraLogApp { response } + /// Legend / line rows for `channels`: normalized name, value at the cursor (converted to the + /// user's units, mono column), and the series colour and stroke. + fn series_rows(&self, channels: &[SelectedChannel]) -> Vec { + let cursor_time = self.get_cursor_time(); + channels + .iter() + .map(|selected| { + let original_name = selected.channel.name(); + let name = if self.field_normalization { + normalize_channel_name_with_custom( + &original_name, + Some(&self.custom_normalizations), + ) + } else { + original_name + }; + // Each log's own record at the cursor, and no value outside its span (R4). + let value = cursor_time + .and_then(|t| self.record_at(selected.file_index, t)) + .and_then(|r| { + self.get_value_at_record(selected.file_index, selected.channel_index, r) + }) + .map(|value| { + let (converted, unit) = self + .unit_preferences + .convert_value(value, selected.channel.unit()); + if unit.is_empty() { + format!("{converted:.2}") + } else { + format!("{converted:.2} {unit}") + } + }); + let (color, stroke) = self.channel_style(selected); + SeriesRow { + name, + tag: self.log_tag(selected), + value, + color, + stroke, + } + }) + .collect() + } + + /// Legend overlay, direct end labels (color-blind safe palettes) and the solo state for the + /// next frame. + #[allow(clippy::too_many_arguments)] + fn paint_plot_overlays( + &self, + ui: &egui::Ui, + response: &egui_plot::PlotResponse>, + style: &PlotStyle<'_>, + rows: &[SeriesRow], + points: &[Option>], + cursor_time: Option, + plot_id: egui::Id, + ) { + let plot_rect = *response.transform.frame(); + if style.cvd_like { + plot_style::direct_labels(ui, &response.transform, rows, points); + } + let hovered = plot_style::legend_overlay( + ui, + plot_rect, + style, + rows, + cursor_time.map(Self::format_time), + plot_id, + ); + if hovered != style.solo { + ui.ctx().request_repaint(); + } + plot_style::solo_memory(ui.ctx(), plot_id, Some(hovered)); + } + /// Format time in seconds to a human-readable string (h:mm:ss.xxx or m:ss.xxx or s.xxx) pub fn format_time(seconds: f64) -> String { let total_seconds = seconds.abs(); @@ -879,19 +880,42 @@ impl UltraLogApp { /// against the channel's full-range min/max so heights stay stable when /// the user pans or zooms. `viewport` is the previous frame's X bounds; /// when `None` (e.g., first frame after load) the full data range is used. + /// Chart points for one selected channel on the tab's time axis: log B's viewport is shifted + /// back into its own time, and its points forward by the offset; a compare pair shares one + /// y range (R1). + fn channel_plot_points( + &mut self, + selected: &SelectedChannel, + plot_area_id: usize, + viewport: Option<(f64, f64)>, + ) -> Option> { + let offset = self + .active_tab + .map_or(0.0, |idx| self.tabs[idx].time_offset(selected.file_index)); + let y_range = self.pair_y_range(selected).unwrap_or((0.0, 1.0)); + self.compute_viewport_points( + selected.file_index, + selected.channel_index, + plot_area_id, + viewport.map(|(lo, hi)| (lo - offset, hi - offset)), + offset, + y_range, + ) + } + + /// Downsampled points of one channel over `viewport` (in the file's own time), scaled to + /// 0..1 by `y_range` and moved by `offset` seconds onto the tab's time axis. The cache holds + /// the raw file-time points: offset and scale are applied to the copy returned, so a nudge + /// smaller than one bucket, or a new pair range, takes effect at once. fn compute_viewport_points( &mut self, file_index: usize, channel_index: usize, plot_area_id: usize, viewport: Option<(f64, f64)>, + offset: f64, + (min_y, max_y): (f64, f64), ) -> Option> { - // Resolve min/max first so the mutable borrow on the cache ends before - // we take immutable borrows on the channel data below. - let (min_y, max_y) = self - .get_channel_min_max(file_index, channel_index) - .unwrap_or((0.0, 1.0)); - let file = self.files.get(file_index)?; let times = file.log.get_times_as_f64(); let data = self.get_channel_data_ref(file_index, channel_index); @@ -923,10 +947,27 @@ impl UltraLogApp { }; let cache_key = (file_index, channel_index, plot_area_id); + let place = |points: &[[f64; 2]]| -> Vec<[f64; 2]> { + let range = (max_y - min_y).abs(); + // Constant channels (range ≈ 0) get parked at the middle of the + // overlay strip so they remain visible instead of pinning to the + // bottom edge — matches the prior `normalize_points` behavior. + points + .iter() + .map(|p| { + let y = if range < f64::EPSILON { + 0.5 + } else { + (p[1] - min_y) / range + }; + [p[0] + offset, y] + }) + .collect() + }; if let Some((cached_key, points)) = self.downsample_cache.get(&cache_key) && *cached_key == view_key { - return Some(points.clone()); + return Some(place(points)); } let downsampled = match view_key { @@ -975,21 +1016,9 @@ impl UltraLogApp { DownsampleViewKey::Full => Self::downsample_lttb(times, data, MAX_CHART_POINTS), }; - let range = (max_y - min_y).abs(); - // Constant channels (range ≈ 0) get parked at the middle of the - // overlay strip so they remain visible instead of pinning to the - // bottom edge — matches the prior `normalize_points` behavior. - let points: Vec<[f64; 2]> = if range < f64::EPSILON { - downsampled.into_iter().map(|p| [p[0], 0.5]).collect() - } else { - downsampled - .into_iter() - .map(|p| [p[0], (p[1] - min_y) / range]) - .collect() - }; - + let points = place(&downsampled); self.downsample_cache - .insert(cache_key, (view_key, points.clone())); + .insert(cache_key, (view_key, downsampled)); Some(points) } @@ -1094,11 +1123,118 @@ impl UltraLogApp { } } -/// Build a grid color matching the active theme but with a user-controlled -/// alpha override. The base RGB comes from `Visuals::text_color`, which is -/// what egui_plot uses by default; we just substitute the alpha so the -/// distance-based fade still applies on top. -fn grid_color_with_opacity(ui: &egui::Ui, alpha: u8) -> egui::Color32 { - let c = ui.visuals().text_color(); - egui::Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), alpha) +/// The grid colour scaled by the user's "Show grid" opacity. egui_plot fades finer grid levels +/// on top, so `plot_grid_major` as the base gives major and minor lines. +fn grid_color_with_opacity(base: egui::Color32, alpha: u8) -> egui::Color32 { + base.gamma_multiply(f32::from(alpha) / 255.0) +} + +/// Run `add` with the plot canvas set to `plot_bg` (egui_plot paints its background with +/// `extreme_bg_color`, which the theme sets to `bg_field` for text inputs). +fn with_plot_bg( + ui: &mut egui::Ui, + plot_bg: egui::Color32, + add: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + ui.scope(|ui| { + ui.visuals_mut().extreme_bg_color = plot_bg; + add(ui) + }) + .inner +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::app::UltraLogApp; + use crate::parsers::aim::AimChannel; + use crate::parsers::types::Meta; + use crate::parsers::{Channel, EcuType, Log, Value}; + use crate::state::{LoadedFile, Tab}; + + /// One "Boost" channel at 10 Hz for `secs` seconds, value `f(t)`. + fn file(name: &str, secs: usize, f: impl Fn(f64) -> f64) -> LoadedFile { + let times: Vec = (0..=secs * 10).map(|i| i as f64 * 0.1).collect(); + let log = Log { + meta: Meta::Empty, + channels: vec![Channel::Aim(AimChannel { + name: "Boost".to_string(), + unit: String::new(), + })], + data: times.iter().map(|&t| vec![Value::Float(f(t))]).collect(), + times, + }; + LoadedFile::new(PathBuf::from(name), name.to_string(), EcuType::Aim, log) + } + + /// Tab 0: A = pull peaking at 1.5, B = pull peaking at 1.8, paired on colour slot 0. + fn comparing_app() -> UltraLogApp { + let mut app = UltraLogApp::default(); + app.files.push(file("a.csv", 20, |t| 1.5 * (t / 20.0))); + app.files.push(file("b.csv", 20, |t| 1.8 * (t / 20.0))); + app.tabs.push(Tab::new(0, "a.csv".to_string())); + app.active_tab = Some(0); + app.update_tab_time_range(0); + app.set_compare_file(0, Some(1)); + app.add_channel(0, 0); + app.add_channel(1, 0); + let b = &mut app.tabs[0].selected_channels[1]; + b.color_index = 0; // pair it with A (step 3 does this when pairing) + app + } + + #[test] + fn a_pair_shares_one_y_range() { + let mut app = comparing_app(); + let [a, b] = [0, 1].map(|i| app.tabs[0].selected_channels[i].clone()); + assert_eq!(app.pair_y_range(&a), Some((0.0, 1.8))); + assert_eq!(app.pair_y_range(&b), Some((0.0, 1.8))); + + // A's peak sits below B's on the shared scale. + let pa = app.channel_plot_points(&a, 0, None).unwrap(); + let pb = app.channel_plot_points(&b, 0, None).unwrap(); + let top = |p: &[[f64; 2]]| p.iter().map(|q| q[1]).fold(f64::MIN, f64::max); + assert!((top(&pa) - 1.5 / 1.8).abs() < 1e-9); + assert!((top(&pb) - 1.0).abs() < 1e-9); + } + + #[test] + fn a_nudge_smaller_than_a_bucket_moves_log_b() { + let mut app = comparing_app(); + let b = app.tabs[0].selected_channels[1].clone(); + let viewport = Some((0.0, 20.0)); + let before = app.channel_plot_points(&b, 0, viewport).unwrap(); + + // 0.01 s is far below the bucket width over a 20 s view, so the cache key may not + // change; the points must move anyway. + app.set_compare_offset(0, 0.01); + let after = app.channel_plot_points(&b, 0, viewport).unwrap(); + assert_eq!(before.len(), after.len()); + for (p, q) in before.iter().zip(&after) { + assert!((q[0] - p[0] - 0.01).abs() < 1e-9); + assert_eq!(p[1], q[1]); + } + } + + #[test] + fn legend_values_come_from_each_logs_own_record() { + let mut app = comparing_app(); + app.set_compare_offset(0, -5.0); + app.tabs[0].cursor_time = Some(10.0); + let channels = app.tabs[0].selected_channels.clone(); + let rows = app.series_rows(&channels); + assert_eq!(rows[0].tag, Some("A")); + assert_eq!(rows[1].tag, Some("B")); + // A at t = 10: 0.75. B at file time 15: 1.35. + assert_eq!(rows[0].value.as_deref(), Some("0.75")); + assert_eq!(rows[1].value.as_deref(), Some("1.35")); + + // Past B's end (file time 20 + 5 = 25 > 20): no B value. + app.tabs[0].cursor_time = Some(19.0); + app.set_compare_offset(0, -5.0); + let rows = app.series_rows(&channels); + assert!(rows[0].value.is_some()); + assert_eq!(rows[1].value, None); + } } diff --git a/src/ui/components/button.rs b/src/ui/components/button.rs new file mode 100644 index 00000000..a93f04fb --- /dev/null +++ b/src/ui/components/button.rs @@ -0,0 +1,93 @@ +//! Button variants (`design/design-system/components/Button/README.md`). +//! +//! - **primary**: `cyan` fill, `on_cyan` label, glow on hover. One per view. +//! - **secondary**: `bg_raised` with a `line_control` border. Everything else. +//! - **ghost**: no frame until hovered. Low-weight actions beside a primary. +//! - **danger**: outlined in `danger`, never filled. Removes data the user loaded. +//! +//! Secondary and ghost take their colours from `Visuals`; primary and danger set them here. + +use eframe::egui::{self, Color32, CornerRadius, Response, RichText, Shape, Stroke, Ui, Vec2}; + +use crate::ui::theme::{self, paint, tokens::radius}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Primary, + Secondary, + Ghost, + Danger, +} + +/// Regular button height (px). +const HEIGHT: f32 = 32.0; +/// `sm` button height for dense rows (px). +const HEIGHT_SM: f32 = 24.0; +/// Icon button size (px). +const ICON_SIZE: f32 = 28.0; + +/// A button of `kind` with an optional leading Phosphor icon. Disable it by wrapping the call +/// in `ui.add_enabled_ui`. +pub fn button(ui: &mut Ui, kind: Kind, icon: Option<&str>, label: impl Into) -> Response { + show(ui, kind, icon, label.into(), HEIGHT) +} + +/// The `sm` (24px) size for dense rows: library items, results toolbars. +pub fn button_sm( + ui: &mut Ui, + kind: Kind, + icon: Option<&str>, + label: impl Into, +) -> Response { + show(ui, kind, icon, label.into(), HEIGHT_SM) +} + +/// A 28px square ghost button holding one icon. `hover_text` names the action. +pub fn icon_button(ui: &mut Ui, icon: &str, hover_text: impl Into) -> Response { + let t = theme::current(ui.ctx()); + ui.add( + egui::Button::new(RichText::new(icon).size(16.0).color(t.ink_muted)) + .frame_when_inactive(false) + .corner_radius(CornerRadius::same(radius::RADIUS_MD)) + .min_size(Vec2::splat(ICON_SIZE)), + ) + .on_hover_text(hover_text.into()) +} + +fn show(ui: &mut Ui, kind: Kind, icon: Option<&str>, label: String, height: f32) -> Response { + let t = theme::current(ui.ctx()); + let text = match icon { + Some(icon) => format!("{icon} {label}"), + None => label, + }; + let text = RichText::new(text).text_style(egui::TextStyle::Button); + let corner = CornerRadius::same(radius::RADIUS_MD); + // Reserve a slot under the button so the hover glow paints behind it. + let glow_slot = ui.painter().add(Shape::Noop); + + let button = match kind { + Kind::Primary => egui::Button::new(text.color(t.on_cyan)).fill(t.cyan), + Kind::Secondary => egui::Button::new(text.color(t.ink)), + Kind::Ghost => egui::Button::new(text.color(t.ink_muted)).frame_when_inactive(false), + Kind::Danger => egui::Button::new(text.color(t.danger)) + .fill(Color32::TRANSPARENT) + .stroke(Stroke::new(1.0, t.danger)), + }; + let response = ui.add( + button + .corner_radius(corner) + .min_size(Vec2::new(0.0, height)), + ); + + if kind == Kind::Primary + && response.hovered() + && ui.is_enabled() + && let Some(glow) = paint::glow(&t, response.rect, radius::RADIUS_MD, t.cyan) + { + ui.painter().set(glow_slot, glow); + } + if response.hovered() && ui.is_enabled() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + response +} diff --git a/src/ui/components/controls.rs b/src/ui/components/controls.rs new file mode 100644 index 00000000..f7208579 --- /dev/null +++ b/src/ui/components/controls.rs @@ -0,0 +1,287 @@ +//! Form controls: Segmented, Checkbox and the Ultra intensity Slider +//! (`design/design-system/components/{Segmented,Checkbox,Slider,IntensityControl}/README.md`). + +use eframe::egui::{ + self, Align2, CornerRadius, FontId, Mesh, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, + pos2, vec2, +}; + +use crate::ui::components::icon; +use crate::ui::fonts::{self, style}; +use crate::ui::theme::{ + self, + tokens::{radius, space, stroke}, +}; + +/// A mutually exclusive choice of 2-5 short options shown at once. The active segment is +/// `cyan_soft` with `cyan` text and a 1px cyan inset; the others `ink_muted` on `bg_field`. +/// Returns true when the value changed. +pub fn segmented(ui: &mut Ui, value: &mut T, options: &[(T, &str)]) -> bool { + let t = theme::current(ui.ctx()); + let font = egui::TextStyle::Button.resolve(ui.style()); + let mut changed = false; + egui::Frame::NONE + .fill(t.bg_field) + .stroke(Stroke::new(stroke::STROKE_HAIRLINE, t.line_control)) + .corner_radius(CornerRadius::same(radius::RADIUS_SM)) + .inner_margin(egui::Margin::same(2)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (option, label) in options { + let active = *value == *option; + let galley = + ui.painter() + .layout_no_wrap((*label).to_owned(), font.clone(), t.ink); + let size = vec2( + galley.size().x + 2.0 * space::SPACE_3, + galley.size().y + space::SPACE_2, + ); + let (rect, response) = ui.allocate_exact_size(size, Sense::click()); + let corner = CornerRadius::same(radius::RADIUS_SM - 1); + let color = if active { + ui.painter().rect( + rect, + corner, + t.cyan_soft, + Stroke::new(stroke::STROKE_HAIRLINE, t.cyan), + StrokeKind::Inside, + ); + t.cyan + } else if response.hovered() { + ui.painter().rect_filled(rect, corner, t.bg_hover); + t.ink + } else { + t.ink_muted + }; + ui.painter() + .galley(rect.center() - galley.size() / 2.0, galley, color); + if response.clicked() && !active { + *value = *option; + changed = true; + } + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + }); + }); + changed +} + +/// A 16px checkbox: `line_control` border on `bg_field`; checked fills `cyan` with an `on_cyan` +/// check. The label is `ink`, 13px. The whole row is the hit area. +pub fn checkbox(ui: &mut Ui, checked: &mut bool, label: impl Into) -> Response { + let t = theme::current(ui.ctx()); + let enabled = ui.is_enabled(); + let font = fonts::text_style(style::LABEL).resolve(ui.style()); + let text_color = if enabled { t.ink } else { t.ink_disabled }; + let galley = ui.painter().layout_no_wrap(label.into(), font, text_color); + let box_size = 16.0; + let size = vec2( + box_size + space::SPACE_2 + galley.size().x, + galley.size().y.max(box_size) + 2.0, + ); + let (rect, mut response) = ui.allocate_exact_size(size, Sense::click()); + if response.clicked() { + *checked = !*checked; + response.mark_changed(); + } + let boxr = Rect::from_min_size( + pos2(rect.left(), rect.center().y - box_size / 2.0), + vec2(box_size, box_size), + ); + let corner = CornerRadius::same(radius::RADIUS_XS + 1); + let painter = ui.painter(); + if *checked { + painter.rect_filled(boxr, corner, if enabled { t.cyan } else { t.ink_disabled }); + painter.text( + boxr.center(), + Align2::CENTER_CENTER, + icon::CHECK, + FontId::proportional(12.0), + t.on_cyan, + ); + } else { + let border = if response.hovered() && enabled { + t.ink_muted + } else { + t.line_control + }; + painter.rect( + boxr, + corner, + t.bg_field, + Stroke::new(stroke::STROKE_HAIRLINE, border), + StrokeKind::Inside, + ); + } + painter.galley( + pos2( + boxr.right() + space::SPACE_2, + rect.center().y - galley.size().y / 2.0, + ), + galley, + text_color, + ); + if response.hovered() && enabled { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + response +} + +/// The Ultra intensity track: 0..=100 on a 4px track that previews the dial, grey +/// `line_control` into the sunset `redline_*` stops, with a 14px `ink` thumb ringed in `cyan`. +/// Arrow keys step by 1 when focused. Returns the response (`changed()` on any change). +pub fn intensity_slider(ui: &mut Ui, value: &mut u8, width: f32) -> Response { + let t = theme::current(ui.ctx()); + let enabled = ui.is_enabled(); + let thumb = 14.0; + let (rect, mut response) = + ui.allocate_exact_size(vec2(width, thumb + 6.0), Sense::click_and_drag()); + let track = Rect::from_min_max( + pos2(rect.left() + thumb / 2.0, rect.center().y - 2.0), + pos2(rect.right() - thumb / 2.0, rect.center().y + 2.0), + ); + + let old = *value; + if enabled { + if let Some(p) = response.interact_pointer_pos() + && (response.dragged() || response.clicked()) + { + let k = ((p.x - track.left()) / track.width()).clamp(0.0, 1.0); + *value = (k * 100.0).round() as u8; + } + if response.has_focus() { + // Keep Left/Right for the slider; otherwise egui also moves focus with them. + ui.memory_mut(|m| { + m.set_focus_lock_filter( + response.id, + egui::EventFilter { + horizontal_arrows: true, + ..Default::default() + }, + ) + }); + let (left, right) = ui.input(|i| { + ( + i.key_pressed(egui::Key::ArrowLeft), + i.key_pressed(egui::Key::ArrowRight), + ) + }); + if left { + *value = value.saturating_sub(1); + } + if right { + *value = (*value + 1).min(100); + } + } + } + if *value != old { + response.mark_changed(); + } + + let painter = ui.painter(); + // Track: line_control up to the cyan stop, then the sunset. + let stops = [ + (0.0, t.line_control), + (0.35, t.redline_0), + (0.6, t.redline_1), + (0.8, t.redline_2), + (0.92, t.redline_3), + (1.0, t.redline_4), + ]; + let mut mesh = Mesh::default(); + for (k, color) in stops { + let color = if enabled { color } else { t.ink_disabled }; + let x = track.left() + track.width() * k; + mesh.colored_vertex(Pos2::new(x, track.top()), color); + mesh.colored_vertex(Pos2::new(x, track.bottom()), color); + } + for i in 0..(stops.len() as u32 - 1) { + let a = i * 2; + mesh.add_triangle(a, a + 1, a + 2); + mesh.add_triangle(a + 1, a + 3, a + 2); + } + painter.add(egui::Shape::mesh(mesh)); + + let x = track.left() + track.width() * f32::from(*value) / 100.0; + let center = pos2(x, rect.center().y); + painter.circle( + center, + thumb / 2.0, + if enabled { t.ink } else { t.ink_disabled }, + Stroke::new( + stroke::STROKE_INDICATOR, + if enabled { t.cyan } else { t.line_control }, + ), + ); + if response.hovered() && enabled { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + response +} + +#[cfg(test)] +mod tests { + use super::*; + use eframe::egui; + + fn key(k: egui::Key) -> egui::Event { + egui::Event::Key { + key: k, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + } + } + + /// One frame with `events`; returns the slider's value and whether it has focus. + fn frame(ctx: &egui::Context, value: &mut u8, events: Vec, focus: bool) -> bool { + let input = egui::RawInput { + events, + ..Default::default() + }; + let mut focused = false; + let mut output = ctx.run_ui(input, |ui| { + let r = intensity_slider(ui, value, 200.0); + if focus { + r.request_focus(); + } + focused = r.has_focus(); + }); + output.textures_delta.clear(); + focused + } + + #[test] + fn arrow_keys_step_the_focused_slider_and_keep_focus() { + let ctx = egui::Context::default(); + let mut value = 35u8; + frame(&ctx, &mut value, vec![], true); + assert!(frame(&ctx, &mut value, vec![], false), "slider took focus"); + frame(&ctx, &mut value, vec![key(egui::Key::ArrowRight)], false); + assert_eq!(value, 36); + assert!( + frame(&ctx, &mut value, vec![key(egui::Key::ArrowRight)], false), + "Right keeps focus" + ); + assert_eq!(value, 37); + frame(&ctx, &mut value, vec![key(egui::Key::ArrowLeft)], false); + assert_eq!(value, 36); + } + + #[test] + fn arrow_right_right_after_focus_still_steps() { + let ctx = egui::Context::default(); + let mut value = 35u8; + frame(&ctx, &mut value, vec![], true); + let still = frame(&ctx, &mut value, vec![key(egui::Key::ArrowRight)], false); + assert_eq!(value, 36); + assert!( + frame(&ctx, &mut value, vec![], false) || !still, + "focus after the first Right" + ); + } +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs new file mode 100644 index 00000000..416dae4b --- /dev/null +++ b/src/ui/components/mod.rs @@ -0,0 +1,18 @@ +//! Night Drive components shared by every view. +//! +//! Each follows its spec in `design/design-system/components//README.md` and reads colours +//! from `crate::ui::theme::current`, so it can be called from any `Ui` without an app reference. + +pub mod button; +pub mod controls; +pub mod notice; +pub mod window; + +pub use button::{Kind, button, button_sm, icon_button}; +pub use controls::{checkbox, intensity_slider, segmented}; +pub use notice::{Status, ecu_badge, notice, status_badge}; +pub use window::{finish_window, window}; + +/// Phosphor icons (regular weight, outline). Filled glyphs are only for transport (play, stop) +/// and the favourite star: draw those with `FontFamily::Name(fonts::PHOSPHOR_FILL)`. +pub use egui_phosphor::regular as icon; diff --git a/src/ui/components/notice.rs b/src/ui/components/notice.rs new file mode 100644 index 00000000..bdb85ddd --- /dev/null +++ b/src/ui/components/notice.rs @@ -0,0 +1,153 @@ +//! Status: Notice (inline message) and status Badge +//! (`design/design-system/components/Notice/README.md`, `.../Badge/README.md`). +//! +//! Every status carries an icon shape and a word, never colour alone: check = success, +//! triangle = warning, octagon = danger, circle-i = info. + +use eframe::egui::{self, Color32, CornerRadius, Margin, RichText, Stroke, Ui, text::LayoutJob}; + +use crate::ui::components::icon; +use crate::ui::theme::{ + self, Tokens, + tokens::{radius, space}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + Info, + Success, + Warning, + Danger, +} + +impl Status { + /// The fixed icon shape for this status. + pub fn icon(self) -> &'static str { + match self { + Status::Info => icon::INFO, + Status::Success => icon::CHECK, + Status::Warning => icon::WARNING, + Status::Danger => icon::WARNING_OCTAGON, + } + } + + /// The status colour (icon, lead sentence, badge text). + pub fn color(self, t: &Tokens) -> Color32 { + match self { + Status::Info => t.info, + Status::Success => t.success, + Status::Warning => t.warning, + Status::Danger => t.danger, + } + } + + /// The soft fill behind a notice or badge. Info has no soft token; it uses `cyan_soft`. + pub fn soft(self, t: &Tokens) -> Color32 { + match self { + Status::Info => t.cyan_soft, + Status::Success => t.success_soft, + Status::Warning => t.warning_soft, + Status::Danger => t.danger_soft, + } + } +} + +/// An inline notice: status icon, a bold lead sentence in the status colour, the rest in `ink`. +/// Lead with what happened, then what to do. +pub fn notice(ui: &mut Ui, status: Status, lead: &str, rest: &str) -> egui::Response { + let t = theme::current(ui.ctx()); + let color = status.color(&t); + egui::Frame::NONE + .fill(status.soft(&t)) + .corner_radius(CornerRadius::same(radius::RADIUS_MD)) + .inner_margin(Margin::symmetric( + space::SPACE_3 as i8, + space::SPACE_2 as i8, + )) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal_top(|ui| { + ui.label(RichText::new(status.icon()).color(color)); + let body = egui::TextStyle::Body.resolve(ui.style()); + let mut job = LayoutJob::default(); + if !lead.is_empty() { + job.append( + lead, + 0.0, + egui::TextFormat { + font_id: egui::FontId::new( + body.size, + egui::FontFamily::Name(crate::ui::fonts::UI_SEMIBOLD.into()), + ), + color, + ..Default::default() + }, + ); + } + if !rest.is_empty() { + job.append( + rest, + if lead.is_empty() { + 0.0 + } else { + body.size * 0.3 + }, + egui::TextFormat { + font_id: body, + color: t.ink, + ..Default::default() + }, + ); + } + job.wrap.max_width = ui.available_width(); + ui.label(job); + }); + }) + .response +} + +/// A small status badge: soft fill, status colour, icon + word. +pub fn status_badge(ui: &mut Ui, status: Status, word: &str) -> egui::Response { + let t = theme::current(ui.ctx()); + badge_frame(ui, status.soft(&t), Stroke::NONE, |ui| { + ui.label( + RichText::new(format!("{} {word}", status.icon())) + .small() + .color(status.color(&t)), + ); + }) +} + +/// The ECU source badge: mono, outlined. +pub fn ecu_badge(ui: &mut Ui, code: &str) -> egui::Response { + let t = theme::current(ui.ctx()); + badge_frame( + ui, + Color32::TRANSPARENT, + Stroke::new(1.0, t.line_control), + |ui| { + ui.label( + RichText::new(code) + .text_style(crate::ui::fonts::text_style( + crate::ui::fonts::style::DATA_SM, + )) + .color(t.ink_subtle), + ); + }, + ) +} + +fn badge_frame( + ui: &mut Ui, + fill: Color32, + stroke: Stroke, + add: impl FnOnce(&mut Ui), +) -> egui::Response { + egui::Frame::NONE + .fill(fill) + .stroke(stroke) + .corner_radius(CornerRadius::same(radius::RADIUS_XS)) + .inner_margin(Margin::symmetric(space::SPACE_1 as i8 + 2, 1)) + .show(ui, add) + .response +} diff --git a/src/ui/components/window.rs b/src/ui/components/window.rs new file mode 100644 index 00000000..5fca3b63 --- /dev/null +++ b/src/ui/components/window.rs @@ -0,0 +1,133 @@ +//! Floating tool windows and dialogs (`design/design-system/components/Window/README.md`). +//! +//! `bg_raised`, `line` border, `radius_lg` and the window shadow come from `Visuals`. This adds +//! the title in Saira 600, the 2px sunset redline along the top edge, and Esc to close. +//! +//! ```ignore +//! let mut open = self.show_thing; +//! let response = components::window(t!("thing.title")).open(&mut open).show(ctx, |ui| { /* … */ }); +//! components::finish_window(ctx, response.as_ref().map(|r| &r.response), &mut open); +//! self.show_thing = open; +//! ``` + +use eframe::egui::{self, RichText}; + +use crate::ui::fonts; +use crate::ui::theme::{self, paint, tokens::radius}; + +/// An `egui::Window` with the Night Drive title. Chain the usual builder calls on it. +pub fn window<'open>(title: impl Into) -> egui::Window<'open> { + egui::Window::new( + RichText::new(title.into()) + .text_style(egui::TextStyle::Heading) + .family(egui::FontFamily::Name(fonts::DISPLAY.into())), + ) +} + +/// Draw the redline on a shown window and close it on Esc when it is the topmost window. +pub fn finish_window(ctx: &egui::Context, response: Option<&egui::Response>, open: &mut bool) { + let Some(response) = response else { + return; + }; + let t = theme::current(ctx); + let painter = ctx.layer_painter(response.layer_id); + paint::redline( + &painter, + response.rect, + &t, + f32::from(radius::RADIUS_LG) * 0.6, + ); + + // Topmost among *visible* layers of this order: egui keeps hidden layers (a toast that + // faded, a closed popup or window) in its order list, so `top_layer_id` alone would leave + // Esc dead after any of them had been shown. + let topmost = ctx.memory(|m| { + m.layer_ids() + .filter(|l| l.order == response.layer_id.order && m.areas().is_visible(l)) + .last() + }) == Some(response.layer_id); + if *open && topmost && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + *open = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn escape() -> egui::Event { + egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + } + } + + /// One frame showing the windows that are open; updates `open`, returns the library rect. + fn frame(ctx: &egui::Context, open: &mut [bool; 2], events: Vec) -> egui::Rect { + let mut library = egui::Rect::NOTHING; + let input = egui::RawInput { + events, + ..Default::default() + }; + let mut output = ctx.run_ui(input, |ui| { + for (i, name) in ["Library", "Editor"].into_iter().enumerate() { + if open[i] { + let mut o = true; + let r = window(name) + .order(egui::Order::Foreground) + .show(ui.ctx(), |ui| ui.label(name)); + finish_window(ui.ctx(), r.as_ref().map(|r| &r.response), &mut o); + if i == 0 + && let Some(r) = &r + { + library = r.response.rect; + } + open[i] = o; + } + } + }); + output.textures_delta.clear(); + library + } + + #[test] + fn esc_closes_the_newest_window_first() { + let ctx = egui::Context::default(); + // The window title uses the app's display family and theme text styles. + crate::ui::fonts::install(&ctx, crate::i18n::Language::English); + let tokens = crate::ui::theme::tokens_at(crate::ui::theme::ThemeSettings::default()); + crate::ui::theme::install(&ctx, &tokens, 1.0); + let mut open = [true, false]; + let mut rect = egui::Rect::NOTHING; + for _ in 0..3 { + rect = frame(&ctx, &mut open, vec![]); + } + // The editor opens from a click inside the library (press, then release opens it): the + // newer window is on top, and Esc closes it before the library. + let at = rect.center(); + let button = |pressed| egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + frame( + &ctx, + &mut open, + vec![egui::Event::PointerMoved(at), button(true)], + ); + open[1] = true; + frame(&ctx, &mut open, vec![button(false)]); + for _ in 0..3 { + frame(&ctx, &mut open, vec![]); + } + frame(&ctx, &mut open, vec![escape()]); + assert_eq!(open, [true, false], "first Esc closes the editor"); + frame(&ctx, &mut open, vec![]); + frame(&ctx, &mut open, vec![escape()]); + assert_eq!(open, [false, false], "second Esc closes the library"); + } +} diff --git a/src/ui/computed_channels_manager.rs b/src/ui/computed_channels_manager.rs index 4e8f1eee..f5ec6909 100644 --- a/src/ui/computed_channels_manager.rs +++ b/src/ui/computed_channels_manager.rs @@ -14,7 +14,11 @@ use crate::expression::{ }; use crate::parsers::Channel; use crate::parsers::types::ComputedChannelInfo; -use crate::state::{CHART_COLORS, SelectedChannel}; +use crate::state::SelectedChannel; +use crate::ui::components::{self, Kind, Status, icon}; +use crate::ui::fonts::{self, style}; +use crate::ui::formula_syntax; +use crate::ui::theme::{self, tokens::radius}; impl UltraLogApp { /// Render the computed channels manager window @@ -24,10 +28,13 @@ impl UltraLogApp { } let mut open = true; - let font_12 = self.scaled_font(12.0); - let font_14 = self.scaled_font(14.0); - egui::Window::new(t!("computed.title")) + let window_response = components::window(t!("computed.title")) + // First open is centered; egui's default (0, 0) put the title bar + // over the menu bar. The user can still drag it, and egui keeps + // the moved position after that. + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) .open(&mut open) .resizable(true) .default_width(500.0) @@ -40,8 +47,11 @@ impl UltraLogApp { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Help button let help_btn = ui.add( - egui::Button::new(egui::RichText::new("?").size(font_14)) - .min_size(egui::vec2(24.0, 24.0)), + egui::Button::new( + egui::RichText::new(icon::QUESTION) + .text_style(egui::TextStyle::Body), + ) + .min_size(egui::vec2(24.0, 24.0)), ); if help_btn.clicked() { self.show_computed_channels_help = !self.show_computed_channels_help; @@ -57,8 +67,8 @@ impl UltraLogApp { ui.add_space(4.0); ui.label( egui::RichText::new(t!("computed.description")) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); ui.add_space(8.0); @@ -66,7 +76,7 @@ impl UltraLogApp { // Quick Create section ui.label( egui::RichText::new(t!("computed.quick_create")) - .size(font_12) + .small() .strong(), ); ui.add_space(4.0); @@ -120,7 +130,7 @@ impl UltraLogApp { "computed.your_library", count = self.computed_library.templates.len() )) - .size(font_14) + .heading() .strong(), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -140,13 +150,13 @@ impl UltraLogApp { ui.add_space(20.0); ui.label( egui::RichText::new(t!("computed.no_channels")) - .color(egui::Color32::GRAY), + .color(self.tokens.ink_subtle), ); ui.add_space(4.0); ui.label( egui::RichText::new(t!("computed.get_started")) - .color(egui::Color32::GRAY) - .size(font_12), + .color(self.tokens.ink_subtle) + .small(), ); ui.add_space(20.0); }); @@ -179,8 +189,6 @@ impl UltraLogApp { self.render_template_card( ui, template, - font_12, - font_14, &mut template_to_edit, &mut template_to_delete, &mut template_to_apply, @@ -222,7 +230,7 @@ impl UltraLogApp { ui.add_space(8.0); // Applied channels section (for current file) - self.render_applied_channels_section(ui, font_12, font_14); + self.render_applied_channels_section(ui); }); // Render help popup if open @@ -230,6 +238,11 @@ impl UltraLogApp { self.render_computed_channels_help(ctx); } + components::finish_window( + ctx, + window_response.as_ref().map(|r| &r.response), + &mut open, + ); if !open { self.show_computed_channels_manager = false; } @@ -241,100 +254,98 @@ impl UltraLogApp { &self, ui: &mut egui::Ui, template: &ComputedChannelTemplate, - font_12: f32, - font_14: f32, template_to_edit: &mut Option, template_to_delete: &mut Option, template_to_apply: &mut Option, template_to_duplicate: &mut Option, ) { + let t = self.tokens; egui::Frame::NONE - .fill(egui::Color32::from_rgb(45, 48, 45)) - .corner_radius(6.0) + .fill(t.bg_raised) + .stroke(egui::Stroke::new(1.0, t.line)) + .corner_radius(egui::CornerRadius::same(radius::RADIUS_MD)) .inner_margin(egui::Margin::symmetric(12, 8)) .show(ui, |ui| { ui.horizontal(|ui| { // Template info ui.vertical(|ui| { ui.horizontal(|ui| { + // The magenta italic ƒ marks a computed channel everywhere. ui.label( - egui::RichText::new("ƒ") - .color(egui::Color32::from_rgb(150, 200, 255)) - .size(font_14), + egui::RichText::new("\u{0192}") + .text_style(egui::TextStyle::Body) + .family(egui::FontFamily::Name( + fonts::DISPLAY_BOLD_ITALIC.into(), + )) + .color(t.magenta), ); ui.label( egui::RichText::new(&template.name) - .strong() - .size(font_14) - .color(egui::Color32::WHITE), + .text_style(egui::TextStyle::Heading) + .color(t.ink), ); if !template.unit.is_empty() { ui.label( - egui::RichText::new(format!("({})", template.unit)) - .size(font_12) - .color(egui::Color32::GRAY), + egui::RichText::new(&template.unit) + .text_style(fonts::text_style(style::DATA_SM)) + .color(t.ink_subtle), ); } if template.is_builtin { ui.label( - egui::RichText::new("★") - .size(font_12) - .color(egui::Color32::GOLD), + egui::RichText::new(icon::STAR) + .family(egui::FontFamily::Name(fonts::PHOSPHOR_FILL.into())) + .color(t.gold), ) .on_hover_text(t!("computed.builtin_template")); } }); ui.label( egui::RichText::new(&template.formula) - .monospace() - .size(font_12) - .color(egui::Color32::from_rgb(160, 180, 160)), + .text_style(fonts::text_style(style::DATA)) + .color(t.ink_muted), ); }); // Buttons on the right ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Overflow menu for Edit/Delete/Duplicate - ui.menu_button("•••", |ui| { - if ui.button(t!("common.edit")).clicked() { - *template_to_edit = Some(template.id.clone()); - ui.close(); - } - if ui.button(t!("common.duplicate")).clicked() { - *template_to_duplicate = Some(template.clone()); - ui.close(); - } - ui.separator(); - if ui - .button( - egui::RichText::new(t!("common.delete")) - .color(egui::Color32::from_rgb(255, 120, 120)), - ) - .clicked() - { - *template_to_delete = Some(template.id.clone()); - ui.close(); - } - }); - - // Apply button (primary action) - if self.active_tab.is_some() { - let apply_btn = egui::Button::new( - egui::RichText::new(t!("common.apply")).color(egui::Color32::WHITE), - ) - .fill(egui::Color32::from_rgb(80, 110, 80)); + ui.menu_button( + egui::RichText::new(icon::DOTS_THREE).color(t.ink_muted), + |ui| { + if ui.button(t!("common.edit")).clicked() { + *template_to_edit = Some(template.id.clone()); + ui.close(); + } + if ui.button(t!("common.duplicate")).clicked() { + *template_to_duplicate = Some(template.clone()); + ui.close(); + } + ui.separator(); + if ui + .button( + egui::RichText::new(t!("common.delete")).color(t.danger), + ) + .clicked() + { + *template_to_delete = Some(template.id.clone()); + ui.close(); + } + }, + ); - if ui.add(apply_btn).clicked() { - *template_to_apply = Some(template.clone()); - } + if self.active_tab.is_some() + && components::button_sm(ui, Kind::Secondary, None, t!("common.apply")) + .clicked() + { + *template_to_apply = Some(template.clone()); } }); }); }); } - /// Render the applied channels section - fn render_applied_channels_section(&mut self, ui: &mut egui::Ui, font_12: f32, font_14: f32) { + fn render_applied_channels_section(&mut self, ui: &mut egui::Ui) { if let Some(tab_idx) = self.active_tab { let file_idx = self.tabs[tab_idx].file_index; let applied_count = self @@ -345,7 +356,7 @@ impl UltraLogApp { ui.label( egui::RichText::new(t!("computed.applied_to_file", count = applied_count)) - .size(font_14) + .heading() .strong(), ); ui.add_space(4.0); @@ -353,8 +364,8 @@ impl UltraLogApp { if applied_count == 0 { ui.label( egui::RichText::new(t!("computed.apply_templates_hint")) - .color(egui::Color32::GRAY) - .size(font_12), + .color(self.tokens.ink_subtle) + .small(), ); } else { let mut channel_to_remove: Option = None; @@ -363,37 +374,37 @@ impl UltraLogApp { if let Some(channels) = self.file_computed_channels.get(&file_idx) { for (idx, channel) in channels.iter().enumerate() { egui::Frame::NONE - .fill(egui::Color32::from_rgb(40, 45, 50)) - .corner_radius(4.0) + .fill(self.tokens.bg_raised) + .corner_radius(egui::CornerRadius::same(radius::RADIUS_SM)) .inner_margin(egui::Margin::symmetric(10, 6)) .show(ui, |ui| { ui.horizontal(|ui| { // Status indicator - if channel.is_valid() { - ui.label( - egui::RichText::new("●") - .color(egui::Color32::GREEN) - .size(font_12), - ); + let (status, word) = if channel.is_valid() { + (Status::Success, t!("formula.formula_valid")) } else { - ui.label( - egui::RichText::new("●") - .color(egui::Color32::RED) - .size(font_12), - ); - } + (Status::Danger, t!("formula.error")) + }; + ui.label( + egui::RichText::new(status.icon()) + .color(status.color(&self.tokens)), + ) + .on_hover_text(word); ui.label( - egui::RichText::new(channel.name()) - .size(font_14) - .color(egui::Color32::LIGHT_GREEN), + egui::RichText::new(format!("\u{0192} {}", channel.name())) + .text_style(egui::TextStyle::Body) + .color(self.tokens.ink), ); if let Some(error) = &channel.error { ui.label( - egui::RichText::new(error) - .size(font_12) - .color(egui::Color32::RED), + egui::RichText::new(format!( + "{} {error}", + crate::ui::components::Status::Danger.icon() + )) + .small() + .color(self.tokens.danger), ); } @@ -434,8 +445,8 @@ impl UltraLogApp { } else { ui.label( egui::RichText::new(t!("computed.load_file_hint")) - .color(egui::Color32::GRAY) - .size(font_12), + .color(self.tokens.ink_subtle) + .small(), ); } } @@ -444,7 +455,7 @@ impl UltraLogApp { fn render_computed_channels_help(&mut self, ctx: &egui::Context) { let mut open = true; - egui::Window::new(t!("computed.help_title")) + let window_response = components::window(t!("computed.help_title")) .open(&mut open) .resizable(false) .default_width(400.0) @@ -507,39 +518,42 @@ impl UltraLogApp { }); }); + components::finish_window( + ctx, + window_response.as_ref().map(|r| &r.response), + &mut open, + ); if !open { self.show_computed_channels_help = false; } } fn help_row(ui: &mut egui::Ui, code: &str, description: &str) { + let t = theme::current(ui.ctx()); + let font = fonts::text_style(style::DATA).resolve(ui.style()); ui.horizontal(|ui| { - ui.label( - egui::RichText::new(code) - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); - ui.label(egui::RichText::new("—").color(egui::Color32::GRAY)); - ui.label(egui::RichText::new(description).color(egui::Color32::GRAY)); + ui.label(formula_syntax::layout(code, font, &t, f32::INFINITY)); + ui.label(egui::RichText::new("\u{2014}").color(t.ink_subtle)); + ui.label(egui::RichText::new(description).color(t.ink_muted)); }); } fn example_row(ui: &mut egui::Ui, formula: &str, description: &str) { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(formula) - .monospace() - .color(egui::Color32::from_rgb(180, 200, 255)), - ); - }); + let t = theme::current(ui.ctx()); + let font = fonts::text_style(style::DATA).resolve(ui.style()); + ui.label(formula_syntax::layout( + formula, + font, + &t, + ui.available_width(), + )); ui.label( egui::RichText::new(format!(" {}", description)) .small() - .color(egui::Color32::GRAY), + .color(t.ink_subtle), ); } - /// Apply a computed channel template to the current file pub fn apply_computed_channel_template(&mut self, template: &ComputedChannelTemplate) { let Some(tab_idx) = self.active_tab else { self.show_toast_warning(&t!("toast.no_active_tab")); @@ -548,7 +562,7 @@ impl UltraLogApp { let file_idx = self.tabs[tab_idx].file_index; if file_idx >= self.files.len() { - self.show_toast_error(&t!("toast.no_active_file")); + self.show_toast_error(&t!("toast.no_file_selected")); return; } let file = &self.files[file_idx]; @@ -649,20 +663,12 @@ impl UltraLogApp { } // Check max channels - if self.tabs[tab_idx].selected_channels.len() >= 10 { + if self.tabs[tab_idx].total_channel_units() >= crate::state::MAX_CHANNELS { self.show_toast_warning(&t!("toast.max_channels_reached")); return; } - // Find unused color - let used_colors: std::collections::HashSet = self.tabs[tab_idx] - .selected_channels - .iter() - .map(|c| c.color_index) - .collect(); - let color_index = (0..CHART_COLORS.len()) - .find(|i| !used_colors.contains(i)) - .unwrap_or(0); + let color_index = crate::state::next_color_index(&self.tabs[tab_idx].selected_channels); // Create the selected channel with computed channel info let channel = Channel::Computed(ComputedChannelInfo { diff --git a/src/ui/export.rs b/src/ui/export.rs index 174599b3..a445e0fe 100644 --- a/src/ui/export.rs +++ b/src/ui/export.rs @@ -1,1633 +1,720 @@ -//! Chart export functionality (PNG, PDF). +//! Export: build an ExportSheet (`ui/export_sheet`) from the active tool's state and write it +//! as PNG or PDF, or copy the chart or table as an image for pasting. +//! +//! Every tool exports through [`UltraLogApp::export_sheet`], so the chart, scatter plots, +//! histogram and tuning tables share one layout, the export themes and one file path. -use printpdf::*; -use rust_i18n::t; +use std::path::Path; -// Use fully qualified path to disambiguate from printpdf's image module -use ::image::{Rgba, RgbaImage}; +use rust_i18n::t; +use crate::analysis::tables::binning::uniform_bin; use crate::analytics; use crate::app::UltraLogApp; use crate::normalize::normalize_channel_name_with_custom; -use crate::state::HistogramMode; - -/// Helper to push text ops into a Vec -pub(crate) fn push_text( - ops: &mut Vec, - text: &str, - size: f32, - x: Mm, - y: Mm, - font: &PdfFontHandle, -) { - ops.push(Op::StartTextSection); - ops.push(Op::SetFont { - font: font.clone(), - size: Pt(size), - }); - ops.push(Op::SetTextCursor { - pos: Point::new(x, y), - }); - ops.push(Op::ShowText { - items: vec![TextItem::Text(text.to_string())], - }); - ops.push(Op::EndTextSection); -} - -/// Helper to push a filled rectangle (polygon) into ops -pub(crate) fn push_filled_rect(ops: &mut Vec, x: f32, y: f32, w: f32, h: f32) { - let rect = Polygon { - rings: vec![PolygonRing { - points: vec![ - LinePoint { - p: Point::new(Mm(x), Mm(y)), - bezier: false, - }, - LinePoint { - p: Point::new(Mm(x + w), Mm(y)), - bezier: false, - }, - LinePoint { - p: Point::new(Mm(x + w), Mm(y + h)), - bezier: false, - }, - LinePoint { - p: Point::new(Mm(x), Mm(y + h)), - bezier: false, - }, - ], - }], - mode: PaintMode::Fill, - winding_order: WindingOrder::NonZero, - }; - ops.push(Op::DrawPolygon { polygon: rect }); +use crate::state::{ActiveTool, HistogramMode, ScatterPlotConfig}; +use crate::ui::export_sheet::{ + self, Body, ChartModel, ExportFormat, GridModel, SeriesModel, Sheet, +}; +use crate::ui::heatmap::MapKind; +use crate::ui::theme::ExportTheme; + +/// Points per series on an exported chart (LTTB keeps peaks and dropouts). +const CHART_POINTS: usize = 1500; +/// Bins per axis on an exported scatter density map. +const SCATTER_BINS: usize = 96; + +pub(crate) type ExportResult = Result>; + +/// Write `sheet` to `path` in `format`. +pub fn write_sheet( + sheet: &Sheet, + format: ExportFormat, + theme: ExportTheme, + path: &Path, +) -> ExportResult<()> { + match format { + ExportFormat::Png => export_sheet::render_png(sheet, theme).save(path)?, + ExportFormat::Pdf => std::fs::write(path, export_sheet::render_pdf(sheet, theme))?, + } + Ok(()) } -/// Helper to push a closed line (border) into ops -pub(crate) fn push_closed_line(ops: &mut Vec, points: &[(f32, f32)]) { - let line = Line { - points: points - .iter() - .map(|&(x, y)| LinePoint { - p: Point::new(Mm(x), Mm(y)), - bezier: false, - }) - .collect(), - is_closed: true, - }; - ops.push(Op::DrawLine { line }); +/// Decimals that keep about three significant digits across `range`. +fn decimals_for(range: f64) -> usize { + if range >= 100.0 { + 0 + } else if range >= 10.0 { + 1 + } else { + 2 + } } -/// Helper to push an open line into ops -fn push_open_line(ops: &mut Vec, points: &[(f32, f32)]) { - let line = Line { - points: points - .iter() - .map(|&(x, y)| LinePoint { - p: Point::new(Mm(x), Mm(y)), - bezier: false, - }) - .collect(), - is_closed: false, - }; - ops.push(Op::DrawLine { line }); +/// `count + 1` evenly spaced axis labels from `min` to `min + range`. +pub(crate) fn even_ticks(min: f64, range: f64, count: usize) -> Vec<(f32, String)> { + let d = decimals_for(range); + (0..=count) + .map(|i| { + let k = i as f64 / count as f64; + (k as f32, format!("{:.d$}", min + k * range)) + }) + .collect() } -impl UltraLogApp { - /// Export the current chart view as PNG - pub fn export_chart_png(&mut self) { - // Show save dialog - let Some(path) = rfd::FileDialog::new() - .add_filter("PNG Image", &["png"]) - .set_file_name("ultralog_chart.png") - .save_file() - else { - return; - }; - - // Create a simple chart representation as image - match self.render_chart_to_png(&path) { - Ok(_) => { - analytics::track_export("png"); - self.show_toast_success(&t!("toast.export_png_success")); - } - Err(e) => self.show_toast_error(&t!("toast.export_failed", error = e.to_string())), +/// `n` with thin-space thousands separators (`223 559`). +pub(crate) fn group_thousands(n: usize) -> String { + let digits = n.to_string(); + let mut out = String::new(); + for (i, ch) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i).is_multiple_of(3) { + out.push('\u{2009}'); } + out.push(ch); } + out +} - /// Export the current chart view as PDF - pub fn export_chart_pdf(&mut self) { - // Show save dialog - let Some(path) = rfd::FileDialog::new() - .add_filter("PDF Document", &["pdf"]) - .set_file_name("ultralog_chart.pdf") - .save_file() - else { - return; - }; - - match self.render_chart_to_pdf(&path) { - Ok(_) => { - analytics::track_export("pdf"); - self.show_toast_success(&t!("toast.export_pdf_success")); - } - Err(e) => self.show_toast_error(&t!("toast.export_failed", error = e.to_string())), - } +/// Finite min and max of `values`, or `None` when there are none. +fn finite_bounds(values: &[f64]) -> Option<(f64, f64)> { + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for &v in values.iter().filter(|v| v.is_finite()) { + lo = lo.min(v); + hi = hi.max(v); } + (lo <= hi).then_some((lo, hi)) +} - /// Render chart data to PNG file - fn render_chart_to_png( - &self, - path: &std::path::Path, - ) -> Result<(), Box> { - let width = 1920u32; - let height = 1080u32; - - // Create image buffer - let mut imgbuf = RgbaImage::new(width, height); - - // Fill with dark background - for pixel in imgbuf.pixels_mut() { - *pixel = Rgba([30, 30, 30, 255]); - } - - // Draw chart area background - let chart_left = 80u32; - let chart_right = width - 40; - let chart_top = 60u32; - let chart_bottom = height - 80; - - for y in chart_top..chart_bottom { - for x in chart_left..chart_right { - imgbuf.put_pixel(x, y, Rgba([40, 40, 40, 255])); - } - } - - // Get time range - let Some((min_time, max_time)) = self.time_range else { - return Err("No time range available".into()); - }; - - let time_span = max_time - min_time; - if time_span <= 0.0 { - return Err("Invalid time range".into()); - } - - let chart_width = (chart_right - chart_left) as f64; - let chart_height = (chart_bottom - chart_top) as f64; - - // Draw each channel - for selected in self.get_selected_channels() { - let color = self.get_channel_color(selected.color_index); - let pixel_color = Rgba([color[0], color[1], color[2], 255]); - - // Get channel data - if selected.file_index >= self.files.len() { - continue; - } - let file = &self.files[selected.file_index]; - let times = file.log.get_times_as_f64(); - let data = self.get_channel_data(selected.file_index, selected.channel_index); - - if data.is_empty() { - continue; - } - - // Find min/max for normalization - let mut data_min = f64::INFINITY; - let mut data_max = f64::NEG_INFINITY; - for &val in &data { - data_min = data_min.min(val); - data_max = data_max.max(val); - } - - let data_range = if (data_max - data_min).abs() < 0.0001 { - 1.0 - } else { - data_max - data_min - }; - - // Draw data points as lines - let mut prev_x: Option = None; - let mut prev_y: Option = None; - - for (&time, &value) in times.iter().zip(data.iter()) { - // Skip points outside time range - if time < min_time || time > max_time { - continue; - } - - let x_ratio = (time - min_time) / time_span; - let y_ratio = (value - data_min) / data_range; - - let x = chart_left + (x_ratio * chart_width) as u32; - let y = chart_bottom - (y_ratio * chart_height) as u32; - - // Draw line from previous point - if let (Some(px), Some(py)) = (prev_x, prev_y) { - draw_line(&mut imgbuf, px, py, x, y, pixel_color); - } - - prev_x = Some(x); - prev_y = Some(y); - } - } - - // Save the image - imgbuf.save(path)?; - - Ok(()) +/// A value on the chart's 0..=1 scale for a channel spanning `lo..=hi`. A constant channel +/// sits at mid-height, as `chart.rs` draws it, not on the bottom border. +fn normalize_like_chart(v: f64, lo: f64, hi: f64) -> f64 { + if (hi - lo).abs() < f64::EPSILON { + 0.5 + } else { + ((v - lo) / (hi - lo)).clamp(0.0, 1.0) } +} - /// Render chart data to PDF file - fn render_chart_to_pdf( - &self, - path: &std::path::Path, - ) -> Result<(), Box> { - let mut ops: Vec = Vec::new(); - - let font_bold = PdfFontHandle::Builtin(BuiltinFont::HelveticaBold); - let font_regular = PdfFontHandle::Builtin(BuiltinFont::Helvetica); - - // Get time range - let Some((min_time, max_time)) = self.time_range else { - return Err("No time range available".into()); +/// `points` (sorted by time) cut to `t0..=t1`: an edge point outside the range is moved to +/// the boundary along its segment, so the edge segment keeps its true slope. +fn clip_to_range(points: &[[f64; 2]], t0: f64, t1: f64) -> Vec<[f64; 2]> { + let lerp = |a: [f64; 2], b: [f64; 2], t: f64| { + let k = if b[0] > a[0] { + (t - a[0]) / (b[0] - a[0]) + } else { + 0.0 }; - - let time_span = max_time - min_time; - if time_span <= 0.0 { - return Err("Invalid time range".into()); - } - - // Chart dimensions in mm (A4 landscape with margins) - let margin: f64 = 20.0; - let chart_left: f64 = margin; - let chart_right: f64 = 297.0 - margin; - let chart_bottom: f64 = margin + 20.0; // Leave room for time labels - let chart_top: f64 = 210.0 - margin - 30.0; // Leave room for title - - let chart_width: f64 = chart_right - chart_left; - let chart_height: f64 = chart_top - chart_bottom; - - // Draw title - push_text( - &mut ops, - "UltraLog Chart Export", - 16.0, - Mm(margin as f32), - Mm(200.0), - &font_bold, - ); - - // Draw subtitle with file info - if let Some(file) = self.files.first() { - let subtitle = format!( - "{} | {} channels selected | Time: {:.1}s - {:.1}s", - file.name, - self.get_selected_channels().len(), - min_time, - max_time - ); - push_text( - &mut ops, - &subtitle, - 10.0, - Mm(margin as f32), - Mm(192.0), - &font_regular, - ); - } - - // Draw chart border - let border_color = Color::Rgb(Rgb::new(0.3, 0.3, 0.3, None)); - ops.push(Op::SetOutlineColor { col: border_color }); - ops.push(Op::SetOutlineThickness { pt: Pt(0.5) }); - - push_closed_line( - &mut ops, - &[ - (chart_left as f32, chart_bottom as f32), - (chart_right as f32, chart_bottom as f32), - (chart_right as f32, chart_top as f32), - (chart_left as f32, chart_top as f32), - ], - ); - - // Draw each channel - for selected in self.get_selected_channels() { - let color_rgb = self.get_channel_color(selected.color_index); - let line_color = Color::Rgb(Rgb::new( - color_rgb[0] as f32 / 255.0, - color_rgb[1] as f32 / 255.0, - color_rgb[2] as f32 / 255.0, - None, - )); - - ops.push(Op::SetOutlineColor { col: line_color }); - ops.push(Op::SetOutlineThickness { pt: Pt(0.75) }); - - // Get channel data - if selected.file_index >= self.files.len() { - continue; - } - let file = &self.files[selected.file_index]; - let times = file.log.get_times_as_f64(); - let data = self.get_channel_data(selected.file_index, selected.channel_index); - - if data.is_empty() { - continue; - } - - // Find min/max for normalization - let mut data_min = f64::INFINITY; - let mut data_max = f64::NEG_INFINITY; - for &val in &data { - data_min = data_min.min(val); - data_max = data_max.max(val); - } - - let data_range = if (data_max - data_min).abs() < 0.0001 { - 1.0 - } else { - data_max - data_min - }; - - // Build line points (downsample for PDF) - let mut points: Vec = Vec::new(); - let step = (times.len() / 500).max(1); // Max ~500 points per channel - - for (i, (&time, &value)) in times.iter().zip(data.iter()).enumerate() { - if i % step != 0 { - continue; - } - - if time < min_time || time > max_time { - continue; - } - - let x_ratio = (time - min_time) / time_span; - let y_ratio = (value - data_min) / data_range; - - let x = chart_left + x_ratio * chart_width; - let y = chart_bottom + y_ratio * chart_height; - - points.push(LinePoint { - p: Point::new(Mm(x as f32), Mm(y as f32)), - bezier: false, - }); - } - - if points.len() >= 2 { - let line = Line { - points, - is_closed: false, - }; - ops.push(Op::DrawLine { line }); + [t, a[1] + (b[1] - a[1]) * k] + }; + let mut out = Vec::with_capacity(points.len()); + for (i, &p) in points.iter().enumerate() { + if (t0..=t1).contains(&p[0]) { + if p[0] > t0 + && out.is_empty() + && let Some(&prev) = i.checked_sub(1).and_then(|j| points.get(j)) + { + out.push(lerp(prev, p, t0)); + } + out.push(p); + } else if p[0] > t1 { + if let Some(&prev) = i.checked_sub(1).and_then(|j| points.get(j)) + && prev[0] <= t1 + && prev[0] >= t0 + { + out.push(lerp(prev, p, t1)); } + break; } + } + out +} - // Draw legend - let legend_y = chart_bottom - 12.0; - let mut legend_x = chart_left; - - for selected in self.get_selected_channels() { - let color_rgb = self.get_channel_color(selected.color_index); - let text_color = Color::Rgb(Rgb::new( - color_rgb[0] as f32 / 255.0, - color_rgb[1] as f32 / 255.0, - color_rgb[2] as f32 / 255.0, - None, - )); - - // Get display name (normalized or original based on setting) - let channel_name = selected.channel.name(); - let display_name = if self.field_normalization { - normalize_channel_name_with_custom(&channel_name, Some(&self.custom_normalizations)) - } else { - channel_name - }; - - ops.push(Op::SetFillColor { col: text_color }); - push_text( - &mut ops, - &display_name, - 8.0, - Mm(legend_x as f32), - Mm(legend_y as f32), - &font_regular, - ); +fn nonzero(range: f64) -> f64 { + if range.abs() < f64::EPSILON { + 1.0 + } else { + range + } +} - legend_x += 40.0; - if legend_x > chart_right - 40.0 { - break; // Don't overflow - } +impl UltraLogApp { + /// The display name of a channel (normalized when field normalization is on). + fn export_channel_name(&self, file_idx: usize, channel_idx: usize) -> String { + let name = self.get_channel_name(file_idx, channel_idx); + if self.field_normalization { + normalize_channel_name_with_custom(&name, Some(&self.custom_normalizations)) + } else { + name } - - // Build page and save PDF - let page = PdfPage::new(Mm(297.0), Mm(210.0), ops); - let mut doc = PdfDocument::new("UltraLog Chart Export"); - doc.with_pages(vec![page]); - let mut warnings = Vec::new(); - let pdf_bytes = doc.save(&PdfSaveOptions::default(), &mut warnings); - std::fs::write(path, pdf_bytes)?; - - Ok(()) } - /// Export the current histogram view as PNG - pub fn export_histogram_png(&mut self) { - // Show save dialog - let Some(path) = rfd::FileDialog::new() - .add_filter("PNG Image", &["png"]) - .set_file_name("ultralog_histogram.png") - .save_file() - else { - return; - }; + fn active_file_index(&self) -> Option { + self.active_tab + .and_then(|i| self.tabs.get(i)) + .map(|tab| tab.file_index) + .filter(|&i| i < self.files.len()) + } - match self.render_histogram_to_png(&path) { - Ok(_) => { - analytics::track_export("histogram_png"); - self.show_toast_success(&t!("toast.histogram_exported_png")); - } - Err(e) => self.show_toast_error(&t!("toast.export_failed", error = e.to_string())), + /// The file-name stem and analytics id for the active tool's export. + fn export_slug(&self) -> String { + match self.active_tool { + ActiveTool::LogViewer => "chart".into(), + ActiveTool::ScatterPlot => "scatter_plot".into(), + ActiveTool::Histogram => "histogram".into(), + _ => self + .active_tool + .generator_kind() + .map_or("table".into(), |k| k.id().to_string()), } } - /// Export the current scatter plot view as PNG - pub fn export_scatter_plot_png(&mut self) { - // Show save dialog - let Some(path) = rfd::FileDialog::new() - .add_filter("PNG Image", &["png"]) - .set_file_name("ultralog_scatter_plot.png") - .save_file() - else { - return; - }; - - match self.render_scatter_plot_to_png(&path) { - Ok(_) => { - analytics::track_export("scatter_plot_png"); - self.show_toast_success(&t!("toast.scatter_exported_png")); + /// Build the ExportSheet for the active tool. + pub fn export_sheet(&self) -> ExportResult { + match self.active_tool { + ActiveTool::LogViewer => self.chart_sheet(), + ActiveTool::ScatterPlot => self.scatter_sheet(), + ActiveTool::Histogram => self.histogram_sheet(), + _ => { + let kind = self + .active_tool + .generator_kind() + .ok_or("Nothing to export")?; + let acc = self + .table_generator + .table(kind) + .ok_or_else(|| t!("table_gen.no_events").to_string())?; + crate::ui::table_export::table_sheet( + acc, + self.table_generator.measure_index(kind), + &self.table_generator.export, + self.active_tool.name(), + ) } - Err(e) => self.show_toast_error(&t!("toast.export_failed", error = e.to_string())), } } - /// Export the current scatter plot view as PDF - pub fn export_scatter_plot_pdf(&mut self) { - // Show save dialog + /// Export the active tool to a file the user picks. + pub fn export_to_file(&mut self, format: ExportFormat, theme: ExportTheme) { + let sheet = match self.export_sheet() { + Ok(sheet) => sheet, + Err(e) => { + self.show_toast_error(&t!("toast.export_failed", error = e.to_string())); + return; + } + }; + let slug = self.export_slug(); + let ext = format.extension(); + let filter = match format { + ExportFormat::Pdf => "PDF Document", + ExportFormat::Png => "PNG Image", + }; + let success = match (self.active_tool, format) { + (ActiveTool::LogViewer, ExportFormat::Pdf) => t!("toast.export_pdf_success"), + (ActiveTool::LogViewer, ExportFormat::Png) => t!("toast.export_png_success"), + (ActiveTool::ScatterPlot, ExportFormat::Pdf) => t!("toast.scatter_exported_pdf"), + (ActiveTool::ScatterPlot, ExportFormat::Png) => t!("toast.scatter_exported_png"), + (ActiveTool::Histogram, ExportFormat::Pdf) => t!("toast.histogram_exported_pdf"), + (ActiveTool::Histogram, ExportFormat::Png) => t!("toast.histogram_exported_png"), + (_, ExportFormat::Pdf) => t!("toast.table_exported_pdf"), + (_, ExportFormat::Png) => t!("toast.table_exported_png"), + }; let Some(path) = rfd::FileDialog::new() - .add_filter("PDF Document", &["pdf"]) - .set_file_name("ultralog_scatter_plot.pdf") + .add_filter(filter, &[ext]) + .set_file_name(format!("ultralog_{slug}.{ext}")) .save_file() else { return; }; - - match self.render_scatter_plot_to_pdf(&path) { - Ok(_) => { - analytics::track_export("scatter_plot_pdf"); - self.show_toast_success(&t!("toast.scatter_exported_pdf")); + match write_sheet(&sheet, format, theme, &path) { + Ok(()) => { + // The chart keeps its pre-3.0 event names ("png" / "pdf"). + let event = if slug == "chart" { + ext.to_string() + } else { + format!("{slug}_{ext}") + }; + analytics::track_export(&event); + self.show_toast_success(&success); } Err(e) => self.show_toast_error(&t!("toast.export_failed", error = e.to_string())), } } - /// Export the current histogram view as PDF - pub fn export_histogram_pdf(&mut self) { - // Show save dialog - let Some(path) = rfd::FileDialog::new() - .add_filter("PDF Document", &["pdf"]) - .set_file_name("ultralog_histogram.pdf") - .save_file() - else { - return; - }; - - match self.render_histogram_to_pdf(&path) { - Ok(_) => { - analytics::track_export("histogram_pdf"); - self.show_toast_success(&t!("toast.histogram_exported_pdf")); + /// Copy the chart or table alone (no header or footer) as an image for pasting. + pub fn copy_export_image(&mut self, theme: ExportTheme) { + let result = self.export_sheet().and_then(|sheet| { + let img = export_sheet::render_png_body(&sheet, theme); + let data = arboard::ImageData { + width: img.width() as usize, + height: img.height() as usize, + bytes: std::borrow::Cow::Owned(img.into_raw()), + }; + arboard::Clipboard::new()?.set_image(data)?; + Ok(()) + }); + match result { + Ok(()) => { + analytics::track_export(&format!("{}_clipboard", self.export_slug())); + self.show_toast_success(&t!("toast.export_copied")); } Err(e) => self.show_toast_error(&t!("toast.export_failed", error = e.to_string())), } } - /// Render histogram to PDF file - fn render_histogram_to_pdf( - &self, - path: &std::path::Path, - ) -> Result<(), Box> { - // Get tab and file data - let tab_idx = self.active_tab.ok_or("No active tab")?; - let config = &self.tabs[tab_idx].histogram_state.config; - let file_idx = self.tabs[tab_idx].file_index; - - if file_idx >= self.files.len() { - return Err("Invalid file index".into()); - } - - let file = &self.files[file_idx]; - let mode = config.mode; - let (grid_cols, grid_rows) = config.effective_grid_size(); - - let x_idx = config.x_channel.ok_or("X axis not selected")?; - let y_idx = config.y_channel.ok_or("Y axis not selected")?; - let z_idx = if mode == HistogramMode::AverageZ { - config - .z_channel - .ok_or("Z axis not selected for Average mode")? - } else { - 0 // unused - }; - - // Get channel data (handles both regular and computed channels) - let x_data = self.get_channel_data(file_idx, x_idx); - let y_data = self.get_channel_data(file_idx, y_idx); - let z_data = if mode == HistogramMode::AverageZ { - Some(self.get_channel_data(file_idx, z_idx)) - } else { - None - }; - - if x_data.is_empty() || y_data.is_empty() { - return Err("No data available".into()); - } - - // Calculate data bounds - let x_min = x_data.iter().cloned().fold(f64::INFINITY, f64::min); - let x_max = x_data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let y_min = y_data.iter().cloned().fold(f64::INFINITY, f64::min); - let y_max = y_data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - - let x_range = if (x_max - x_min).abs() < f64::EPSILON { - 1.0 - } else { - x_max - x_min - }; - let y_range = if (y_max - y_min).abs() < f64::EPSILON { - 1.0 - } else { - y_max - y_min - }; - - // Build histogram grid - let mut hit_counts = vec![vec![0u32; grid_cols]; grid_rows]; - let mut z_sums = vec![vec![0.0f64; grid_cols]; grid_rows]; - - for i in 0..x_data.len() { - let x_bin = (((x_data[i] - x_min) / x_range) * (grid_cols - 1) as f64).round() as usize; - let y_bin = (((y_data[i] - y_min) / y_range) * (grid_rows - 1) as f64).round() as usize; - let x_bin = x_bin.min(grid_cols - 1); - let y_bin = y_bin.min(grid_rows - 1); - - hit_counts[y_bin][x_bin] += 1; - if let Some(ref z) = z_data { - z_sums[y_bin][x_bin] += z[i]; - } - } - - // Calculate cell values and find min/max for color scaling - let mut cell_values = vec![vec![None::; grid_cols]; grid_rows]; - let mut min_value: f64 = f64::INFINITY; - let mut max_value: f64 = f64::NEG_INFINITY; - - for y_bin in 0..grid_rows { - for x_bin in 0..grid_cols { - let hits = hit_counts[y_bin][x_bin]; - if hits > 0 { - let value = match mode { - HistogramMode::HitCount => hits as f64, - HistogramMode::AverageZ => z_sums[y_bin][x_bin] / hits as f64, - }; - cell_values[y_bin][x_bin] = Some(value); - min_value = min_value.min(value); - max_value = max_value.max(value); - } - } + /// The Log Viewer chart over the visible time range. + fn chart_sheet(&self) -> ExportResult { + let channels = self.get_selected_channels(); + if channels.is_empty() { + return Err("No channels selected".into()); + } + // The tab range: log A joined with the shifted log B, as on screen. + let (full0, full1) = self.get_time_range().ok_or("No time range available")?; + let tab = self.active_tab.map(|i| &self.tabs[i]); + let (t0, t1) = self + .chart_last_x_bounds + .get(&0) + .map(|&(a, b)| (a.max(full0), b.min(full1))) + .filter(|(a, b)| b > a) + .unwrap_or((full0, full1)); + if t1 <= t0 { + return Err("Invalid time range".into()); } - let value_range = if (max_value - min_value).abs() < f64::EPSILON { - 1.0 - } else { - max_value - min_value - }; - - // Get channel names (handles both regular and computed channels) - let x_name = self.get_channel_name(file_idx, x_idx); - let y_name = self.get_channel_name(file_idx, y_idx); - let z_name = if mode == HistogramMode::AverageZ { - self.get_channel_name(file_idx, z_idx) - } else { - "Hit Count".to_string() - }; - - let font_bold = PdfFontHandle::Builtin(BuiltinFont::HelveticaBold); - let font_regular = PdfFontHandle::Builtin(BuiltinFont::Helvetica); - - let mut ops: Vec = Vec::new(); - - // Chart dimensions in mm (A4 landscape with margins) - let margin: f64 = 20.0; - let axis_margin: f64 = 25.0; - let chart_left: f64 = margin + axis_margin; - let chart_right: f64 = 250.0; // Leave room for legend - let chart_bottom: f64 = margin + axis_margin; - let chart_top: f64 = 210.0 - margin - 30.0; - - let chart_width: f64 = chart_right - chart_left; - let chart_height: f64 = chart_top - chart_bottom; - - let cell_width = chart_width / grid_cols as f64; - let cell_height = chart_height / grid_rows as f64; - - // Draw title - push_text( - &mut ops, - "UltraLog Histogram Export", - 16.0, - Mm(margin as f32), - Mm(200.0), - &font_bold, - ); - - // Draw subtitle - let subtitle = format!( - "{} | Grid: {}x{} | Mode: {}", - file.name, - grid_cols, - grid_rows, - if mode == HistogramMode::HitCount { - "Hit Count" - } else { - "Average Z" + let mut series = Vec::new(); + for selected in channels { + let Some(file) = self.files.get(selected.file_index) else { + continue; + }; + let times = file.log.get_times_as_f64(); + let data = self.get_channel_data_ref(selected.file_index, selected.channel_index); + if times.is_empty() || times.len() != data.len() { + continue; } - ); - push_text( - &mut ops, - &subtitle, - 10.0, - Mm(margin as f32), - Mm(192.0), - &font_regular, - ); - - // Draw axis labels - let axis_subtitle = format!("X: {} | Y: {} | Z: {}", x_name, y_name, z_name); - push_text( - &mut ops, - &axis_subtitle, - 9.0, - Mm(margin as f32), - Mm(186.0), - &font_regular, - ); - - // Draw histogram cells - for y_bin in 0..grid_rows { - for x_bin in 0..grid_cols { - let cell_x = chart_left + x_bin as f64 * cell_width; - let cell_y = chart_bottom + y_bin as f64 * cell_height; - - if let Some(value) = cell_values[y_bin][x_bin] { - // Calculate color - let normalized = if mode == HistogramMode::HitCount && max_value > 1.0 { - (value.ln() / max_value.ln()).clamp(0.0, 1.0) - } else { - ((value - min_value) / value_range).clamp(0.0, 1.0) - }; - let color = Self::get_pdf_heat_color(normalized); - - ops.push(Op::SetFillColor { col: color }); - - push_filled_rect( - &mut ops, - cell_x as f32, - cell_y as f32, - cell_width as f32, - cell_height as f32, - ); - - // Draw cell value text (only for smaller grids) - if grid_cols.max(grid_rows) <= 32 { - let text = if mode == HistogramMode::HitCount { - format!("{}", hit_counts[y_bin][x_bin]) - } else { - format!("{:.1}", value) - }; - - // Calculate text color based on brightness - let brightness = normalized; - let text_color = if brightness > 0.5 { - Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)) // Black - } else { - Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)) // White - }; - - ops.push(Op::SetFillColor { col: text_color }); - let font_size = if grid_cols.max(grid_rows) <= 16 { - 6.0 - } else { - 4.0 - }; - push_text( - &mut ops, - &text, - font_size, - Mm((cell_x + cell_width / 2.0 - 2.0) as f32), - Mm((cell_y + cell_height / 2.0 - 1.0) as f32), - &font_regular, - ); - } - } + // Normalized to the whole channel, like the chart; a compare pair shares one range + // (R1). + let Some(mut bounds) = finite_bounds(data) else { + continue; + }; + if let Some(partner) = tab.and_then(|t| t.partner_of(selected)) + && let Some((plo, phi)) = finite_bounds( + self.get_channel_data_ref(partner.file_index, partner.channel_index), + ) + { + bounds = (bounds.0.min(plo), bounds.1.max(phi)); + } + let (lo, hi) = bounds; + // Log B is sliced in its own time, then moved onto the tab axis. + let offset = tab.map_or(0.0, |t| t.time_offset(selected.file_index)); + let (s0, s1) = (t0 - offset, t1 - offset); + let a = times.partition_point(|&t| t < s0).saturating_sub(1); + let b = (times.partition_point(|&t| t <= s1) + 1).min(times.len()); + if a >= b { + continue; } + let raw: Vec<[f64; 2]> = + crate::app::UltraLogApp::downsample_lttb(×[a..b], &data[a..b], CHART_POINTS) + .into_iter() + .filter(|p| p[1].is_finite()) + .map(|[t, v]| [t + offset, v]) + .collect(); + let points = clip_to_range(&raw, t0, t1) + .into_iter() + .map(|[t, v]| [t, normalize_like_chart(v, lo, hi)]) + .collect(); + let unit = self + .unit_preferences + .convert_value(0.0, selected.channel.unit()) + .1 + .to_string(); + series.push(SeriesModel { + name: self.export_channel_name(selected.file_index, selected.channel_index), + unit, + color_index: selected.color_index, + log: tab + .filter(|t| t.compare.is_some()) + .map(|t| t.log_role(selected)), + points, + }); + } + if series.is_empty() { + return Err("No data available".into()); } - // Draw grid lines - let grid_color = Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)); - ops.push(Op::SetOutlineColor { col: grid_color }); - ops.push(Op::SetOutlineThickness { pt: Pt(0.25) }); - - // Vertical grid lines (X axis divisions) - for i in 0..=grid_cols { - let x = chart_left + i as f64 * cell_width; - push_open_line( - &mut ops, - &[ - (x as f32, chart_bottom as f32), - (x as f32, chart_top as f32), - ], - ); - } - - // Horizontal grid lines (Y axis divisions) - for i in 0..=grid_rows { - let y = chart_bottom + i as f64 * cell_height; - push_open_line( - &mut ops, - &[ - (chart_left as f32, y as f32), - (chart_right as f32, y as f32), - ], - ); - } - - // Draw axis value labels - let label_color = Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)); - ops.push(Op::SetFillColor { col: label_color }); - - // Y axis labels - for i in 0..=4 { - let t = i as f64 / 4.0; - let value = y_min + t * y_range; - let y_pos = chart_bottom + t * chart_height; - push_text( - &mut ops, - &format!("{:.0}", value), - 7.0, - Mm((chart_left - 12.0) as f32), - Mm((y_pos - 1.0) as f32), - &font_regular, - ); - } - - // X axis labels - for i in 0..=4 { - let t = i as f64 / 4.0; - let value = x_min + t * x_range; - let x_pos = chart_left + t * chart_width; - push_text( - &mut ops, - &format!("{:.0}", value), - 7.0, - Mm((x_pos - 4.0) as f32), - Mm((chart_bottom - 8.0) as f32), - &font_regular, - ); - } - - // Draw legend (color scale) - let legend_left: f64 = 260.0; - let legend_width: f64 = 15.0; - let legend_bottom: f64 = chart_bottom; - let legend_height: f64 = chart_height; - - // Draw legend title - ops.push(Op::SetFillColor { - col: Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)), - }); - let legend_title = if mode == HistogramMode::HitCount { - "Hits" - } else { - "Value" - }; - push_text( - &mut ops, - legend_title, - 9.0, - Mm(legend_left as f32), - Mm((legend_bottom + legend_height + 5.0) as f32), - &font_bold, - ); - - // Draw color gradient bar - let gradient_steps = 30; - let step_height = legend_height / gradient_steps as f64; - - for i in 0..gradient_steps { - let t = i as f64 / gradient_steps as f64; - let color = Self::get_pdf_heat_color(t); - ops.push(Op::SetFillColor { col: color }); - - let y = legend_bottom + i as f64 * step_height; - push_filled_rect( - &mut ops, - legend_left as f32, - y as f32, - legend_width as f32, - (step_height + 0.5) as f32, - ); - } - - // Draw legend min/max labels - ops.push(Op::SetFillColor { - col: Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)), - }); - let min_label = if mode == HistogramMode::HitCount { - "0".to_string() - } else { - format!("{:.1}", min_value) - }; - let max_label = if mode == HistogramMode::HitCount { - format!("{:.0}", max_value) - } else { - format!("{:.1}", max_value) - }; - - push_text( - &mut ops, - &min_label, - 7.0, - Mm((legend_left + legend_width + 3.0) as f32), - Mm(legend_bottom as f32), - &font_regular, - ); - push_text( - &mut ops, - &max_label, - 7.0, - Mm((legend_left + legend_width + 3.0) as f32), - Mm((legend_bottom + legend_height - 3.0) as f32), - &font_regular, - ); - - // Draw statistics - let stats_y = legend_bottom - 15.0; - push_text( - &mut ops, - &format!("Total Points: {}", x_data.len()), - 8.0, - Mm(legend_left as f32), - Mm(stats_y as f32), - &font_regular, - ); - - // Build page and save PDF - let page = PdfPage::new(Mm(297.0), Mm(210.0), ops); - let mut doc = PdfDocument::new("UltraLog Histogram Export"); - doc.with_pages(vec![page]); - let mut warnings = Vec::new(); - let pdf_bytes = doc.save(&PdfSaveOptions::default(), &mut warnings); - std::fs::write(path, pdf_bytes)?; - - Ok(()) - } - - /// Get a PDF color from the heat map gradient based on normalized value (0-1) - fn get_pdf_heat_color(normalized: f64) -> Color { - const HEAT_COLORS: &[[u8; 3]] = &[ - [0, 0, 80], // Dark blue (0.0) - [0, 0, 180], // Blue - [0, 100, 255], // Light blue - [0, 200, 255], // Cyan - [0, 255, 200], // Cyan-green - [0, 255, 100], // Green - [100, 255, 0], // Yellow-green - [200, 255, 0], // Yellow - [255, 200, 0], // Orange - [255, 100, 0], // Red-orange - [255, 0, 0], // Red (1.0) + let file = self + .active_file_index() + .or(channels.first().map(|c| c.file_index)) + .and_then(|i| self.files.get(i)) + .ok_or("No file loaded")?; + let mut meta = vec![ + format!( + "{} \u{2013} {}", + Self::format_time(t0), + Self::format_time(t1) + ), + format!( + "{} records \u{00B7} {} ch", + group_thousands(file.log.times.len()), + file.log.channels.len() + ), ]; - - let t = normalized.clamp(0.0, 1.0); - let scaled = t * (HEAT_COLORS.len() - 1) as f64; - let idx = scaled.floor() as usize; - let frac = scaled - idx as f64; - - if idx >= HEAT_COLORS.len() - 1 { - let c = HEAT_COLORS[HEAT_COLORS.len() - 1]; - return Color::Rgb(Rgb::new( - c[0] as f32 / 255.0, - c[1] as f32 / 255.0, - c[2] as f32 / 255.0, - None, + // Both files and log B's offset, so the sheet says what is compared with what. + if let Some(compare) = tab.and_then(|t| t.compare.as_ref()) + && let Some(b) = self.files.get(compare.file_index) + { + meta.push(format!( + "A {} \u{00B7} B {} \u{00B7} B offset {:+.2} s", + file.name, b.name, compare.offset_s )); } - - let c1 = HEAT_COLORS[idx]; - let c2 = HEAT_COLORS[idx + 1]; - - let r = (c1[0] as f64 + (c2[0] as f64 - c1[0] as f64) * frac) / 255.0; - let g = (c1[1] as f64 + (c2[1] as f64 - c1[1] as f64) * frac) / 255.0; - let b = (c1[2] as f64 + (c2[2] as f64 - c1[2] as f64) * frac) / 255.0; - - Color::Rgb(Rgb::new(r as f32, g as f32, b as f32, None)) + Ok(Sheet { + title: file.name.clone(), + meta, + body: Body::Chart(ChartModel { series, t0, t1 }), + }) } - /// Get a PNG color from the heat map gradient based on normalized value (0-1) - fn get_png_heat_color(normalized: f64) -> Rgba { - const HEAT_COLORS: &[[u8; 3]] = &[ - [0, 0, 80], // Dark blue (0.0) - [0, 0, 180], // Blue - [0, 100, 255], // Light blue - [0, 200, 255], // Cyan - [0, 255, 200], // Cyan-green - [0, 255, 100], // Green - [100, 255, 0], // Yellow-green - [200, 255, 0], // Yellow - [255, 200, 0], // Orange - [255, 100, 0], // Red-orange - [255, 0, 0], // Red (1.0) - ]; - - let t = normalized.clamp(0.0, 1.0); - let scaled = t * (HEAT_COLORS.len() - 1) as f64; - let idx = scaled.floor() as usize; - let frac = scaled - idx as f64; - - if idx >= HEAT_COLORS.len() - 1 { - let c = HEAT_COLORS[HEAT_COLORS.len() - 1]; - return Rgba([c[0], c[1], c[2], 255]); - } - - let c1 = HEAT_COLORS[idx]; - let c2 = HEAT_COLORS[idx + 1]; - - let r = (c1[0] as f64 + (c2[0] as f64 - c1[0] as f64) * frac) as u8; - let g = (c1[1] as f64 + (c2[1] as f64 - c1[1] as f64) * frac) as u8; - let b = (c1[2] as f64 + (c2[2] as f64 - c1[2] as f64) * frac) as u8; - - Rgba([r, g, b, 255]) - } - - /// Render histogram to PNG file - fn render_histogram_to_png( - &self, - path: &std::path::Path, - ) -> Result<(), Box> { - // Get tab and file data + /// The histogram as the Histogram tool shows it (custom ranges, sample filters, min hits). + fn histogram_sheet(&self) -> ExportResult { let tab_idx = self.active_tab.ok_or("No active tab")?; let config = &self.tabs[tab_idx].histogram_state.config; - let file_idx = self.tabs[tab_idx].file_index; - - if file_idx >= self.files.len() { - return Err("Invalid file index".into()); - } - + let file_idx = self.active_file_index().ok_or("Invalid file index")?; let mode = config.mode; - let (grid_cols, grid_rows) = config.effective_grid_size(); + let (cols, rows) = config.effective_grid_size(); let x_idx = config.x_channel.ok_or("X axis not selected")?; let y_idx = config.y_channel.ok_or("Y axis not selected")?; - let z_idx = if mode == HistogramMode::AverageZ { - config - .z_channel - .ok_or("Z axis not selected for Average mode")? - } else { - 0 // unused + let z_idx = match mode { + HistogramMode::AverageZ => Some( + config + .z_channel + .ok_or("Z axis not selected for Average mode")?, + ), + HistogramMode::HitCount => None, }; - - // Get channel data (handles both regular and computed channels) let x_data = self.get_channel_data(file_idx, x_idx); let y_data = self.get_channel_data(file_idx, y_idx); - let z_data = if mode == HistogramMode::AverageZ { - Some(self.get_channel_data(file_idx, z_idx)) - } else { - None - }; - - if x_data.is_empty() || y_data.is_empty() { + let z_data = z_idx.map(|z| self.get_channel_data(file_idx, z)); + if x_data.is_empty() || x_data.len() != y_data.len() { return Err("No data available".into()); } - // Calculate data bounds - let x_min = x_data.iter().cloned().fold(f64::INFINITY, f64::min); - let x_max = x_data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let y_min = y_data.iter().cloned().fold(f64::INFINITY, f64::min); - let y_max = y_data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - - let x_range = if (x_max - x_min).abs() < f64::EPSILON { - 1.0 - } else { - x_max - x_min - }; - let y_range = if (y_max - y_min).abs() < f64::EPSILON { - 1.0 - } else { - y_max - y_min + let bounds = |custom: Option<(f64, f64)>, data: &[f64]| match custom { + Some((lo, hi)) if hi > lo => (lo, hi), + _ => finite_bounds(data).unwrap_or((0.0, 1.0)), }; + let (x_min, x_max) = bounds(config.custom_x_range, &x_data); + let (y_min, y_max) = bounds(config.custom_y_range, &y_data); + let (x_range, y_range) = (nonzero(x_max - x_min), nonzero(y_max - y_min)); - // Build histogram grid - let mut hit_counts = vec![vec![0u32; grid_cols]; grid_rows]; - let mut z_sums = vec![vec![0.0f64; grid_cols]; grid_rows]; - - for i in 0..x_data.len() { - let x_bin = (((x_data[i] - x_min) / x_range) * (grid_cols - 1) as f64).round() as usize; - let y_bin = (((y_data[i] - y_min) / y_range) * (grid_rows - 1) as f64).round() as usize; - let x_bin = x_bin.min(grid_cols - 1); - let y_bin = y_bin.min(grid_rows - 1); - - hit_counts[y_bin][x_bin] += 1; - if let Some(ref z) = z_data { - z_sums[y_bin][x_bin] += z[i]; - } - } - - // Calculate cell values and find min/max for color scaling - let mut cell_values = vec![vec![None::; grid_cols]; grid_rows]; - let mut min_value: f64 = f64::INFINITY; - let mut max_value: f64 = f64::NEG_INFINITY; - - for y_bin in 0..grid_rows { - for x_bin in 0..grid_cols { - let hits = hit_counts[y_bin][x_bin]; - if hits > 0 { - let value = match mode { - HistogramMode::HitCount => hits as f64, - HistogramMode::AverageZ => z_sums[y_bin][x_bin] / hits as f64, - }; - cell_values[y_bin][x_bin] = Some(value); - min_value = min_value.min(value); - max_value = max_value.max(value); + let filters: Vec<_> = config + .sample_filters + .iter() + .filter(|f| f.enabled) + .map(|f| (f, self.get_channel_data(file_idx, f.channel_idx))) + .collect(); + + // Same binning as `render_histogram_grid` (`binning::uniform_bin`). + let mut hits = vec![vec![0u32; cols]; rows]; + let mut z_sums = vec![vec![0.0f64; cols]; rows]; + let mut samples = 0usize; + 'samples: for i in 0..x_data.len() { + for (filter, data) in &filters { + let Some(&v) = data.get(i) else { + continue 'samples; + }; + if filter.min_value.is_some_and(|m| v < m) + || filter.max_value.is_some_and(|m| v > m) + { + continue 'samples; } } - } - - let value_range = if (max_value - min_value).abs() < f64::EPSILON { - 1.0 - } else { - max_value - min_value - }; - - // Image dimensions - let width = 1920u32; - let height = 1080u32; - let margin = 80u32; - let legend_width = 100u32; - - let chart_left = margin; - let chart_right = width - margin - legend_width; - let chart_top = margin; - let chart_bottom = height - margin; - - let chart_width = chart_right - chart_left; - let chart_height = chart_bottom - chart_top; - let cell_width = chart_width as f64 / grid_cols as f64; - let cell_height = chart_height as f64 / grid_rows as f64; - - // Create image buffer - let mut imgbuf = RgbaImage::new(width, height); - - // Fill with dark background - for pixel in imgbuf.pixels_mut() { - *pixel = Rgba([30, 30, 30, 255]); - } - - // Draw histogram cells - #[allow(clippy::needless_range_loop)] - for y_bin in 0..grid_rows { - for x_bin in 0..grid_cols { - if let Some(value) = cell_values[y_bin][x_bin] { - // Calculate color - let normalized = if mode == HistogramMode::HitCount && max_value > 1.0 { - (value.ln() / max_value.ln()).clamp(0.0, 1.0) - } else { - ((value - min_value) / value_range).clamp(0.0, 1.0) - }; - let color = Self::get_png_heat_color(normalized); - - // Calculate cell position (Y inverted - higher values at top) - let cell_x = chart_left as f64 + x_bin as f64 * cell_width; - let cell_y = chart_bottom as f64 - (y_bin + 1) as f64 * cell_height; - - // Fill cell - for py in 0..(cell_height.ceil() as u32) { - for px in 0..(cell_width.ceil() as u32) { - let x = (cell_x as u32 + px).min(width - 1); - let y = (cell_y as u32 + py).min(height - 1); - imgbuf.put_pixel(x, y, color); - } - } - } + if config.custom_x_range.is_some() && (x_data[i] < x_min || x_data[i] > x_max) { + continue; } - } - - // Draw grid lines - let grid_color = Rgba([80, 80, 80, 255]); - - // Vertical grid lines (X axis divisions) - for i in 0..=grid_cols { - let x = chart_left + (i as f64 * cell_width) as u32; - for py in chart_top..chart_bottom { - if x < width { - imgbuf.put_pixel(x, py, grid_color); - } + if config.custom_y_range.is_some() && (y_data[i] < y_min || y_data[i] > y_max) { + continue; } - } - - // Horizontal grid lines (Y axis divisions) - for i in 0..=grid_rows { - let y = chart_top + (i as f64 * cell_height) as u32; - for px in chart_left..chart_right { - if y < height { - imgbuf.put_pixel(px, y, grid_color); - } + let xb = uniform_bin(x_data[i], x_min, x_range, cols); + let yb = uniform_bin(y_data[i], y_min, y_range, rows); + hits[yb][xb] += 1; + if let Some(z) = &z_data { + z_sums[yb][xb] += z[i]; } + samples += 1; } - // Draw color scale legend - let legend_left = width - margin - legend_width + 20; - let legend_bar_width = 20u32; - let legend_height = chart_height; - - for i in 0..legend_height { - let t = i as f64 / legend_height as f64; - let color = Self::get_png_heat_color(t); - - for px in 0..legend_bar_width { - let x = legend_left + px; - let y = chart_bottom - i; - if x < width && y < height { - imgbuf.put_pixel(x, y, color); + // Like `render_histogram_grid`: the colour range spans every cell with hits, and cells + // under the minimum-hits filter are blanked afterwards (they still widen the range). + let min_hits = config.min_hits_filter; + let mut values = vec![vec![None::; cols]; rows]; + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + for r in 0..rows { + for c in 0..cols { + let h = hits[r][c]; + if h == 0 { + continue; + } + let v = match mode { + HistogramMode::HitCount => h as f64, + HistogramMode::AverageZ => z_sums[r][c] / h as f64, + }; + lo = lo.min(v); + hi = hi.max(v); + if !(min_hits > 0 && h < min_hits) { + values[r][c] = Some(v); } } } + if !lo.is_finite() { + // No sample landed in the grid: export it empty, as the tool shows it. + (lo, hi) = (0.0, 1.0); + } + let value_range = nonzero(hi - lo); + let norm = values + .iter() + .map(|row| { + row.iter() + .map(|v| { + v.map(|v| { + let k = if mode == HistogramMode::HitCount && hi > 1.0 { + v.ln() / hi.ln() + } else { + (v - lo) / value_range + }; + k.clamp(0.0, 1.0) as f32 + }) + }) + .collect() + }) + .collect(); + let labels = values + .iter() + .enumerate() + .map(|(r, row)| { + row.iter() + .enumerate() + .map(|(c, v)| { + v.map(|v| match mode { + HistogramMode::HitCount => hits[r][c].to_string(), + HistogramMode::AverageZ => format!("{v:.1}"), + }) + }) + .collect() + }) + .collect(); + let (scale_title, scale_min, scale_max) = match mode { + HistogramMode::HitCount => ( + t!("histogram.scale_hits").to_string(), + "0".to_string(), + format!("{hi:.0}"), + ), + HistogramMode::AverageZ => ( + format!( + "{} \u{00B7} {}", + t!("histogram.scale_average"), + self.export_channel_name(file_idx, z_idx.unwrap_or(0)) + ), + format!("{lo:.1}"), + format!("{hi:.1}"), + ), + }; - // Save the image - imgbuf.save(path)?; - - Ok(()) + let file = &self.files[file_idx]; + Ok(Sheet { + title: file.name.clone(), + meta: vec![ + format!("{cols} \u{00D7} {rows}"), + format!("{} samples", group_thousands(samples)), + ], + body: Body::Grids(vec![GridModel { + caption: None, + x_title: self.export_channel_name(file_idx, x_idx), + y_title: self.export_channel_name(file_idx, y_idx), + x_ticks: even_ticks(x_min, x_range, 4), + y_ticks: even_ticks(y_min, y_range, 4), + norm, + labels, + counts: None, + kind: MapKind::Sequential, + scale_title, + scale_min, + scale_max, + rich_lean: false, + dense: false, + }]), + }) } - /// Render scatter plot to PNG file (exports both left and right plots) - fn render_scatter_plot_to_png( - &self, - path: &std::path::Path, - ) -> Result<(), Box> { + /// Both scatter plots as density maps, side by side. + fn scatter_sheet(&self) -> ExportResult { let tab_idx = self.active_tab.ok_or("No active tab")?; - - // Image dimensions (wider to fit two plots) - let width = 2560u32; - let height = 1080u32; - let margin = 60u32; - let gap = 40u32; - - let plot_width = (width - 2 * margin - gap) / 2; - let plot_height = height - 2 * margin; - - // Create image buffer - let mut imgbuf = RgbaImage::new(width, height); - - // Fill with dark background - for pixel in imgbuf.pixels_mut() { - *pixel = Rgba([30, 30, 30, 255]); - } - - // Render left plot - let left_config = &self.tabs[tab_idx].scatter_plot_state.left; - let left_rect = (margin, margin, plot_width, plot_height); - self.render_scatter_plot_to_image(&mut imgbuf, left_config, left_rect, tab_idx)?; - - // Render right plot - let right_config = &self.tabs[tab_idx].scatter_plot_state.right; - let right_rect = (margin + plot_width + gap, margin, plot_width, plot_height); - self.render_scatter_plot_to_image(&mut imgbuf, right_config, right_rect, tab_idx)?; - - // Save the image - imgbuf.save(path)?; - - Ok(()) + let state = &self.tabs[tab_idx].scatter_plot_state; + let fallback = self.tabs[tab_idx].file_index; + let grids: Vec = [&state.left, &state.right] + .into_iter() + .filter_map(|config| self.scatter_grid(config, fallback)) + .collect(); + if grids.is_empty() { + return Err("Select X and Y channels to export a scatter plot".into()); + } + let file = self + .files + .get(state.left.file_index.unwrap_or(fallback)) + .ok_or("Invalid file index")?; + Ok(Sheet { + title: file.name.clone(), + meta: vec![format!("{} records", group_thousands(file.log.times.len()))], + body: Body::Grids(grids), + }) } - /// Helper to render a single scatter plot to an image region - fn render_scatter_plot_to_image( - &self, - imgbuf: &mut RgbaImage, - config: &crate::state::ScatterPlotConfig, - rect: (u32, u32, u32, u32), - tab_idx: usize, - ) -> Result<(), Box> { - let (left, top, width, height) = rect; - let right = left + width; - let bottom = top + height; - - let file_idx = config.file_index.unwrap_or(self.tabs[tab_idx].file_index); - - // Check if we have valid axis selections - let (x_idx, y_idx) = match (config.x_channel, config.y_channel) { - (Some(x), Some(y)) => (x, y), - _ => { - // Draw placeholder - let placeholder_color = Rgba([80, 80, 80, 255]); - for y in top..bottom { - for x in left..right { - imgbuf.put_pixel(x, y, placeholder_color); - } - } - return Ok(()); - } - }; - + fn scatter_grid(&self, config: &ScatterPlotConfig, fallback: usize) -> Option { + let file_idx = config.file_index.unwrap_or(fallback); + let (x_idx, y_idx) = (config.x_channel?, config.y_channel?); if file_idx >= self.files.len() { - return Err("Invalid file index".into()); + return None; } - - let x_data = self.get_channel_data(file_idx, x_idx); - let y_data = self.get_channel_data(file_idx, y_idx); - - if x_data.is_empty() || y_data.is_empty() || x_data.len() != y_data.len() { - return Err("No data available".into()); + let x = self.get_channel_data(file_idx, x_idx); + let y = self.get_channel_data(file_idx, y_idx); + if x.is_empty() || x.len() != y.len() { + return None; } - - // Calculate data bounds - let x_min = x_data.iter().cloned().fold(f64::INFINITY, f64::min); - let x_max = x_data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let y_min = y_data.iter().cloned().fold(f64::INFINITY, f64::min); - let y_max = y_data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - - let x_range = if (x_max - x_min).abs() < f64::EPSILON { - 1.0 - } else { - x_max - x_min - }; - let y_range = if (y_max - y_min).abs() < f64::EPSILON { - 1.0 - } else { - y_max - y_min - }; - - // Build 2D histogram (512 bins like the UI) - const HEATMAP_BINS: usize = 512; - let mut histogram = vec![vec![0u32; HEATMAP_BINS]; HEATMAP_BINS]; - let mut max_hits: u32 = 0; - - for (&x, &y) in x_data.iter().zip(y_data.iter()) { - let x_bin = (((x - x_min) / x_range) * (HEATMAP_BINS - 1) as f64).round() as usize; - let y_bin = (((y - y_min) / y_range) * (HEATMAP_BINS - 1) as f64).round() as usize; - - let x_bin = x_bin.min(HEATMAP_BINS - 1); - let y_bin = y_bin.min(HEATMAP_BINS - 1); - - histogram[y_bin][x_bin] += 1; - max_hits = max_hits.max(histogram[y_bin][x_bin]); - } - - // Fill background with black - let bg_color = Rgba([0, 0, 0, 255]); - for y in top..bottom { - for x in left..right { - imgbuf.put_pixel(x, y, bg_color); + let (x_min, x_max) = finite_bounds(&x)?; + let (y_min, y_max) = finite_bounds(&y)?; + let (x_range, y_range) = (nonzero(x_max - x_min), nonzero(y_max - y_min)); + let n = SCATTER_BINS; + let mut hits = vec![vec![0u32; n]; n]; + for (&xv, &yv) in x.iter().zip(&y) { + if xv.is_finite() && yv.is_finite() { + hits[uniform_bin(yv, y_min, y_range, n)][uniform_bin(xv, x_min, x_range, n)] += 1; } } - - let cell_width = width as f64 / HEATMAP_BINS as f64; - let cell_height = height as f64 / HEATMAP_BINS as f64; - - // Draw heatmap cells - #[allow(clippy::needless_range_loop)] - for y_bin in 0..HEATMAP_BINS { - for x_bin in 0..HEATMAP_BINS { - let hits = histogram[y_bin][x_bin]; - if hits > 0 { - // Normalize using log scale - let normalized = if max_hits > 1 { - (hits as f64).ln() / (max_hits as f64).ln() - } else { - 1.0 - }; - let color = Self::get_png_heat_color(normalized); - - // Calculate cell position (Y inverted) - let cell_x = left as f64 + x_bin as f64 * cell_width; - let cell_y = bottom as f64 - (y_bin + 1) as f64 * cell_height; - - // Fill cell - for py in 0..(cell_height.ceil() as u32 + 1) { - for px in 0..(cell_width.ceil() as u32 + 1) { - let x = (cell_x as u32 + px).min(right - 1); - let y = (cell_y as u32 + py).min(bottom - 1); - if x >= left && y >= top { - imgbuf.put_pixel(x, y, color); + let max = hits.iter().flatten().copied().max().unwrap_or(0); + let norm = hits + .iter() + .map(|row| { + row.iter() + .map(|&h| { + (h > 0).then(|| { + if max > 1 { + ((h as f64).ln() / (max as f64).ln()) as f32 + } else { + 1.0 } - } - } - } - } - } - - Ok(()) + }) + }) + .collect() + }) + .collect(); + let x_title = self.export_channel_name(file_idx, x_idx); + let y_title = self.export_channel_name(file_idx, y_idx); + Some(GridModel { + caption: Some(format!("{y_title} vs {x_title}")), + x_title, + y_title, + x_ticks: even_ticks(x_min, x_range, 4), + y_ticks: even_ticks(y_min, y_range, 4), + norm, + labels: Vec::new(), + counts: None, + kind: MapKind::Sequential, + scale_title: t!("histogram.scale_hits").to_string(), + scale_min: "1".into(), + scale_max: max.to_string(), + rich_lean: false, + dense: true, + }) } +} - /// Render scatter plot to PDF file - fn render_scatter_plot_to_pdf( - &self, - path: &std::path::Path, - ) -> Result<(), Box> { - let tab_idx = self.active_tab.ok_or("No active tab")?; - - let font_bold = PdfFontHandle::Builtin(BuiltinFont::HelveticaBold); - let font_regular = PdfFontHandle::Builtin(BuiltinFont::Helvetica); - - let mut ops: Vec = Vec::new(); - - // Draw title - push_text( - &mut ops, - "UltraLog Scatter Plot Export", - 16.0, - Mm(20.0), - Mm(200.0), - &font_bold, - ); - - // Subtitle with file info - if let Some(file_idx) = self.selected_file - && file_idx < self.files.len() - { - let file = &self.files[file_idx]; - push_text( - &mut ops, - &file.name, - 10.0, - Mm(20.0), - Mm(192.0), - &font_regular, - ); - } - - // Layout: two plots side by side - let margin: f64 = 20.0; - let gap: f64 = 15.0; - let plot_width: f64 = (297.0 - 2.0 * margin - gap) / 2.0; - let plot_height: f64 = 150.0; - let plot_top: f64 = 180.0; - let plot_bottom: f64 = plot_top - plot_height; - - // Render left plot - let left_config = &self.tabs[tab_idx].scatter_plot_state.left; - let left_rect = (margin, plot_bottom, plot_width, plot_height); - self.render_scatter_plot_to_pdf_region( - &mut ops, - &font_regular, - left_config, - left_rect, - tab_idx, - )?; - - // Render right plot - let right_config = &self.tabs[tab_idx].scatter_plot_state.right; - let right_rect = ( - margin + plot_width + gap, - plot_bottom, - plot_width, - plot_height, - ); - self.render_scatter_plot_to_pdf_region( - &mut ops, - &font_regular, - right_config, - right_rect, - tab_idx, - )?; - - // Build page and save PDF - let page = PdfPage::new(Mm(297.0), Mm(210.0), ops); - let mut doc = PdfDocument::new("UltraLog Scatter Plot Export"); - doc.with_pages(vec![page]); - let mut warnings = Vec::new(); - let pdf_bytes = doc.save(&PdfSaveOptions::default(), &mut warnings); - std::fs::write(path, pdf_bytes)?; +#[cfg(test)] +mod tests { + use super::*; + use crate::state::LogRole; - Ok(()) + #[test] + fn thousands_use_thin_spaces() { + assert_eq!(group_thousands(223_559), "223\u{2009}559"); + assert_eq!(group_thousands(1_000_000), "1\u{2009}000\u{2009}000"); + assert_eq!(group_thousands(999), "999"); } - /// Helper to render a single scatter plot to a PDF region - fn render_scatter_plot_to_pdf_region( - &self, - ops: &mut Vec, - font: &PdfFontHandle, - config: &crate::state::ScatterPlotConfig, - rect: (f64, f64, f64, f64), - tab_idx: usize, - ) -> Result<(), Box> { - let (left, bottom, width, height) = rect; - let right = left + width; - let top = bottom + height; + #[test] + fn constant_channel_sits_mid_height() { + assert_eq!(normalize_like_chart(5.0, 5.0, 5.0), 0.5); + assert_eq!(normalize_like_chart(7.5, 5.0, 10.0), 0.5); + } - let file_idx = config.file_index.unwrap_or(self.tabs[tab_idx].file_index); + #[test] + fn edge_points_move_along_their_segment() { + let pts = [[0.0, 0.0], [10.0, 10.0], [20.0, 0.0], [30.0, 10.0]]; + let clipped = clip_to_range(&pts, 5.0, 25.0); + assert_eq!(clipped.first(), Some(&[5.0, 5.0])); + assert_eq!(clipped.last(), Some(&[25.0, 5.0])); + assert_eq!(clipped.len(), 4); + // Fully inside: unchanged. + assert_eq!(clip_to_range(&pts, 0.0, 30.0), pts.to_vec()); + } - // Check if we have valid axis selections - let (x_idx, y_idx) = match (config.x_channel, config.y_channel) { - (Some(x), Some(y)) => (x, y), - _ => { - // Draw placeholder text - push_text( - ops, - "No axes selected", - 10.0, - Mm((left + width / 2.0 - 15.0) as f32), - Mm((bottom + height / 2.0) as f32), - font, - ); - return Ok(()); - } + /// One "MAP" channel at 10 Hz over 0..=`secs` s, value `f(t)`. + fn file(name: &str, secs: usize, f: impl Fn(f64) -> f64) -> crate::state::LoadedFile { + use crate::parsers::aim::AimChannel; + use crate::parsers::types::Meta; + use crate::parsers::{Channel, EcuType, Log, Value}; + let times: Vec = (0..=secs * 10).map(|i| i as f64 * 0.1).collect(); + let log = Log { + meta: Meta::Empty, + channels: vec![Channel::Aim(AimChannel { + name: "MAP".to_string(), + unit: String::new(), + })], + data: times.iter().map(|&t| vec![Value::Float(f(t))]).collect(), + times, }; + crate::state::LoadedFile::new( + std::path::PathBuf::from(name), + name.to_string(), + EcuType::Aim, + log, + ) + } - if file_idx >= self.files.len() { - return Err("Invalid file index".into()); - } - - let x_data = self.get_channel_data(file_idx, x_idx); - let y_data = self.get_channel_data(file_idx, y_idx); - - if x_data.is_empty() || y_data.is_empty() || x_data.len() != y_data.len() { - return Err("No data available".into()); - } - - // Get channel names for labels (handles both regular and computed channels) - let x_name = self.get_channel_name(file_idx, x_idx); - let y_name = self.get_channel_name(file_idx, y_idx); - - // Draw axis labels - push_text( - ops, - &format!("{} vs {}", y_name, x_name), - 9.0, - Mm(left as f32), - Mm((top + 3.0) as f32), - font, - ); - - // Calculate data bounds - let x_min = x_data.iter().cloned().fold(f64::INFINITY, f64::min); - let x_max = x_data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let y_min = y_data.iter().cloned().fold(f64::INFINITY, f64::min); - let y_max = y_data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - - let x_range = if (x_max - x_min).abs() < f64::EPSILON { - 1.0 - } else { - x_max - x_min - }; - let y_range = if (y_max - y_min).abs() < f64::EPSILON { - 1.0 - } else { - y_max - y_min + #[test] + fn compare_export_keeps_log_bs_early_part_and_shared_scale() { + let mut app = UltraLogApp::default(); + app.files.push(file("a.csv", 10, |t| t)); // 0..10 + app.files.push(file("b.csv", 8, |t| 2.0 * t)); // 0..16 + app.tabs + .push(crate::state::Tab::new(0, "a.csv".to_string())); + app.active_tab = Some(0); + app.update_tab_time_range(0); + app.choose_compare_file(0, Some(1)); + app.add_channel(0, 0); // MAP pairs with B's MAP + app.set_compare_offset(0, -3.0); + + let sheet = app.chart_sheet().expect("chart sheet"); + let Body::Chart(chart) = &sheet.body else { + panic!("chart body"); }; - - // Build 2D histogram (use smaller bins for PDF) - const PDF_BINS: usize = 64; - let mut histogram = vec![vec![0u32; PDF_BINS]; PDF_BINS]; - let mut max_hits: u32 = 0; - - for (&x, &y) in x_data.iter().zip(y_data.iter()) { - let x_bin = (((x - x_min) / x_range) * (PDF_BINS - 1) as f64).round() as usize; - let y_bin = (((y - y_min) / y_range) * (PDF_BINS - 1) as f64).round() as usize; - - let x_bin = x_bin.min(PDF_BINS - 1); - let y_bin = y_bin.min(PDF_BINS - 1); - - histogram[y_bin][x_bin] += 1; - max_hits = max_hits.max(histogram[y_bin][x_bin]); - } - - let cell_width = width / PDF_BINS as f64; - let cell_height = height / PDF_BINS as f64; - - // Draw heatmap cells - #[allow(clippy::needless_range_loop)] - for y_bin in 0..PDF_BINS { - for x_bin in 0..PDF_BINS { - let hits = histogram[y_bin][x_bin]; - if hits > 0 { - // Normalize using log scale - let normalized = if max_hits > 1 { - (hits as f64).ln() / (max_hits as f64).ln() - } else { - 1.0 - }; - let color = Self::get_pdf_heat_color(normalized); - - ops.push(Op::SetFillColor { col: color }); - - let cell_x = left + x_bin as f64 * cell_width; - let cell_y = bottom + y_bin as f64 * cell_height; - - push_filled_rect( - ops, - cell_x as f32, - cell_y as f32, - cell_width as f32, - cell_height as f32, - ); - } - } - } - - // Draw border - let border_color = Color::Rgb(Rgb::new(0.5, 0.5, 0.5, None)); - ops.push(Op::SetOutlineColor { col: border_color }); - ops.push(Op::SetOutlineThickness { pt: Pt(0.5) }); - - push_closed_line( - ops, - &[ - (left as f32, bottom as f32), - (right as f32, bottom as f32), - (right as f32, top as f32), - (left as f32, top as f32), - ], - ); - - // Draw axis labels - let label_color = Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)); - ops.push(Op::SetFillColor { col: label_color }); - - // X axis labels - for i in 0..=4 { - let t = i as f64 / 4.0; - let value = x_min + t * x_range; - let x_pos = left + t * width; - push_text( - ops, - &format!("{:.0}", value), - 6.0, - Mm((x_pos - 3.0) as f32), - Mm((bottom - 5.0) as f32), - font, - ); - } - - // Y axis labels - for i in 0..=4 { - let t = i as f64 / 4.0; - let value = y_min + t * y_range; - let y_pos = bottom + t * height; - push_text( - ops, - &format!("{:.0}", value), - 6.0, - Mm((left - 10.0) as f32), - Mm((y_pos - 1.0) as f32), - font, - ); - } - - Ok(()) + assert_eq!((chart.t0, chart.t1), (-3.0, 10.0)); + let (a, b) = (&chart.series[0], &chart.series[1]); + assert_eq!(a.log, Some(LogRole::A)); + assert_eq!(b.log, Some(LogRole::B)); + // Log B starts at tab time -3 (its file time 0), before log A exists. + assert!((b.points.first().unwrap()[0] + 3.0).abs() < 1e-9); + // Shared scale 0..16: A's peak (10) sits at 10/16, B's (16) at the top. + let top = |p: &[[f64; 2]]| p.iter().map(|q| q[1]).fold(f64::MIN, f64::max); + assert!((top(&a.points) - 10.0 / 16.0).abs() < 1e-9); + assert!((top(&b.points) - 1.0).abs() < 1e-9); + assert!(sheet.meta.last().unwrap().contains("B offset -3.00 s")); } -} - -/// Draw a line between two points using Bresenham's algorithm -pub(crate) fn draw_line(img: &mut RgbaImage, x0: u32, y0: u32, x1: u32, y1: u32, color: Rgba) { - let dx = (x1 as i32 - x0 as i32).abs(); - let dy = -(y1 as i32 - y0 as i32).abs(); - let sx: i32 = if x0 < x1 { 1 } else { -1 }; - let sy: i32 = if y0 < y1 { 1 } else { -1 }; - let mut err = dx + dy; - let mut x = x0 as i32; - let mut y = y0 as i32; - - let (width, height) = img.dimensions(); - - loop { - if x >= 0 && x < width as i32 && y >= 0 && y < height as i32 { - img.put_pixel(x as u32, y as u32, color); - } - - if x == x1 as i32 && y == y1 as i32 { - break; - } - - let e2 = 2 * err; - if e2 >= dy { - err += dy; - x += sx; - } - if e2 <= dx { - err += dx; - y += sy; - } + #[test] + fn even_ticks_span_the_range() { + let ticks = even_ticks(1000.0, 6000.0, 4); + assert_eq!(ticks.first().unwrap(), &(0.0, "1000".to_string())); + assert_eq!(ticks.last().unwrap(), &(1.0, "7000".to_string())); + assert_eq!(even_ticks(0.5, 1.0, 2)[1].1, "1.00"); } } diff --git a/src/ui/export_dialog.rs b/src/ui/export_dialog.rs new file mode 100644 index 00000000..3d9034f0 --- /dev/null +++ b/src/ui/export_dialog.rs @@ -0,0 +1,201 @@ +//! ExportDialog (`design/design-system/components/ExportDialog/README.md`). +//! +//! Format (PDF / PNG), then the export theme as two radio cards whose thumbnails are painted +//! with the real export tokens, an info notice about the colour-blind safe palette, and the +//! actions: Copy image (the chart or table alone, for pasting), Cancel, and Export PDF / PNG. +//! The format and the theme per format are remembered (`UserSettings::export`). + +use eframe::egui::{ + self, Align2, CornerRadius, FontFamily, FontId, Rect, RichText, Sense, Stroke, StrokeKind, + pos2, vec2, +}; +use rust_i18n::t; + +use crate::app::UltraLogApp; +use crate::state::ActiveTool; +use crate::ui::components::{self, Kind, Status, icon}; +use crate::ui::export_sheet::ExportFormat; +use crate::ui::fonts; +use crate::ui::theme::tokens::{radius, space}; +use crate::ui::theme::{self, ExportTheme, Tokens, export_tokens, paint}; + +enum Action { + Export, + Copy, + Cancel, +} + +/// A miniature sheet in `e` (an export theme's tokens): ground, redline, plot and two series. +fn paint_thumbnail(painter: &egui::Painter, rect: Rect, e: &Tokens, line: egui::Color32) { + let r = CornerRadius::same(radius::RADIUS_SM); + painter.rect_filled(rect, r, e.bg_base); + paint::redline(painter, rect, e, f32::from(radius::RADIUS_SM) * 0.6); + // The preview's SVG viewBox is 120 × 50 below the redline. + let area = Rect::from_min_max(rect.min + vec2(0.0, 7.0), rect.max - vec2(0.0, 7.0)); + let at = |x: f32, y: f32| { + pos2( + area.left() + x / 120.0 * area.width(), + area.top() + y / 50.0 * area.height(), + ) + }; + let plot = Rect::from_min_max(at(8.0, 8.0), at(112.0, 44.0)); + painter.rect_filled(plot, 0.0, e.plot_bg); + painter.rect_stroke(plot, 0.0, Stroke::new(1.0, e.line), StrokeKind::Inside); + let one = [ + (8., 36.), + (30., 30.), + (50., 34.), + (70., 16.), + (90., 26.), + (112., 20.), + ]; + let two = [(8., 40.), (40., 38.), (70., 26.), (112., 14.)]; + for (i, pts) in [&one[..], &two[..]].into_iter().enumerate() { + let (color, _) = theme::series(e, i, true); + let pts: Vec<_> = pts.iter().map(|&(x, y)| at(x, y)).collect(); + painter.add(egui::Shape::line(pts, Stroke::new(1.5, color))); + } + painter.rect_stroke(rect, r, Stroke::new(1.0, line), StrokeKind::Inside); +} + +/// One export-theme radio card. +fn theme_card(ui: &mut egui::Ui, theme: ExportTheme, selected: bool, width: f32) -> egui::Response { + let t = theme::current(ui.ctx()); + let pad = space::SPACE_2; + let thumb_h = 64.0; + let height = pad + thumb_h + pad + 36.0 + pad; + let (rect, response) = ui.allocate_exact_size(vec2(width, height), Sense::click()); + let (name, desc) = match theme { + ExportTheme::Paper => (t!("export.paper"), t!("export.paper_desc")), + ExportTheme::Graphite => (t!("export.graphite"), t!("export.graphite_desc")), + }; + response.widget_info(|| { + egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, selected, name.as_ref()) + }); + + let painter = ui.painter(); + let r = CornerRadius::same(radius::RADIUS_MD); + painter.rect_filled(rect, r, t.bg_field); + let border = if selected { + Stroke::new(2.0, t.cyan) + } else if response.hovered() { + Stroke::new(1.0, t.line) + } else { + Stroke::new(1.0, t.line_control) + }; + painter.rect_stroke(rect, r, border, StrokeKind::Inside); + + let thumb = Rect::from_min_size(rect.min + vec2(pad, pad), vec2(width - 2.0 * pad, thumb_h)); + paint_thumbnail(painter, thumb, &export_tokens(theme), t.line); + painter.text( + pos2(thumb.left(), thumb.bottom() + pad), + Align2::LEFT_TOP, + name, + FontId::new(13.0, FontFamily::Name(fonts::UI_SEMIBOLD.into())), + t.ink, + ); + painter.text( + pos2(thumb.left(), thumb.bottom() + pad + 18.0), + Align2::LEFT_TOP, + desc, + FontId::proportional(12.0), + t.ink_subtle, + ); + response +} + +impl UltraLogApp { + fn export_dialog_title(&self) -> String { + match self.active_tool { + ActiveTool::LogViewer => t!("export.title_chart"), + ActiveTool::ScatterPlot => t!("export.title_scatter"), + ActiveTool::Histogram => t!("export.title_histogram"), + ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => t!("export.title_table"), + } + .to_string() + } + + pub fn render_export_dialog(&mut self, ctx: &egui::Context) { + if !self.show_export_dialog { + return; + } + let mut open = true; + let mut action = None; + let mut choice = self.export_choice; + let response = components::window(self.export_dialog_title()) + .id(egui::Id::new("export_dialog")) + .open(&mut open) + .resizable(false) + .collapsible(false) + .default_width(460.0) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + let t = theme::current(ui.ctx()); + ui.spacing_mut().item_spacing.y = space::SPACE_3; + ui.horizontal(|ui| { + ui.add_sized( + [64.0, 22.0], + egui::Label::new(RichText::new(t!("export.format")).color(t.ink_muted)), + ); + components::segmented( + ui, + &mut choice.format, + &[(ExportFormat::Pdf, "PDF"), (ExportFormat::Png, "PNG")], + ); + }); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = space::SPACE_2; + let width = (ui.available_width() - space::SPACE_2) / 2.0; + for theme in [ExportTheme::Paper, ExportTheme::Graphite] { + let selected = choice.theme() == theme; + if theme_card(ui, theme, selected, width).clicked() { + *choice.theme_mut() = theme; + } + } + }); + components::notice(ui, Status::Info, "", &t!("export.cvd_note")); + ui.separator(); + ui.horizontal(|ui| { + let copy = components::button( + ui, + Kind::Ghost, + Some(icon::COPY), + t!("export.copy_image"), + ) + .on_hover_text(t!("export.copy_tooltip")); + if copy.clicked() { + action = Some(Action::Copy); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let label = match choice.format { + ExportFormat::Pdf => t!("export.export_pdf"), + ExportFormat::Png => t!("export.export_png"), + }; + let export = components::button( + ui, + Kind::Primary, + Some(icon::DOWNLOAD_SIMPLE), + label, + ); + if export.clicked() { + action = Some(Action::Export); + } + if components::button(ui, Kind::Ghost, None, t!("common.cancel")).clicked() + { + action = Some(Action::Cancel); + } + }); + }); + }); + components::finish_window(ctx, response.as_ref().map(|r| &r.response), &mut open); + + self.export_choice = choice; + self.show_export_dialog = open && action.is_none(); + match action { + Some(Action::Export) => self.export_to_file(choice.format, choice.theme()), + Some(Action::Copy) => self.copy_export_image(choice.theme()), + Some(Action::Cancel) | None => {} + } + } +} diff --git a/src/ui/export_sheet/canvas.rs b/src/ui/export_sheet/canvas.rs new file mode 100644 index 00000000..7c026433 --- /dev/null +++ b/src/ui/export_sheet/canvas.rs @@ -0,0 +1,80 @@ +//! The drawing surface the export sheet is laid out on. Two back ends implement it: PNG +//! (`png.rs`, vello_cpu) and PDF (`pdf.rs`, printpdf), so every export is drawn once. +//! +//! Units are points with the origin at the top-left and y growing down. Text positions are the +//! baseline. + +use eframe::egui::{Color32, Pos2, Rect}; + +/// The faces an export uses (the same files as the UI, `assets/fonts/`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Face { + /// Saira 800 italic: the sheet title. + Display, + /// Outfit 400: legend names. + Ui, + /// Outfit 600: emphasis. + UiSemibold, + /// JetBrains Mono 400: numbers, meta, footer, axis ticks. + Mono, +} + +impl Face { + /// The bundled face name (`fonts::face_bytes`). + pub fn font_name(self) -> &'static str { + match self { + Face::Display => "Saira-ExtraBoldItalic", + Face::Ui => "Outfit-Regular", + Face::UiSemibold => "Outfit-SemiBold", + Face::Mono => "JetBrainsMono-Regular", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HAlign { + Left, + Center, + Right, +} + +pub trait Canvas { + fn fill_rect(&mut self, rect: Rect, color: Color32); + + /// An open polyline; `dash` is `[on, off]` in points. + fn polyline(&mut self, points: &[Pos2], width: f32, color: Color32, dash: Option<[f32; 2]>); + + /// Text with its baseline at `pos.y`, aligned to `pos.x` per `align`. + fn text(&mut self, pos: Pos2, text: &str, face: Face, size: f32, color: Color32, align: HAlign); + + /// An RGBA image stretched over `rect`. + fn image(&mut self, rect: Rect, image: &::image::RgbaImage); + + fn stroke_rect(&mut self, rect: Rect, width: f32, color: Color32) { + let pts = [ + rect.left_top(), + rect.right_top(), + rect.right_bottom(), + rect.left_bottom(), + rect.left_top(), + ]; + self.polyline(&pts, width, color, None); + } + + /// A horizontal gradient through `stops` (evenly spaced), drawn as thin slices so both + /// back ends render it the same way. + fn gradient_rect(&mut self, rect: Rect, color_at: &dyn Fn(f32) -> Color32) { + let slices = 96; + let w = rect.width() / slices as f32; + for i in 0..slices { + let k = (i as f32 + 0.5) / slices as f32; + let r = Rect::from_min_size( + rect.left_top() + eframe::egui::vec2(w * i as f32, 0.0), + eframe::egui::vec2(w + 0.6, rect.height()), + ); + self.fill_rect(r, color_at(k)); + } + } +} + +pub use crate::ui::theme::flatten; diff --git a/src/ui/export_sheet/mod.rs b/src/ui/export_sheet/mod.rs new file mode 100644 index 00000000..cedd170a --- /dev/null +++ b/src/ui/export_sheet/mod.rs @@ -0,0 +1,1015 @@ +//! The ExportSheet (`design/design-system/4-exports.md`, +//! `design/design-system/components/ExportSheet/README.md`). +//! +//! Every PNG and PDF export is the same sheet: the sunset redline across the top edge, the +//! lockup, the title, the time range and counts right-aligned in mono, the chart or table, its +//! legend or ColorScale, and a footer (ultralog.co · version · date). Exports use an export +//! theme (Paper or Graphite), always the color-blind safe palette, and no intensity effects. +//! +//! Callers build a [`Sheet`] from app state (`ui/export.rs`, `ui/table_export.rs`); this module +//! lays it out once on a [`Canvas`], and the PNG / PDF back ends turn that into files. + +pub mod canvas; +pub mod pdf; +pub mod png; +pub mod text; + +use std::sync::OnceLock; + +use ::image::RgbaImage; +use eframe::egui::{Color32, Pos2, Rect, pos2, vec2}; +use rust_i18n::t; + +use crate::state::LogRole; +use crate::ui::heatmap::{self, MapKind}; +use crate::ui::theme::{self, ExportTheme, SeriesStroke, Tokens, export_tokens}; +use canvas::{Canvas, Face, HAlign, flatten}; + +/// PNG sheet size in points (rendered at 2×: 1920 × 1080 px). +pub const PNG_SIZE: (f32, f32) = (960.0, 540.0); +/// PNG pixels per point. +pub const PNG_SCALE: f32 = 2.0; +/// PDF page: A4 landscape, in points. +pub const PDF_SIZE: (f32, f32) = (842.0, 595.0); + +/// One line series, normalized to its own range for display. +pub struct SeriesModel { + pub name: String, + pub unit: String, + /// Colour slot; the export palette is `theme::series(.., true)`. + pub color_index: usize, + /// Which log the series comes from while the tab compares two logs (issue #98): log B is + /// dashed in its pair's colour and both get an A/B tag in the legend. + pub log: Option, + /// `(time, value 0..=1)` within the sheet's time range. + pub points: Vec<[f64; 2]>, +} + +pub struct ChartModel { + pub series: Vec, + pub t0: f64, + pub t1: f64, +} + +/// How sure a table cell is (count badge fill style). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Confidence { + High, + Medium, + Low, +} + +/// Event count and confidence per table cell, `[row][col]`. +pub type CountGrid = Vec>>; + +/// A heatmap or table. Row 0 is the lowest Y bin and is drawn at the bottom. +pub struct GridModel { + /// Shown above the grid when several grids share a sheet. + pub caption: Option, + pub x_title: String, + pub y_title: String, + /// Axis labels at positions 0..=1 along each axis (0 = left / bottom), already formatted. + /// Labels that would overlap the previous one are skipped. + pub x_ticks: Vec<(f32, String)>, + pub y_ticks: Vec<(f32, String)>, + /// Colour position 0..=1 per cell; `None` is empty (hatched). + pub norm: Vec>>, + /// Value text per cell, if it should print one. + pub labels: Vec>>, + /// Event count and confidence per cell (tables only). + pub counts: Option, + pub kind: MapKind, + pub scale_title: String, + pub scale_min: String, + pub scale_max: String, + /// A diverging scale of a lambda deviation: label its ends rich · target · lean. + pub rich_lean: bool, + /// A density map with many small cells (scatter plots): no cell gaps, labels or hatching, + /// and empty cells show the plot ground. + pub dense: bool, +} + +/// The file type the ExportDialog writes, remembered in `UserSettings`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ExportFormat { + #[default] + Pdf, + Png, +} + +impl ExportFormat { + pub fn extension(self) -> &'static str { + match self { + ExportFormat::Pdf => "pdf", + ExportFormat::Png => "png", + } + } +} + +/// The ExportDialog's remembered choices: the last format, and the last theme per format. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct ExportChoice { + pub format: ExportFormat, + pub pdf_theme: ExportTheme, + pub png_theme: ExportTheme, +} + +impl ExportChoice { + /// The theme remembered for the current format. + pub fn theme(&self) -> ExportTheme { + match self.format { + ExportFormat::Pdf => self.pdf_theme, + ExportFormat::Png => self.png_theme, + } + } + + pub fn theme_mut(&mut self) -> &mut ExportTheme { + match self.format { + ExportFormat::Pdf => &mut self.pdf_theme, + ExportFormat::Png => &mut self.png_theme, + } + } +} + +pub enum Body { + Chart(ChartModel), + Grids(Vec), +} + +pub struct Sheet { + pub title: String, + /// Right-aligned mono lines: time range, record/channel counts. + pub meta: Vec, + pub body: Body, +} + +impl Sheet { + fn footer_right() -> String { + format!( + "v{} \u{00B7} exported {}", + env!("CARGO_PKG_VERSION"), + chrono::Local::now().format("%Y-%m-%d") + ) + } +} + +/// The pre-rasterized lockup for `theme` (dark grounds: Graphite and the About window). +pub(crate) fn lockup(theme: ExportTheme) -> &'static RgbaImage { + static PAPER: OnceLock = OnceLock::new(); + static DARK: OnceLock = OnceLock::new(); + let (cell, bytes): (&OnceLock, &[u8]) = match theme { + ExportTheme::Paper => ( + &PAPER, + include_bytes!("../../../assets/brand/ultralog-lockup-paper.png"), + ), + ExportTheme::Graphite => ( + &DARK, + include_bytes!("../../../assets/brand/ultralog-lockup.png"), + ), + }; + cell.get_or_init(|| { + ::image::load_from_memory(bytes) + .map(|i| i.to_rgba8()) + .unwrap_or_else(|_| RgbaImage::new(1, 1)) + }) +} + +/// Render the full sheet as PNG pixels (1920 × 1080). +pub fn render_png(sheet: &Sheet, theme: ExportTheme) -> RgbaImage { + let (w, h) = PNG_SIZE; + let mut c = png::PngCanvas::new(w, h, PNG_SCALE); + draw_sheet( + &mut c, + sheet, + theme, + Rect::from_min_size(Pos2::ZERO, vec2(w, h)), + false, + ); + c.finish() +} + +/// Render only the chart or table (no header, legend or footer) for pasting into a document. +pub fn render_png_body(sheet: &Sheet, theme: ExportTheme) -> RgbaImage { + let (w, h) = (PNG_SIZE.0, PNG_SIZE.1 * 0.7); + let t = export_tokens(theme); + let mut c = png::PngCanvas::new(w, h, PNG_SCALE); + let page = Rect::from_min_size(Pos2::ZERO, vec2(w, h)); + c.fill_rect(page, t.bg_base); + draw_body(&mut c, &sheet.body, &t, page.shrink(12.0)); + c.finish() +} + +/// Render the full sheet as a one-page A4 landscape PDF. +pub fn render_pdf(sheet: &Sheet, theme: ExportTheme) -> Vec { + let (w, h) = PDF_SIZE; + let mut c = pdf::PdfCanvas::new(&sheet.title, w, h); + draw_sheet( + &mut c, + sheet, + theme, + Rect::from_min_size(Pos2::ZERO, vec2(w, h)), + true, + ); + c.finish() +} + +/// Redline colour at `k` (cyan → violet → magenta → orange → gold at 0 / 30 / 55 / 80 / 100 %). +fn redline_at(t: &Tokens, k: f32) -> Color32 { + let stops = [ + (0.0, t.redline_0), + (0.30, t.redline_1), + (0.55, t.redline_2), + (0.80, t.redline_3), + (1.0, t.redline_4), + ]; + for pair in stops.windows(2) { + let ((k0, c0), (k1, c1)) = (pair[0], pair[1]); + if k <= k1 { + return theme::lerp_oklab(c0, c1, ((k - k0) / (k1 - k0)).clamp(0.0, 1.0)); + } + } + t.redline_4 +} + +fn draw_sheet(c: &mut dyn Canvas, sheet: &Sheet, theme: ExportTheme, page: Rect, pdf: bool) { + let t = export_tokens(theme); + // Graphite PDFs keep a white margin so a printer never floods the sheet edge to edge. + let area = if pdf && theme == ExportTheme::Graphite { + c.fill_rect(page, export_tokens(ExportTheme::Paper).bg_base); + page.shrink(18.0) + } else { + page + }; + c.fill_rect(area, t.bg_base); + c.gradient_rect( + Rect::from_min_size(area.left_top(), vec2(area.width(), 3.0)), + &|k| redline_at(&t, k), + ); + + let content = Rect::from_min_max( + area.left_top() + vec2(30.0, 26.0), + area.right_bottom() - vec2(30.0, 22.0), + ); + + // Lockup, title and meta + let logo = lockup(theme); + let logo_h = 20.0; + let logo_w = logo_h * logo.width() as f32 / logo.height().max(1) as f32; + c.image( + Rect::from_min_size(content.left_top(), vec2(logo_w, logo_h)), + logo, + ); + let title_base = content.top() + logo_h + 28.0; + c.text( + pos2(content.left(), title_base), + &sheet.title, + Face::Display, + 22.0, + t.ink, + HAlign::Left, + ); + for (i, line) in sheet.meta.iter().enumerate() { + c.text( + pos2(content.right(), title_base - 14.0 + 12.0 * i as f32), + line, + Face::Mono, + 8.5, + t.ink_subtle, + HAlign::Right, + ); + } + + // Footer + c.text( + pos2(content.left(), content.bottom()), + "ultralog.co", + Face::Mono, + 7.5, + t.ink_subtle, + HAlign::Left, + ); + c.text( + pos2(content.right(), content.bottom()), + &Sheet::footer_right(), + Face::Mono, + 7.5, + t.ink_subtle, + HAlign::Right, + ); + + // Body, then its legend or ColorScale. A chart legend wraps onto as many lines as it needs. + let legend = match &sheet.body { + Body::Chart(chart) => legend_layout(chart, content.width()), + Body::Grids(_) => Vec::new(), + }; + let legend_lines = legend.iter().map(|e| e.line + 1).max().unwrap_or(1); + let legend_h = 24.0 + LEGEND_LINE * (legend_lines - 1) as f32; + let body = Rect::from_min_max( + pos2(content.left(), title_base + 18.0), + pos2(content.right(), content.bottom() - 18.0 - legend_h), + ); + draw_body(c, &sheet.body, &t, body); + let legend_y = body.bottom() + 16.0; + match &sheet.body { + Body::Chart(chart) => { + draw_series_legend(c, chart, &legend, &t, pos2(body.left(), legend_y)) + } + Body::Grids(grids) => { + if let Some(g) = grids.first() { + draw_color_scale(c, g, &t, pos2(body.left(), legend_y)); + } + } + } +} + +fn draw_body(c: &mut dyn Canvas, body: &Body, t: &Tokens, rect: Rect) { + match body { + Body::Chart(chart) => draw_chart(c, chart, t, rect), + Body::Grids(grids) => { + let n = grids.len().max(1) as f32; + let gap = 24.0; + let w = (rect.width() - gap * (n - 1.0)) / n; + for (i, g) in grids.iter().enumerate() { + let r = Rect::from_min_size( + rect.left_top() + vec2((w + gap) * i as f32, 0.0), + vec2(w, rect.height()), + ); + draw_grid(c, g, t, r); + } + } + } +} + +fn series_style(t: &Tokens, s: &SeriesModel) -> (Color32, Option<[f32; 2]>) { + let (color, stroke) = match s.log { + Some(role) => theme::compare_series(t, s.color_index, true, role == LogRole::B), + None => theme::series(t, s.color_index, true), + }; + let dash = match stroke { + SeriesStroke::Solid => None, + SeriesStroke::Dashed => Some([4.0, 2.5]), + SeriesStroke::Dotted => Some([1.0, 2.0]), + }; + (color, dash) +} + +/// "Nice" tick spacing for `span` seconds aiming at about `count` ticks. +fn nice_step(span: f64, count: f64) -> f64 { + let raw = (span / count).max(1e-9); + let mag = 10f64.powf(raw.log10().floor()); + let norm = raw / mag; + let nice = if norm < 1.5 { + 1.0 + } else if norm < 3.5 { + 2.0 + } else if norm < 7.5 { + 5.0 + } else { + 10.0 + }; + nice * mag +} + +fn draw_chart(c: &mut dyn Canvas, chart: &ChartModel, t: &Tokens, rect: Rect) { + let plot = Rect::from_min_max(rect.left_top(), pos2(rect.right(), rect.bottom() - 14.0)); + c.fill_rect(plot, t.plot_bg); + let span = (chart.t1 - chart.t0).max(1e-9); + let x_of = |time: f64| plot.left() + ((time - chart.t0) / span) as f32 * plot.width(); + let grid = flatten(t.plot_grid, t.plot_bg); + + // Grid and time ticks + let step = nice_step(span, 8.0); + // Ticks from an integer index, not `tick += step`, so float error never accumulates. + let first = (chart.t0 / step - 1e-9).ceil() as i64; + let last = (chart.t1 / step + 1e-9).floor() as i64; + for k in first..=last { + let tick = k as f64 * step; + let x = x_of(tick); + c.polyline( + &[pos2(x, plot.top()), pos2(x, plot.bottom())], + 0.4, + grid, + None, + ); + c.text( + pos2(x, rect.bottom()), + &format_tick(tick, step), + Face::Mono, + 7.0, + t.plot_axis, + HAlign::Center, + ); + } + for i in 1..4 { + let y = plot.top() + plot.height() * i as f32 / 4.0; + c.polyline( + &[pos2(plot.left(), y), pos2(plot.right(), y)], + 0.4, + grid, + None, + ); + } + + for s in &chart.series { + let (color, dash) = series_style(t, s); + let pts: Vec = s + .points + .iter() + .map(|p| pos2(x_of(p[0]), plot.bottom() - p[1] as f32 * plot.height())) + .collect(); + c.polyline(&pts, 1.2, color, dash); + } + c.stroke_rect(plot, 0.6, flatten(t.plot_grid_major, t.plot_bg)); +} + +/// Decimals that resolve `step` (0.5 → 1, 0.05 → 2, 0.002 → 3). +fn tick_decimals(step: f64) -> usize { + if step >= 1.0 { + 0 + } else { + ((-step.log10()).ceil().max(0.0) as usize).min(6) + } +} + +/// A time tick: seconds under a minute, else `m:ss` with the step's decimals. The value is +/// rounded to those decimals *before* minutes are split off, so 119.9999 reads 2:00, not 1:60. +fn format_tick(seconds: f64, step: f64) -> String { + let d = tick_decimals(step); + let scale = 10f64.powi(d as i32); + let rounded = (seconds * scale).round() / scale; + if rounded >= 60.0 { + let m = (rounded / 60.0).floor(); + let s = rounded - m * 60.0; + let width = if d == 0 { 2 } else { d + 3 }; + format!("{}:{:0width$.d$}", m, s) + } else { + format!("{rounded:.d$}") + } +} + +/// Line height of a wrapped series legend. +const LEGEND_LINE: f32 = 13.0; +/// Swatch plus gap before a legend label, and the gap after it. +const LEGEND_SWATCH: f32 = 16.0; +const LEGEND_GAP: f32 = 16.0; + +/// One series legend entry: which series, which line, and its x offset from the legend start. +struct LegendEntry { + series: usize, + line: usize, + x: f32, + label: String, +} + +/// Lay the series legend out left to right, wrapping at `width`. +fn legend_layout(chart: &ChartModel, width: f32) -> Vec { + let mut entries = Vec::with_capacity(chart.series.len()); + let (mut line, mut x) = (0, 0.0); + for (series, s) in chart.series.iter().enumerate() { + let name = match s.log { + Some(LogRole::A) => format!("A \u{00B7} {}", s.name), + Some(LogRole::B) => format!("B \u{00B7} {}", s.name), + None => s.name.clone(), + }; + let label = if s.unit.is_empty() { + name + } else { + format!("{name} ({})", s.unit) + }; + let w = LEGEND_SWATCH + text::width(&label, Face::Ui, 8.5); + if x > 0.0 && x + w > width { + line += 1; + x = 0.0; + } + entries.push(LegendEntry { + series, + line, + x, + label, + }); + x += w + LEGEND_GAP; + } + entries +} + +fn draw_series_legend( + c: &mut dyn Canvas, + chart: &ChartModel, + entries: &[LegendEntry], + t: &Tokens, + origin: Pos2, +) { + for e in entries { + let (color, dash) = series_style(t, &chart.series[e.series]); + let x = origin.x + e.x; + let y = origin.y + LEGEND_LINE * e.line as f32; + c.polyline( + &[pos2(x, y - 3.0), pos2(x + 12.0, y - 3.0)], + 1.6, + color, + dash, + ); + c.text( + pos2(x + LEGEND_SWATCH, y), + &e.label, + Face::Ui, + 8.5, + t.ink_muted, + HAlign::Left, + ); + } +} + +/// Diagonal hatch segments inside `rect` (45°, `step` apart). +fn hatch(c: &mut dyn Canvas, rect: Rect, color: Color32, step: f32) { + let mut k = -rect.height(); + while k < rect.width() { + // Line from (left + k, bottom) going up-right; clip to the rect. + let x0 = rect.left() + k; + let (mut a, mut b) = ( + pos2(x0, rect.bottom()), + pos2(x0 + rect.height(), rect.top()), + ); + if a.x < rect.left() { + a = pos2(rect.left(), rect.bottom() - (rect.left() - a.x)); + } + if b.x > rect.right() { + b = pos2(rect.right(), rect.top() + (b.x - rect.right())); + } + if a.x < b.x { + c.polyline(&[a, b], 0.3, color, None); + } + k += step; + } +} + +fn draw_grid(c: &mut dyn Canvas, g: &GridModel, t: &Tokens, rect: Rect) { + let mut area = rect; + if let Some(caption) = &g.caption { + c.text( + pos2(area.left(), area.top() + 8.0), + caption, + Face::UiSemibold, + 8.0, + t.ink_muted, + HAlign::Left, + ); + area = Rect::from_min_max(area.left_top() + vec2(0.0, 14.0), area.right_bottom()); + } + let rows = g.norm.len(); + let cols = g.norm.first().map_or(0, Vec::len); + if rows == 0 || cols == 0 { + return; + } + // Axis titles: Y above the row labels, X centred under the column labels. + c.text( + pos2(area.left(), area.top() + 7.0), + &g.y_title, + Face::UiSemibold, + 7.0, + t.ink_muted, + HAlign::Left, + ); + let plot = Rect::from_min_max( + pos2(area.left() + 36.0, area.top() + 12.0), + pos2(area.right(), area.bottom() - 22.0), + ); + c.text( + pos2(plot.center().x, area.bottom()), + &g.x_title, + Face::UiSemibold, + 7.0, + t.ink_muted, + HAlign::Center, + ); + let cw = plot.width() / cols as f32; + let ch = plot.height() / rows as f32; + let value_size = (ch * 0.45).clamp(4.5, 8.0); + let badge_size = (ch * 0.28).clamp(3.5, 5.5); + if g.dense { + c.fill_rect(plot, t.plot_bg); + } + + for r in 0..rows { + let y = plot.bottom() - ch * (r + 1) as f32; + for col in 0..cols { + let full = Rect::from_min_size(pos2(plot.left() + cw * col as f32, y), vec2(cw, ch)); + if g.dense { + if let Some(v) = g.norm[r][col] { + // Slight overlap so neighbouring cells leave no hairline seams. + c.fill_rect(full.expand(0.15), heatmap::fill(t, g.kind, v)); + } + continue; + } + let cell = full.shrink(0.4); + let Some(v) = g.norm[r][col] else { + c.fill_rect(cell, t.cell_empty); + hatch(c, cell, flatten(t.line_subtle, t.cell_empty), 3.0); + continue; + }; + let fill = heatmap::fill(t, g.kind, v); + c.fill_rect(cell, fill); + if let Some(Some(label)) = g.labels.get(r).and_then(|row| row.get(col)) + && cw > 16.0 + && ch > 9.0 + { + let mut text_color = heatmap::text_on(t, g.kind, v); + if t.cell_plate.a() > 0 { + let w = text::width(label, Face::Mono, value_size) + 3.0; + c.fill_rect( + Rect::from_center_size(cell.center(), vec2(w, value_size + 1.5)), + flatten(t.cell_plate, fill), + ); + text_color = t.ink; + } + c.text( + pos2(cell.center().x, cell.center().y + value_size * 0.35), + label, + Face::Mono, + value_size, + text_color, + HAlign::Center, + ); + } + if let Some(Some((count, conf))) = g + .counts + .as_ref() + .and_then(|rows| rows.get(r).and_then(|row| row.get(col)).copied()) + && cw > 20.0 + { + draw_badge(c, t, cell, count, conf, badge_size); + } + } + } + if g.dense { + c.stroke_rect(plot, 0.6, flatten(t.plot_grid_major, t.plot_bg)); + } + + // Axis labels, skipping any that would collide with the previous one. + let mut last_right = f32::NEG_INFINITY; + for (k, label) in &g.x_ticks { + let x = plot.left() + k.clamp(0.0, 1.0) * plot.width(); + let w = text::width(label, Face::Mono, 6.5); + if x - w / 2.0 < last_right + 4.0 { + continue; + } + last_right = x + w / 2.0; + c.text( + pos2(x, plot.bottom() + 9.0), + label, + Face::Mono, + 6.5, + t.plot_axis, + HAlign::Center, + ); + } + let mut last_y = f32::INFINITY; + for (k, label) in &g.y_ticks { + let y = plot.bottom() - k.clamp(0.0, 1.0) * plot.height(); + if last_y - y < 9.0 { + continue; + } + last_y = y; + c.text( + pos2(plot.left() - 4.0, y + 2.3), + label, + Face::Mono, + 6.5, + t.plot_axis, + HAlign::Right, + ); + } +} + +fn draw_badge( + c: &mut dyn Canvas, + t: &Tokens, + cell: Rect, + count: usize, + conf: Confidence, + size: f32, +) { + let text = count.to_string(); + let w = text::width(&text, Face::Mono, size) + 2.0; + let r = Rect::from_min_size( + pos2(cell.right() - w - 0.8, cell.top() + 0.8), + vec2(w, size + 1.2), + ); + let base = pos2(r.center().x, r.bottom() - 1.0); + match conf { + Confidence::High => { + c.fill_rect(r, t.ink); + c.text(base, &text, Face::Mono, size, t.bg_base, HAlign::Center); + } + Confidence::Medium => { + c.fill_rect(r, t.bg_base); + c.stroke_rect(r, 0.4, t.ink); + c.text(base, &text, Face::Mono, size, t.ink, HAlign::Center); + } + Confidence::Low => { + c.fill_rect(r, t.bg_base); + let pts = [ + r.left_top(), + r.right_top(), + r.right_bottom(), + r.left_bottom(), + r.left_top(), + ]; + c.polyline(&pts, 0.4, t.ink_muted, Some([0.8, 0.6])); + c.text(base, &text, Face::Mono, size, t.ink_muted, HAlign::Center); + } + } +} + +fn draw_color_scale(c: &mut dyn Canvas, g: &GridModel, t: &Tokens, origin: Pos2) { + let title = g.scale_title.clone(); + c.text( + origin, + &title, + Face::UiSemibold, + 7.0, + t.ink_muted, + HAlign::Left, + ); + let mut x = origin.x + text::width(&title, Face::UiSemibold, 7.0) + 12.0; + c.text( + pos2(x, origin.y), + &g.scale_min, + Face::Mono, + 7.5, + t.ink_muted, + HAlign::Left, + ); + x += text::width(&g.scale_min, Face::Mono, 7.5) + 6.0; + let bar = Rect::from_min_size(pos2(x, origin.y - 6.0), vec2(150.0, 6.0)); + let kind = g.kind; + c.gradient_rect(bar, &|k| heatmap::fill(t, kind, k)); + c.text( + pos2(bar.right() + 6.0, origin.y), + &g.scale_max, + Face::Mono, + 7.5, + t.ink_muted, + HAlign::Left, + ); + if kind == MapKind::Diverging && g.rich_lean { + let y = origin.y + 8.0; + c.text( + pos2(bar.left(), y), + &t!("chart.rich"), + Face::Mono, + 6.0, + t.ink_subtle, + HAlign::Left, + ); + c.text( + pos2(bar.center().x, y), + &t!("chart.target"), + Face::Mono, + 6.0, + t.ink_subtle, + HAlign::Center, + ); + c.text( + pos2(bar.right(), y), + &t!("chart.lean"), + Face::Mono, + 6.0, + t.ink_subtle, + HAlign::Right, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chart_sheet() -> Sheet { + let series = (0..3) + .map(|i| SeriesModel { + name: format!("Channel {i}"), + unit: "kPa".into(), + color_index: i, + log: None, + points: (0..200) + .map(|k| { + let x = k as f64 / 10.0; + [x, 0.5 + 0.4 * ((x + i as f64).sin())] + }) + .collect(), + }) + .collect(); + Sheet { + title: "2025-03-06 Haltech pull".into(), + meta: vec![ + "0:00.000 \u{2013} 0:20.000".into(), + "223\u{2009}559 records \u{00B7} 114 ch".into(), + ], + body: Body::Chart(ChartModel { + series, + t0: 0.0, + t1: 20.0, + }), + } + } + + #[test] + fn png_sheet_is_2x_with_paper_ground_and_redline() { + let img = render_png(&chart_sheet(), ExportTheme::Paper); + assert_eq!((img.width(), img.height()), (1920, 1080)); + let paper = export_tokens(ExportTheme::Paper).bg_base; + let px = img.get_pixel(1900, 1060); + assert_eq!([px[0], px[1], px[2]], [paper.r(), paper.g(), paper.b()]); + // The redline starts cyan-ish at the top-left edge. + let top = img.get_pixel(4, 2); + assert_ne!([top[0], top[1], top[2]], [paper.r(), paper.g(), paper.b()]); + } + + #[test] + fn pdf_sheet_embeds_fonts_and_parses() { + let bytes = render_pdf(&chart_sheet(), ExportTheme::Graphite); + assert!(bytes.starts_with(b"%PDF")); + let text = String::from_utf8_lossy(&bytes); + assert!( + text.contains("FontFile2") || text.contains("FontFile"), + "fonts are embedded" + ); + } + + /// `EXPORT_SHEET_DUMP= cargo test --lib dump_sheets -- --ignored` writes sample sheets + /// for a visual check. + #[test] + #[ignore] + fn dump_sheets() { + let dir = std::env::var("EXPORT_SHEET_DUMP").expect("EXPORT_SHEET_DUMP"); + for theme in [ExportTheme::Paper, ExportTheme::Graphite] { + let name = format!("{theme:?}").to_lowercase(); + render_png(&chart_sheet(), theme) + .save(format!("{dir}/chart-{name}.png")) + .unwrap(); + std::fs::write( + format!("{dir}/chart-{name}.pdf"), + render_pdf(&chart_sheet(), theme), + ) + .unwrap(); + } + // Twelve long channel names (legend wrap) and a Japanese title (system CJK face). + let mut wide = chart_sheet(); + wide.title = "ログ 2025-03-06 回転数 Журнал".into(); + if let Body::Chart(chart) = &mut wide.body { + chart.series = (0..12) + .map(|i| SeriesModel { + name: format!("Manifold Pressure {i}"), + unit: "kPa".into(), + color_index: i, + log: None, + points: (0..200) + .map(|k| { + let x = k as f64 / 10.0; + [x, 0.5 + 0.4 * (x * 0.3 + i as f64).sin()] + }) + .collect(), + }) + .collect(); + } + render_png(&wide, ExportTheme::Paper) + .save(format!("{dir}/chart-wide-paper.png")) + .unwrap(); + + // A compare sheet: two pairs, log B dashed and shifted (issue #98). + let mut compare = chart_sheet(); + compare + .meta + .push("A 2025-03-06.csv \u{00B7} B 2025-07-18.csv \u{00B7} B offset +1.25 s".into()); + if let Body::Chart(chart) = &mut compare.body { + chart.series = (0..4) + .map(|i| { + let (slot, b) = (i / 2, i % 2 == 1); + SeriesModel { + name: ["MAP", "RPM"][slot].into(), + unit: ["kPa", "rpm"][slot].into(), + color_index: slot, + log: Some(if b { LogRole::B } else { LogRole::A }), + points: (0..200) + .map(|k| { + let x = k as f64 / 10.0; + let shift = if b { 1.25 } else { 0.0 }; + [x, 0.45 + 0.35 * ((x - shift) * 0.6 + slot as f64).sin()] + }) + .collect(), + } + }) + .collect(); + } + for theme in [ExportTheme::Paper, ExportTheme::Graphite] { + let name = format!("{theme:?}").to_lowercase(); + render_png(&compare, theme) + .save(format!("{dir}/chart-compare-{name}.png")) + .unwrap(); + } + std::fs::write( + format!("{dir}/chart-wide-paper.pdf"), + render_pdf(&wide, ExportTheme::Paper), + ) + .unwrap(); + + // A dense scatter density map pair. + let blob = |cx: f32, cy: f32| -> Vec>> { + (0..96) + .map(|r| { + (0..96) + .map(|c| { + let (dx, dy) = ((c as f32 - cx) / 20.0, (r as f32 - cy) / 12.0); + let v = (-(dx * dx + dy * dy)).exp(); + (v > 0.05).then_some(v) + }) + .collect() + }) + .collect() + }; + let grid = |caption: &str, norm| GridModel { + caption: Some(caption.into()), + x_title: "RPM".into(), + y_title: "MAP".into(), + x_ticks: vec![ + (0.0, "800".into()), + (0.5, "4000".into()), + (1.0, "7200".into()), + ], + y_ticks: vec![(0.0, "20".into()), (0.5, "110".into()), (1.0, "200".into())], + norm, + labels: Vec::new(), + counts: None, + kind: MapKind::Sequential, + scale_title: "Hits".into(), + scale_min: "1".into(), + scale_max: "4120".into(), + rich_lean: false, + dense: true, + }; + let sheet = Sheet { + title: "Scatter".into(), + meta: vec!["223\u{2009}559 records".into()], + body: Body::Grids(vec![ + grid("MAP vs RPM", blob(40.0, 50.0)), + grid("AFR vs RPM", blob(60.0, 30.0)), + ]), + }; + render_png(&sheet, ExportTheme::Paper) + .save(format!("{dir}/scatter-paper.png")) + .unwrap(); + } + + #[test] + fn tick_labels_round_before_splitting_minutes() { + assert_eq!(format_tick(119.99999, 0.1), "2:00.0"); + assert_eq!(format_tick(59.95, 0.05), "59.95"); + assert_eq!(format_tick(125.0, 5.0), "2:05"); + assert_eq!(format_tick(0.15, 0.05), "0.15"); + // Ticks at 0.05 apart never repeat a label. + let labels: Vec = (0..10) + .map(|k| format_tick(k as f64 * 0.05, 0.05)) + .collect(); + let mut unique = labels.clone(); + unique.dedup(); + assert_eq!(labels, unique); + } + + #[test] + fn legend_wraps_inside_the_sheet() { + let series = (0..12) + .map(|i| SeriesModel { + name: format!("Manifold Pressure {i}"), + unit: "kPa".into(), + color_index: i, + log: None, + points: Vec::new(), + }) + .collect(); + let chart = ChartModel { + series, + t0: 0.0, + t1: 1.0, + }; + let width = 900.0; + let entries = legend_layout(&chart, width); + assert_eq!(entries.len(), 12); + assert!( + entries.iter().any(|e| e.line > 0), + "12 long names need a second line" + ); + for e in &entries { + let w = LEGEND_SWATCH + text::width(&e.label, Face::Ui, 8.5); + assert!(e.x + w <= width + 0.5, "{} ends past the sheet", e.label); + } + } + + #[test] + fn copy_for_paste_is_the_body_only() { + let img = render_png_body(&chart_sheet(), ExportTheme::Paper); + assert_eq!(img.width(), 1920); + assert!(img.height() < 1080); + } +} diff --git a/src/ui/export_sheet/pdf.rs b/src/ui/export_sheet/pdf.rs new file mode 100644 index 00000000..8347480c --- /dev/null +++ b/src/ui/export_sheet/pdf.rs @@ -0,0 +1,187 @@ +//! PDF back end: printpdf with the bundled TTFs embedded, so text stays selectable and every +//! character the UI shows (−, λ, °, thin spaces, Cyrillic channel names) prints. + +use std::collections::HashMap; + +use ::image::RgbaImage; +use eframe::egui::{Color32, Pos2, Rect}; +use printpdf::{ + Color, FontId, Line, LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Op, PaintMode, + ParsedFont, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing, + Pt, RawImage, RawImageData, RawImageFormat, Rgb, TextItem, WindingOrder, XObjectTransform, +}; + +use super::canvas::{Canvas, Face, HAlign}; +use super::text; + +pub struct PdfCanvas { + doc: PdfDocument, + ops: Vec, + width: f32, + height: f32, + fonts: HashMap<&'static str, FontId>, +} + +fn rgb(c: Color32) -> Color { + Color::Rgb(Rgb::new( + c.r() as f32 / 255.0, + c.g() as f32 / 255.0, + c.b() as f32 / 255.0, + None, + )) +} + +impl PdfCanvas { + /// A one-page document of `width` × `height` points. + pub fn new(title: &str, width: f32, height: f32) -> Self { + Self { + doc: PdfDocument::new(title), + // Round joins and caps, like the PNG back end (kurbo's default): PDF's default + // miter join makes narrow spikes look taller than the data. + ops: vec![ + Op::SetLineJoinStyle { + join: LineJoinStyle::Round, + }, + Op::SetLineCapStyle { + cap: LineCapStyle::Round, + }, + ], + width, + height, + fonts: HashMap::new(), + } + } + + fn point(&self, p: Pos2) -> Point { + Point { + x: Pt(p.x), + y: Pt(self.height - p.y), + } + } + + fn font(&mut self, name: &'static str, bytes: &'static [u8], index: u32) -> Option { + if let Some(id) = self.fonts.get(name) { + return Some(id.clone()); + } + let mut warnings = Vec::new(); + let parsed = ParsedFont::from_bytes(bytes, index as usize, &mut warnings)?; + let id = self.doc.add_font(&parsed); + self.fonts.insert(name, id.clone()); + Some(id) + } + + /// Serialize the document. + pub fn finish(mut self) -> Vec { + let page = PdfPage::new( + printpdf::Mm::from(Pt(self.width)), + printpdf::Mm::from(Pt(self.height)), + std::mem::take(&mut self.ops), + ); + self.doc.with_pages(vec![page]); + let mut warnings = Vec::new(); + self.doc.save(&PdfSaveOptions::default(), &mut warnings) + } +} + +impl Canvas for PdfCanvas { + fn fill_rect(&mut self, rect: Rect, color: Color32) { + let corners = [ + rect.left_bottom(), + rect.right_bottom(), + rect.right_top(), + rect.left_top(), + ]; + let points = corners + .iter() + .map(|p| LinePoint { + p: self.point(*p), + bezier: false, + }) + .collect(); + self.ops.push(Op::SetFillColor { col: rgb(color) }); + self.ops.push(Op::DrawPolygon { + polygon: Polygon { + rings: vec![PolygonRing { points }], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }, + }); + } + + fn polyline(&mut self, points: &[Pos2], width: f32, color: Color32, dash: Option<[f32; 2]>) { + if points.len() < 2 { + return; + } + self.ops.push(Op::SetOutlineColor { col: rgb(color) }); + self.ops.push(Op::SetOutlineThickness { pt: Pt(width) }); + if let Some([on, off]) = dash { + self.ops.push(Op::SetLineDashPattern { + dash: LineDashPattern::new(0.0, &[on, off]), + }); + } + let line = Line { + points: points + .iter() + .map(|p| LinePoint { + p: self.point(*p), + bezier: false, + }) + .collect(), + is_closed: false, + }; + self.ops.push(Op::DrawLine { line }); + if dash.is_some() { + self.ops.push(Op::SetLineDashPattern { + dash: LineDashPattern::solid(), + }); + } + } + + fn text(&mut self, pos: Pos2, s: &str, face: Face, size: f32, color: Color32, align: HAlign) { + let (runs, width) = text::layout(s, face, size); + let x = match align { + HAlign::Left => pos.x, + HAlign::Center => pos.x - width / 2.0, + HAlign::Right => pos.x - width, + }; + self.ops.push(Op::SetFillColor { col: rgb(color) }); + for run in runs { + let Some(font) = self.font(run.font_name, run.font, run.index) else { + continue; + }; + let origin = self.point(Pos2::new(x + run.x, pos.y)); + self.ops.push(Op::StartTextSection); + self.ops.push(Op::SetFont { + font: PdfFontHandle::External(font), + size: Pt(size), + }); + self.ops.push(Op::SetTextCursor { pos: origin }); + self.ops.push(Op::ShowText { + items: vec![TextItem::Text(run.text)], + }); + self.ops.push(Op::EndTextSection); + } + } + + fn image(&mut self, rect: Rect, image: &RgbaImage) { + let raw = RawImage { + pixels: RawImageData::U8(image.as_raw().clone()), + width: image.width() as usize, + height: image.height() as usize, + data_format: RawImageFormat::RGBA8, + tag: Vec::new(), + }; + let id = self.doc.add_image(&raw); + // dpi maps the image's pixel width onto the rect's width in points. + let dpi = image.width() as f32 * 72.0 / rect.width().max(1.0); + self.ops.push(Op::UseXobject { + id, + transform: XObjectTransform { + translate_x: Some(Pt(rect.left())), + translate_y: Some(Pt(self.height - rect.bottom())), + dpi: Some(dpi), + ..Default::default() + }, + }); + } +} diff --git a/src/ui/export_sheet/png.rs b/src/ui/export_sheet/png.rs new file mode 100644 index 00000000..67533894 --- /dev/null +++ b/src/ui/export_sheet/png.rs @@ -0,0 +1,145 @@ +//! PNG back end: vello_cpu rasterizes rectangles, strokes and glyph outlines; images (the +//! lockup) are composited after rendering. + +use ::image::{Rgba, RgbaImage}; +use eframe::egui::{Color32, Pos2, Rect}; +use vello_cpu::color::{AlphaColor, Srgb}; +use vello_cpu::kurbo::{self, Affine, BezPath, Stroke}; +use vello_cpu::{Pixmap, RenderContext, Resources}; + +use super::canvas::{Canvas, Face, HAlign}; +use super::text; + +pub struct PngCanvas { + ctx: RenderContext, + width: u16, + height: u16, + scale: f32, + images: Vec<(Rect, RgbaImage)>, +} + +fn paint(c: Color32) -> AlphaColor { + let [r, g, b, a] = c.to_srgba_unmultiplied(); + AlphaColor::from_rgba8(r, g, b, a) +} + +impl PngCanvas { + /// A canvas of `width` × `height` points rendered at `scale` pixels per point. + pub fn new(width: f32, height: f32, scale: f32) -> Self { + let w = (width * scale).round() as u16; + let h = (height * scale).round() as u16; + let mut ctx = RenderContext::new(w, h); + ctx.set_transform(Affine::scale(scale as f64)); + Self { + ctx, + width: w, + height: h, + scale, + images: Vec::new(), + } + } + + /// Render and return straight-alpha RGBA pixels. + pub fn finish(mut self) -> RgbaImage { + let mut pixmap = Pixmap::new(self.width, self.height); + self.ctx.flush(); + self.ctx.render(&mut pixmap, &mut Resources::new()); + let data = pixmap.data_as_u8_slice(); + let mut out = RgbaImage::new(self.width as u32, self.height as u32); + for (px, chunk) in out.pixels_mut().zip(data.chunks_exact(4)) { + let a = chunk[3]; + let un = |c: u8| { + if a == 0 { + 0 + } else { + ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 + } + }; + *px = Rgba([un(chunk[0]), un(chunk[1]), un(chunk[2]), a]); + } + for (rect, image) in &self.images { + composite(&mut out, image, *rect, self.scale); + } + out + } +} + +/// Alpha-blend `src` into `dst` over `rect` (points), resampled with a Catmull-Rom filter so +/// the lockup's thin strokes survive the downscale. +fn composite(dst: &mut RgbaImage, src: &RgbaImage, rect: Rect, scale: f32) { + let x0 = (rect.left() * scale).round() as i64; + let y0 = (rect.top() * scale).round() as i64; + let w = (rect.width() * scale).round().max(1.0) as i64; + let h = (rect.height() * scale).round().max(1.0) as i64; + let resized = ::image::imageops::resize( + src, + w as u32, + h as u32, + ::image::imageops::FilterType::CatmullRom, + ); + let src = &resized; + for dy in 0..h { + for dx in 0..w { + let (x, y) = (x0 + dx, y0 + dy); + if x < 0 || y < 0 || x >= dst.width() as i64 || y >= dst.height() as i64 { + continue; + } + let sx = (dx * src.width() as i64 / w) as u32; + let sy = (dy * src.height() as i64 / h) as u32; + let s = src.get_pixel(sx.min(src.width() - 1), sy.min(src.height() - 1)); + let a = s[3] as f32 / 255.0; + let d = dst.get_pixel_mut(x as u32, y as u32); + for c in 0..3 { + d[c] = (s[c] as f32 * a + d[c] as f32 * (1.0 - a)).round() as u8; + } + d[3] = ((a + d[3] as f32 / 255.0 * (1.0 - a)) * 255.0).round() as u8; + } + } +} + +impl Canvas for PngCanvas { + fn fill_rect(&mut self, rect: Rect, color: Color32) { + self.ctx.set_paint(paint(color)); + self.ctx.fill_rect(&kurbo::Rect::new( + rect.left() as f64, + rect.top() as f64, + rect.right() as f64, + rect.bottom() as f64, + )); + } + + fn polyline(&mut self, points: &[Pos2], width: f32, color: Color32, dash: Option<[f32; 2]>) { + if points.len() < 2 { + return; + } + let mut path = BezPath::new(); + path.move_to((points[0].x as f64, points[0].y as f64)); + for p in &points[1..] { + path.line_to((p.x as f64, p.y as f64)); + } + let mut stroke = Stroke::new(width as f64); + if let Some([on, off]) = dash { + stroke = stroke.with_dashes(0.0, [on as f64, off as f64]); + } + self.ctx.set_stroke(stroke); + self.ctx.set_paint(paint(color)); + self.ctx.stroke_path(&path); + } + + fn text(&mut self, pos: Pos2, s: &str, face: Face, size: f32, color: Color32, align: HAlign) { + let (runs, width) = text::layout(s, face, size); + let x = match align { + HAlign::Left => pos.x, + HAlign::Center => pos.x - width / 2.0, + HAlign::Right => pos.x - width, + }; + let mut path = BezPath::new(); + text::outlines(&runs, size, x, pos.y, &mut path); + self.ctx.set_paint(paint(color)); + self.ctx.fill_path(&path); + } + + fn image(&mut self, rect: Rect, image: &RgbaImage) { + self.images.push((rect, image.clone())); + } +} diff --git a/src/ui/export_sheet/text.rs b/src/ui/export_sheet/text.rs new file mode 100644 index 00000000..fe6d9fa9 --- /dev/null +++ b/src/ui/export_sheet/text.rs @@ -0,0 +1,204 @@ +//! Text layout for exports: glyph lookup, advances and outlines from the bundled fonts with +//! skrifa. Layout is plain left-to-right with per-glyph advances (no kerning or shaping), which +//! suits the labels, numbers and names an export prints. A character missing from the chosen +//! face falls back to JetBrains Mono (Greek, Cyrillic), the Noto script faces, then a system CJK +//! face (`fonts::export_cjk_face`). A character no face has prints as `?`, never as `.notdef`: +//! printpdf's `.notdef` width differs from skrifa's and shifted the text after it. + +use skrifa::instance::{LocationRef, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::{FontRef, GlyphId, MetadataProvider}; +use vello_cpu::kurbo::BezPath; + +use super::canvas::Face; +use crate::ui::fonts; + +const FALLBACKS: [&str; 4] = [ + "JetBrainsMono-Regular", + "NotoSansDevanagari", + "NotoSansBengali", + "NotoSansArabic", +]; + +/// The system CJK face's key in `Run::font_name`. +pub const SYSTEM_CJK: &str = "system-cjk"; + +/// Glyphs that share one font, starting at `x` (points from the text start). +pub struct Run { + pub font_name: &'static str, + pub font: &'static [u8], + /// Face index inside `font` (non-zero only for a `.ttc` collection). + pub index: u32, + pub text: String, + pub glyphs: Vec<(GlyphId, f32)>, + pub x: f32, +} + +fn font_ref(bytes: &'static [u8], index: u32) -> Option> { + FontRef::from_index(bytes, index).ok() +} + +type ChainFace = (&'static str, &'static [u8], u32, FontRef<'static>); + +/// Lay out `text` at `size` points: runs by font, and the total advance width. +pub fn layout(text: &str, face: Face, size: f32) -> (Vec, f32) { + let mut chain: Vec = std::iter::once(face.font_name()) + .chain(FALLBACKS) + .filter_map(|name| { + let bytes = fonts::face_bytes(name)?; + Some((name, bytes, 0, font_ref(bytes, 0)?)) + }) + .collect(); + let needs_cjk = text.chars().any(|ch| { + !chain + .iter() + .any(|(_, _, _, font)| font.charmap().map(ch).is_some()) + }); + if needs_cjk + && let Some((bytes, index)) = fonts::export_cjk_face() + && let Some(font) = font_ref(bytes, index) + { + chain.push((SYSTEM_CJK, bytes, index, font)); + } + let mut runs: Vec = Vec::new(); + let mut x = 0.0f32; + for ch in text.chars() { + let found = chain.iter().find_map(|(name, bytes, index, font)| { + font.charmap() + .map(ch) + .map(|g| (*name, *bytes, *index, font, g, ch)) + }); + // Nothing has it: a `?` from the primary face keeps the gap visible and the widths true. + let pick = found.or_else(|| { + chain + .first() + .and_then(|(n, b, i, f)| f.charmap().map('?').map(|g| (*n, *b, *i, f, g, '?'))) + }); + let Some((name, bytes, index, font, gid, ch)) = pick else { + continue; + }; + let advance = font + .glyph_metrics(Size::new(size), LocationRef::default()) + .advance_width(gid) + .unwrap_or(size * 0.5); + match runs.last_mut() { + Some(run) if run.font_name == name => { + run.glyphs.push((gid, x - run.x)); + run.text.push(ch); + } + _ => runs.push(Run { + font_name: name, + font: bytes, + index, + text: ch.to_string(), + glyphs: vec![(gid, 0.0)], + x, + }), + } + x += advance; + } + (runs, x) +} + +/// The advance width of `text` at `size` points. +pub fn width(text: &str, face: Face, size: f32) -> f32 { + layout(text, face, size).1 +} + +struct PathPen<'a> { + path: &'a mut BezPath, + ox: f64, + oy: f64, +} + +impl OutlinePen for PathPen<'_> { + fn move_to(&mut self, x: f32, y: f32) { + self.path.move_to((self.ox + x as f64, self.oy - y as f64)); + } + fn line_to(&mut self, x: f32, y: f32) { + self.path.line_to((self.ox + x as f64, self.oy - y as f64)); + } + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.path.quad_to( + (self.ox + cx0 as f64, self.oy - cy0 as f64), + (self.ox + x as f64, self.oy - y as f64), + ); + } + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.path.curve_to( + (self.ox + cx0 as f64, self.oy - cy0 as f64), + (self.ox + cx1 as f64, self.oy - cy1 as f64), + (self.ox + x as f64, self.oy - y as f64), + ); + } + fn close(&mut self) { + self.path.close_path(); + } +} + +/// Append the outlines of `runs` at `size` points to `path`, with the text origin (baseline, +/// left) at `(x, y)`. +pub fn outlines(runs: &[Run], size: f32, x: f32, y: f32, path: &mut BezPath) { + for run in runs { + let Some(font) = font_ref(run.font, run.index) else { + continue; + }; + let outlines = font.outline_glyphs(); + for (gid, gx) in &run.glyphs { + let Some(glyph) = outlines.get(*gid) else { + continue; + }; + let mut pen = PathPen { + path, + ox: (x + run.x + gx) as f64, + oy: y as f64, + }; + let _ = glyph.draw( + DrawSettings::unhinted(Size::new(size), LocationRef::default()), + &mut pen, + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layout_measures_and_falls_back() { + let (runs, w) = layout("RPM 6297", Face::Mono, 10.0); + assert_eq!(runs.len(), 1); + // JetBrains Mono is monospaced: 8 glyphs × 600/1000 em. + assert!((w - 48.0).abs() < 0.5, "{w}"); + // Outfit has no Cyrillic: those letters come from the mono fallback. + let (runs, _) = layout("Ab Жд", Face::Ui, 10.0); + assert!(runs.iter().any(|r| r.font_name == "JetBrainsMono-Regular")); + } + + #[test] + fn cjk_uses_the_system_face_when_there_is_one() { + let (runs, _) = layout("ログ 回転数", Face::Ui, 10.0); + if fonts::export_cjk_face().is_some() { + assert!(runs.iter().any(|r| r.font_name == SYSTEM_CJK)); + assert!(runs.iter().all(|r| !r.text.contains('?'))); + } + } + + #[test] + fn a_character_no_face_has_prints_as_a_question_mark() { + // U+E000 is a private-use code point: no bundled or system face maps it. + let (runs, w) = layout("A\u{E000}B", Face::Ui, 10.0); + let text: String = runs.iter().map(|r| r.text.as_str()).collect(); + assert_eq!(text, "A?B"); + assert!(w > 0.0); + } + + #[test] + fn outlines_produce_geometry() { + let (runs, _) = layout("Ultra", Face::Display, 20.0); + let mut path = BezPath::new(); + outlines(&runs, 20.0, 0.0, 20.0, &mut path); + assert!(path.elements().len() > 20); + } +} diff --git a/src/ui/files_panel.rs b/src/ui/files_panel.rs index 66481d95..48a43ac1 100644 --- a/src/ui/files_panel.rs +++ b/src/ui/files_panel.rs @@ -1,11 +1,21 @@ //! Files panel - file management, loading, and file list. +//! +//! The empty state is the DropZone (`design/design-system/components/DropZone/README.md`): a +//! dashed `line_control` border on `bg_panel` with the perspective floor rising from the bottom +//! edge, a Saira italic title, one primary "Select a file" and the extensions in mono. While a +//! file is dragged over the window the border goes solid `cyan` on `cyan_soft`. -use eframe::egui; +use eframe::egui::{self, Align2, CornerRadius, FontId, Rect, RichText, Stroke, StrokeKind}; use rust_i18n::t; use crate::app::UltraLogApp; use crate::state::{LoadingState, SUPPORTED_EXTENSIONS}; -use crate::ui::icons::draw_upload_icon; +use crate::ui::components::{self, Kind, icon}; +use crate::ui::fonts::{self, style}; +use crate::ui::theme::{ + paint, + tokens::{radius, space, stroke}, +}; impl UltraLogApp { /// Render the files panel content (called from side_panel.rs) @@ -34,18 +44,18 @@ impl UltraLogApp { self.render_add_file_button(ui); }); } else if !is_loading { - // Nice drop zone when no files loaded self.render_drop_zone_card(ui); } } /// Render the list of loaded files fn render_file_list(&mut self, ui: &mut egui::Ui) { + let t = self.tokens; let mut file_to_remove: Option = None; let mut file_to_switch: Option = None; // Collect file info upfront to avoid borrow issues - let file_info: Vec<(String, bool, String, usize, usize)> = self + let file_info: Vec<(String, bool, &'static str, String, usize, usize)> = self .files .iter() .enumerate() @@ -53,6 +63,7 @@ impl UltraLogApp { ( file.name.clone(), self.selected_file == Some(i), + file.ecu_type.short_code(), file.ecu_type.name().to_string(), file.log.channels.len(), file.log.data.len(), @@ -61,93 +72,67 @@ impl UltraLogApp { .collect(); ui.label( - egui::RichText::new(t!("files.loaded_files", count = file_info.len())) - .size(self.scaled_font(13.0)) - .color(egui::Color32::GRAY), + RichText::new(t!("files.loaded_files", count = file_info.len())) + .text_style(fonts::text_style(style::LABEL)) + .color(t.ink_muted), ); - ui.add_space(4.0); + ui.add_space(space::SPACE_1); - for (i, (file_name, is_selected, ecu_name, channel_count, data_count)) in + for (i, (file_name, is_selected, code, ecu_name, channel_count, data_count)) in file_info.iter().enumerate() { - // File card with selection highlight - let card_bg = if *is_selected { - egui::Color32::from_rgb(50, 55, 45) // Subtle olive tint for selected - } else { - egui::Color32::from_rgb(40, 40, 40) - }; - - let card_border = if *is_selected { - egui::Color32::from_rgb(113, 120, 78) // Olive green for selected - } else { - egui::Color32::from_rgb(55, 55, 55) - }; - + let border = if *is_selected { t.cyan } else { t.line }; egui::Frame::NONE - .fill(card_bg) - .stroke(egui::Stroke::new(1.0, card_border)) - .corner_radius(6) - .inner_margin(egui::Margin::symmetric(10, 8)) + .fill(if *is_selected { + t.bg_active + } else { + t.bg_raised + }) + .stroke(Stroke::new(stroke::STROKE_HAIRLINE, border)) + .corner_radius(CornerRadius::same(radius::RADIUS_MD)) + .inner_margin(egui::Margin::symmetric( + space::SPACE_3 as i8, + space::SPACE_2 as i8, + )) .show(ui, |ui| { ui.horizontal(|ui| { - // File name (clickable) + components::ecu_badge(ui, code).on_hover_text(ecu_name.as_str()); let response = ui.add( - egui::Label::new( - egui::RichText::new(file_name) - .size(self.scaled_font(14.0)) - .color(if *is_selected { - egui::Color32::WHITE - } else { - egui::Color32::LIGHT_GRAY - }), - ) + egui::Label::new(RichText::new(file_name).color(if *is_selected { + t.ink + } else { + t.ink_muted + })) + .truncate() .sense(egui::Sense::click()), ); - if response.clicked() { file_to_switch = Some(i); } - if response.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } - // Spacer ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Delete button - let close_btn = ui.add( - egui::Label::new( - egui::RichText::new("x") - .size(self.scaled_font(16.0)) - .color(egui::Color32::from_rgb(150, 150, 150)), - ) - .sense(egui::Sense::click()), - ); - - if close_btn.clicked() { + if components::icon_button(ui, icon::X, "Remove log").clicked() { file_to_remove = Some(i); } - - if close_btn.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } }); }); - // ECU type and data info ui.label( - egui::RichText::new(t!( + RichText::new(t!( "files.file_info", ecu = ecu_name, channels = channel_count, points = data_count )) - .size(self.scaled_font(11.0)) - .color(egui::Color32::GRAY), + .text_style(fonts::text_style(style::DATA_SM)) + .color(t.ink_subtle), ); }); - ui.add_space(4.0); + ui.add_space(space::SPACE_1); } // Handle deferred file switching @@ -162,85 +147,65 @@ impl UltraLogApp { /// Render the "Add File" button fn render_add_file_button(&mut self, ui: &mut egui::Ui) { - let primary_color = egui::Color32::from_rgb(113, 120, 78); // Olive green - - let button_response = egui::Frame::NONE - .fill(primary_color) - .corner_radius(6) - .inner_margin(egui::vec2(16.0, 8.0)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new("+") - .color(egui::Color32::WHITE) - .size(self.scaled_font(16.0)), - ); - ui.label( - egui::RichText::new(t!("files.add_file")) - .color(egui::Color32::WHITE) - .size(self.scaled_font(14.0)), - ); - }); - }); - - if button_response - .response - .interact(egui::Sense::click()) - .clicked() + if components::button(ui, Kind::Secondary, Some(icon::PLUS), t!("files.add_file")).clicked() && let Some(path) = rfd::FileDialog::new() .add_filter("Log Files", crate::state::SUPPORTED_EXTENSIONS) .pick_file() { self.start_loading_file(path); } - - if button_response.response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } } /// Render the drop zone card for when no files are loaded fn render_drop_zone_card(&mut self, ui: &mut egui::Ui) { - let primary_color = egui::Color32::from_rgb(113, 120, 78); // Olive green - let card_bg = egui::Color32::from_rgb(45, 45, 45); - let text_gray = egui::Color32::from_rgb(150, 150, 150); - - ui.add_space(20.0); - - // Drop zone card - egui::Frame::NONE - .fill(card_bg) - .corner_radius(12) - .inner_margin(20.0) + let t = self.tokens; + let dragging = ui.ctx().input(|i| !i.raw.hovered_files.is_empty()); + + ui.add_space(space::SPACE_3); + let frame = egui::Frame::NONE.inner_margin(egui::Margin::symmetric( + space::SPACE_4 as i8, + space::SPACE_6 as i8, + )); + // Reserve slots so the fill and the floor paint under the content. + let fill_slot = ui.painter().add(egui::Shape::Noop); + let floor_slot = ui.painter().add(egui::Shape::Noop); + let response = frame .show(ui, |ui| { + ui.set_width(ui.available_width()); ui.vertical_centered(|ui| { - // Upload icon - let icon_size = 32.0; - let (icon_rect, _) = ui.allocate_exact_size( - egui::vec2(icon_size, icon_size), - egui::Sense::hover(), + // Upload mark: cyan ring with the upload icon + let size = 44.0; + let (mark, _) = + ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover()); + ui.painter().circle_stroke( + mark.center(), + size / 2.0 - 1.0, + Stroke::new(1.5, t.cyan), + ); + ui.painter().text( + mark.center(), + Align2::CENTER_CENTER, + icon::UPLOAD_SIMPLE, + FontId::proportional(20.0), + t.cyan, ); - draw_upload_icon(ui, icon_rect.center(), icon_size, primary_color); - - ui.add_space(12.0); - - // Select file button - let button_response = egui::Frame::NONE - .fill(primary_color) - .corner_radius(6) - .inner_margin(egui::vec2(16.0, 8.0)) - .show(ui, |ui| { - ui.label( - egui::RichText::new(t!("files.select_file")) - .color(egui::Color32::WHITE) - .size(self.scaled_font(14.0)), - ); - }); - if button_response - .response - .interact(egui::Sense::click()) - .clicked() + ui.add_space(space::SPACE_2); + ui.label( + RichText::new(t!("files.drop_file_here")) + .text_style(fonts::text_style(style::TITLE)) + .family(egui::FontFamily::Name(fonts::DISPLAY_BOLD_ITALIC.into())) + .color(t.ink), + ); + ui.add_space(space::SPACE_2); + + if components::button( + ui, + Kind::Primary, + Some(icon::UPLOAD_SIMPLE), + t!("files.select_file"), + ) + .clicked() && let Some(path) = rfd::FileDialog::new() .add_filter("Log Files", crate::state::SUPPORTED_EXTENSIONS) .pick_file() @@ -248,39 +213,55 @@ impl UltraLogApp { self.start_loading_file(path); } - if button_response.response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - - ui.add_space(12.0); - - ui.label( - egui::RichText::new(t!("files.or")) - .color(text_gray) - .size(self.scaled_font(12.0)), - ); - - ui.add_space(8.0); - - ui.label( - egui::RichText::new(t!("files.drop_file_here")) - .color(egui::Color32::LIGHT_GRAY) - .size(self.scaled_font(13.0)), - ); - - ui.add_space(12.0); - + ui.add_space(space::SPACE_3); let extensions_text = SUPPORTED_EXTENSIONS .iter() .map(|ext| ext.to_uppercase()) .collect::>() - .join(" \u{2022} "); + .join(" \u{00B7} "); ui.label( - egui::RichText::new(extensions_text) - .color(text_gray) - .size(self.scaled_font(11.0)), + RichText::new(extensions_text) + .text_style(fonts::text_style(style::DATA_SM)) + .color(t.ink_subtle), ); }); - }); + }) + .response; + + let rect = response.rect; + let corner = CornerRadius::same(radius::RADIUS_LG); + ui.painter().set( + fill_slot, + egui::Shape::rect_filled( + rect, + corner, + if dragging { t.cyan_soft } else { t.bg_panel }, + ), + ); + let floor = Rect::from_min_max( + egui::pos2(rect.left() + 1.0, rect.top() + rect.height() * 0.62), + rect.right_bottom() - egui::vec2(1.0, 1.0), + ); + ui.painter().set( + floor_slot, + egui::Shape::Vec(paint::floor(floor, t.grid_floor)), + ); + + if dragging { + ui.painter().rect_stroke( + rect, + corner, + Stroke::new(stroke::STROKE_INDICATOR, t.cyan), + StrokeKind::Inside, + ); + } else { + paint::dashed_rect( + ui.painter(), + rect.shrink(0.5), + f32::from(radius::RADIUS_LG), + Stroke::new(stroke::STROKE_HAIRLINE, t.line_control), + 4.0, + ); + } } } diff --git a/src/ui/fonts.rs b/src/ui/fonts.rs new file mode 100644 index 00000000..5f778b70 --- /dev/null +++ b/src/ui/fonts.rs @@ -0,0 +1,467 @@ +//! Font families and the type scale. +//! +//! Three brand faces, each with one job (design/design-system/README.md → Typography): Saira for +//! titles and brand moments, Outfit for the interface, JetBrains Mono for numbers. egui families +//! have no weight axis, so every weight the type scale uses is its own named family, built from a +//! static instance in `assets/fonts/` (see `scripts/fonts/build_static_fonts.sh`). +//! +//! Every family falls back to egui's default fonts (Greek, Cyrillic, emoji), then the bundled +//! Noto faces for Devanagari, Bengali and Arabic, then a tiny CJK subset that covers only the +//! language picker's own names, then, for the `ja` / `zh-CN` locales, a CJK font from the OS. + +use std::collections::BTreeMap; + +use eframe::egui::{self, FontData, FontDefinitions, FontFamily, FontId, TextStyle}; + +use crate::i18n::Language; +use crate::ui::theme::tokens::text; + +/// Outfit 500: labels, buttons, tabs. +pub const UI_MEDIUM: &str = "ui-medium"; +/// Outfit 600: section headings, card titles. +pub const UI_SEMIBOLD: &str = "ui-semibold"; +/// Saira 600: panel and window titles, the overline. +pub const DISPLAY: &str = "display"; +/// Saira 700 italic: headlines (brand moments only). +pub const DISPLAY_BOLD_ITALIC: &str = "display-bold-italic"; +/// Saira 800 italic: the display size (brand moments only). +pub const DISPLAY_BLACK_ITALIC: &str = "display-black-italic"; +/// JetBrains Mono 500: large data readouts. +pub const MONO_MEDIUM: &str = "mono-medium"; +/// Phosphor fill: transport glyphs (play, stop) and the favourite star only. +pub const PHOSPHOR_FILL: &str = "phosphor-fill"; + +/// Phosphor regular sits right after each brand face, so an icon codepoint in any label +/// resolves to the outline icon set. +const PHOSPHOR: &str = "phosphor"; + +/// Named text styles beyond egui's five (Body, Button, Small, Heading, Monospace). +pub mod style { + pub const TITLE: &str = "title"; + pub const OVERLINE: &str = "overline"; + pub const LABEL: &str = "label"; + pub const HEADLINE: &str = "headline"; + pub const DISPLAY: &str = "display"; + pub const DATA: &str = "data"; + pub const DATA_LG: &str = "data_lg"; + pub const DATA_SM: &str = "data_sm"; +} + +/// `TextStyle::Name` for one of the [`style`] names. +pub fn text_style(name: &str) -> TextStyle { + TextStyle::Name(name.into()) +} + +const BRAND_FACES: [(&str, &[u8]); 8] = [ + ( + "Outfit-Regular", + include_bytes!("../../assets/fonts/Outfit-Regular.ttf"), + ), + ( + "Outfit-Medium", + include_bytes!("../../assets/fonts/Outfit-Medium.ttf"), + ), + ( + "Outfit-SemiBold", + include_bytes!("../../assets/fonts/Outfit-SemiBold.ttf"), + ), + ( + "Saira-SemiBold", + include_bytes!("../../assets/fonts/Saira-SemiBold.ttf"), + ), + ( + "Saira-BoldItalic", + include_bytes!("../../assets/fonts/Saira-BoldItalic.ttf"), + ), + ( + "Saira-ExtraBoldItalic", + include_bytes!("../../assets/fonts/Saira-ExtraBoldItalic.ttf"), + ), + ( + "JetBrainsMono-Regular", + include_bytes!("../../assets/fonts/JetBrainsMono-Regular.ttf"), + ), + ( + "JetBrainsMono-Medium", + include_bytes!("../../assets/fonts/JetBrainsMono-Medium.ttf"), + ), +]; + +/// Bundled script fallbacks, in fallback order. +const SCRIPT_FACES: [(&str, &[u8]); 4] = [ + ( + "NotoSansDevanagari", + include_bytes!("../../assets/fonts/NotoSansDevanagari-Regular.ttf"), + ), + ( + "NotoSansBengali", + include_bytes!("../../assets/fonts/NotoSansBengali-Regular.ttf"), + ), + ( + "NotoSansArabic", + include_bytes!("../../assets/fonts/NotoSansArabic-Regular.ttf"), + ), + ( + "NotoSansSC-LanguageNames", + include_bytes!("../../assets/fonts/NotoSansSC-LanguageNames.ttf"), + ), +]; + +/// The bytes of a bundled face by its name in `BRAND_FACES` / `SCRIPT_FACES` (exports draw +/// text with the same fonts the UI uses). +pub fn face_bytes(name: &str) -> Option<&'static [u8]> { + BRAND_FACES + .iter() + .chain(SCRIPT_FACES.iter()) + .find(|(n, _)| *n == name) + .map(|(_, bytes)| *bytes) +} + +const SYSTEM_CJK: &str = "system-cjk"; + +/// Font definitions for `language`. Only `ja` and `zh-CN` read a CJK font from disk. +pub fn definitions(language: Language) -> FontDefinitions { + let system_cjk = system_cjk_font(language); + build_definitions(system_cjk) +} + +fn build_definitions(system_cjk: Option) -> FontDefinitions { + let mut fonts = FontDefinitions::default(); + let egui_proportional = fonts.families[&FontFamily::Proportional].clone(); + let egui_monospace = fonts.families[&FontFamily::Monospace].clone(); + + for (name, bytes) in BRAND_FACES.iter().chain(SCRIPT_FACES.iter()) { + fonts + .font_data + .insert((*name).to_owned(), FontData::from_static(bytes).into()); + } + let mut scripts: Vec = SCRIPT_FACES.iter().map(|(n, _)| (*n).to_owned()).collect(); + if let Some(data) = system_cjk { + fonts.font_data.insert(SYSTEM_CJK.to_owned(), data.into()); + scripts.push(SYSTEM_CJK.to_owned()); + } + + fonts.font_data.insert( + PHOSPHOR.to_owned(), + FontData::from_static(egui_phosphor::Variant::Regular.font_bytes()).into(), + ); + fonts.font_data.insert( + PHOSPHOR_FILL.to_owned(), + FontData::from_static(egui_phosphor::Variant::Fill.font_bytes()).into(), + ); + + let with_tail = |face: &str, tail: &[String]| -> Vec { + [face.to_owned(), PHOSPHOR.to_owned()] + .into_iter() + .chain(tail.iter().cloned()) + .chain(scripts.iter().cloned()) + .collect() + }; + + let families = [ + ( + FontFamily::Proportional, + "Outfit-Regular", + &egui_proportional, + ), + ( + FontFamily::Monospace, + "JetBrainsMono-Regular", + &egui_monospace, + ), + ( + FontFamily::Name(UI_MEDIUM.into()), + "Outfit-Medium", + &egui_proportional, + ), + ( + FontFamily::Name(UI_SEMIBOLD.into()), + "Outfit-SemiBold", + &egui_proportional, + ), + ( + FontFamily::Name(DISPLAY.into()), + "Saira-SemiBold", + &egui_proportional, + ), + ( + FontFamily::Name(DISPLAY_BOLD_ITALIC.into()), + "Saira-BoldItalic", + &egui_proportional, + ), + ( + FontFamily::Name(DISPLAY_BLACK_ITALIC.into()), + "Saira-ExtraBoldItalic", + &egui_proportional, + ), + ( + FontFamily::Name(MONO_MEDIUM.into()), + "JetBrainsMono-Medium", + &egui_monospace, + ), + ]; + for (family, face, tail) in families { + fonts.families.insert(family, with_tail(face, tail)); + } + fonts.families.insert( + FontFamily::Name(PHOSPHOR_FILL.into()), + vec![PHOSPHOR_FILL.to_owned()], + ); + fonts +} + +/// The type scale (`design/tokens/tokens.json` → type) as egui text styles, sizes multiplied by +/// the user's font scale and rounded like [`crate::app::UltraLogApp::scaled_font`]. +pub fn text_styles(scale: f32) -> BTreeMap { + let id = |(_, size, _): (&str, f32, f32), family: FontFamily| { + FontId::new((size * scale).round(), family) + }; + let named = |name: &str| FontFamily::Name(name.into()); + BTreeMap::from([ + (TextStyle::Body, id(text::BODY, FontFamily::Proportional)), + (TextStyle::Button, id(text::LABEL, named(UI_MEDIUM))), + ( + TextStyle::Small, + id(text::CAPTION, FontFamily::Proportional), + ), + (TextStyle::Heading, id(text::HEADING, named(UI_SEMIBOLD))), + (TextStyle::Monospace, id(text::DATA, FontFamily::Monospace)), + (text_style(style::TITLE), id(text::TITLE, named(DISPLAY))), + ( + text_style(style::OVERLINE), + id(text::OVERLINE, named(DISPLAY)), + ), + (text_style(style::LABEL), id(text::LABEL, named(UI_MEDIUM))), + ( + text_style(style::HEADLINE), + id(text::HEADLINE, named(DISPLAY_BOLD_ITALIC)), + ), + ( + text_style(style::DISPLAY), + id(text::DISPLAY, named(DISPLAY_BLACK_ITALIC)), + ), + ( + text_style(style::DATA), + id(text::DATA, FontFamily::Monospace), + ), + ( + text_style(style::DATA_LG), + id(text::DATA_LG, named(MONO_MEDIUM)), + ), + ( + text_style(style::DATA_SM), + id(text::DATA_SM, FontFamily::Monospace), + ), + ]) +} + +/// System CJK font candidates per locale: `(path, face index in a .ttc)`. The first one that +/// exists is used. Noto Sans CJK `.ttc` files order their faces JP, KR, SC, TC, HK. +fn cjk_candidates(language: Language) -> &'static [(&'static str, u32)] { + match language { + Language::Japanese => { + #[cfg(target_os = "macos")] + { + &[ + ("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", 0), + ("/System/Library/Fonts/Hiragino Sans GB.ttc", 0), + ] + } + #[cfg(target_os = "windows")] + { + &[ + ("C:\\Windows\\Fonts\\YuGothR.ttc", 0), + ("C:\\Windows\\Fonts\\meiryo.ttc", 0), + ("C:\\Windows\\Fonts\\msgothic.ttc", 0), + ] + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + &[ + ("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 0), + ("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", 0), + ( + "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", + 0, + ), + ("/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", 0), + ] + } + } + Language::ChineseSimplified => { + #[cfg(target_os = "macos")] + { + &[ + ("/System/Library/Fonts/Hiragino Sans GB.ttc", 0), + ("/System/Library/Fonts/STHeiti Light.ttc", 0), + ] + } + #[cfg(target_os = "windows")] + { + &[ + ("C:\\Windows\\Fonts\\msyh.ttc", 0), + ("C:\\Windows\\Fonts\\simsun.ttc", 0), + ] + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + &[ + ("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 2), + ("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", 2), + ( + "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", + 2, + ), + ("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", 0), + ] + } + } + _ => &[], + } +} + +/// A system CJK face for exports, whatever the UI language: Japanese candidates, then Chinese. +/// Read once and kept for the process (exports need `'static` bytes). `(bytes, face index)`. +pub fn export_cjk_face() -> Option<(&'static [u8], u32)> { + static FACE: std::sync::OnceLock> = std::sync::OnceLock::new(); + *FACE.get_or_init(|| { + cjk_candidates(Language::Japanese) + .iter() + .chain(cjk_candidates(Language::ChineseSimplified)) + .find_map(|(path, index)| { + let bytes = std::fs::read(path).ok()?; + let bytes: &'static [u8] = Box::leak(bytes.into_boxed_slice()); + Some((bytes, *index)) + }) + }) +} + +fn system_cjk_font(language: Language) -> Option { + for (path, index) in cjk_candidates(language) { + match std::fs::read(path) { + Ok(bytes) => { + tracing::info!("Loaded CJK font {path}"); + let mut data = FontData::from_owned(bytes); + data.index = *index; + return Some(data); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => tracing::warn!("Could not read CJK font {path}: {e}"), + } + } + if !cjk_candidates(language).is_empty() { + tracing::warn!( + "No system CJK font found for {}; text will show missing glyphs", + language.locale_code() + ); + } + None +} + +/// Install the fonts for `language` on `ctx`. +pub fn install(ctx: &egui::Context, language: Language) { + ctx.set_fonts(definitions(language)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_family_starts_with_its_brand_face_and_keeps_fallbacks() { + let fonts = build_definitions(None); + let first = |f: FontFamily| fonts.families[&f][0].clone(); + assert_eq!(first(FontFamily::Proportional), "Outfit-Regular"); + assert_eq!(first(FontFamily::Monospace), "JetBrainsMono-Regular"); + assert_eq!(first(FontFamily::Name(DISPLAY.into())), "Saira-SemiBold"); + for (family, list) in &fonts.families { + for name in list { + assert!(fonts.font_data.contains_key(name), "{name} has no data"); + } + if *family == FontFamily::Name(PHOSPHOR_FILL.into()) { + continue; // icon-only family + } + assert_eq!( + list[1], PHOSPHOR, + "{family:?}: icons resolve right after the face" + ); + assert!(list.iter().any(|n| n == "NotoSansDevanagari")); + assert!(list.iter().any(|n| n == "NotoSansSC-LanguageNames")); + // egui's defaults stay as fallbacks for Greek, Cyrillic and emoji. + assert!(list.len() > SCRIPT_FACES.len() + 2, "{list:?}"); + } + } + + /// Lay out one frame with `fonts` and report which characters of `text` have no glyph in + /// `family` (after its fallbacks). + fn missing_glyphs(fonts: FontDefinitions, family: FontFamily, text: &str) -> String { + let ctx = egui::Context::default(); + ctx.set_fonts(fonts); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); // no renderer in a test + let id = FontId::new(14.0, family); + ctx.fonts_mut(|f| text.chars().filter(|c| !f.has_glyph(&id, *c)).collect()) + } + + #[test] + fn every_locale_name_and_brand_glyph_renders_without_a_system_font() { + // The language picker shows every language in its own script, in every locale. + let names: String = Language::all().iter().map(|l| l.display_name()).collect(); + for (family, symbols) in [ + (FontFamily::Proportional, "ƒ−·°λ→✓"), + (FontFamily::Monospace, "−·°λ→"), + (FontFamily::Name(DISPLAY.into()), "ƒ−·°"), + ] { + let missing = missing_glyphs( + build_definitions(None), + family.clone(), + &format!("{names}{symbols}"), + ); + let missing: String = missing.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(missing.is_empty(), "{family:?} lacks {missing:?}"); + } + } + + #[test] + fn system_cjk_font_covers_the_ui_when_the_os_has_one() { + for (language, sample) in [ + (Language::Japanese, "設定ファイル表示"), + (Language::ChineseSimplified, "设置文件显示"), + ] { + let Some(data) = system_cjk_font(language) else { + continue; // No CJK font on this machine (e.g. a bare CI image). + }; + let missing = missing_glyphs( + build_definitions(Some(data)), + FontFamily::Proportional, + sample, + ); + assert!(missing.is_empty(), "{language:?} lacks {missing:?}"); + } + } + + #[test] + fn non_cjk_locales_read_no_system_font() { + assert!(cjk_candidates(Language::English).is_empty()); + assert!(cjk_candidates(Language::Hindi).is_empty()); + assert!(!cjk_candidates(Language::Japanese).is_empty()); + assert!(!cjk_candidates(Language::ChineseSimplified).is_empty()); + } + + #[test] + fn text_styles_follow_the_type_scale_and_font_scale() { + let s = text_styles(1.0); + assert_eq!(s[&TextStyle::Body].size, 14.0); + assert_eq!(s[&TextStyle::Button].size, 13.0); + assert_eq!(s[&TextStyle::Small].size, 12.0); + assert_eq!(s[&TextStyle::Heading].size, 15.0); + assert_eq!(s[&text_style(style::TITLE)].size, 18.0); + assert_eq!( + s[&text_style(style::TITLE)].family, + FontFamily::Name(DISPLAY.into()) + ); + assert_eq!(s[&TextStyle::Monospace].family, FontFamily::Monospace); + let large = text_styles(1.25); + assert_eq!(large[&TextStyle::Body].size, (14.0f32 * 1.25).round()); + } +} diff --git a/src/ui/formula_editor.rs b/src/ui/formula_editor.rs index f73f468f..acb56c00 100644 --- a/src/ui/formula_editor.rs +++ b/src/ui/formula_editor.rs @@ -11,6 +11,10 @@ use crate::computed::ComputedChannelTemplate; use crate::expression::{ build_channel_bindings, extract_channel_references, generate_preview, validate_formula, }; +use crate::ui::components::{self, Kind, Status}; +use crate::ui::fonts::{self, style}; +use crate::ui::formula_syntax; +use crate::ui::theme::tokens::space; impl UltraLogApp { /// Render the formula editor dialog @@ -28,7 +32,7 @@ impl UltraLogApp { t!("formula.new_computed_channel") }; - egui::Window::new(title) + let window_response = components::window(title) .open(&mut open) .resizable(true) .default_width(550.0) @@ -36,11 +40,16 @@ impl UltraLogApp { .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .order(egui::Order::Foreground) .show(ctx, |ui| { - ui.add_space(4.0); + let t = self.tokens; + let label = |text: std::borrow::Cow<'_, str>| { + egui::RichText::new(text) + .text_style(fonts::text_style(style::LABEL)) + .color(t.ink_muted) + }; // Name field ui.horizontal(|ui| { - ui.label(t!("formula.name")); + ui.label(label(t!("formula.name"))); ui.add( egui::TextEdit::singleline(&mut self.formula_editor_state.name) .hint_text(t!("formula.name_hint")) @@ -48,16 +57,23 @@ impl UltraLogApp { ); }); - ui.add_space(8.0); + ui.add_space(space::SPACE_2); - // Formula field - ui.label(t!("formula.formula")); + // Formula field, syntax coloured + ui.label(label(t!("formula.formula"))); + let data_font = fonts::text_style(style::DATA).resolve(ui.style()); + let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| { + let job = + formula_syntax::layout(text.as_str(), data_font.clone(), &t, wrap_width); + ui.fonts_mut(|f| f.layout_job(job)) + }; let formula_response = ui.add( egui::TextEdit::multiline(&mut self.formula_editor_state.formula) .hint_text(t!("formula.formula_hint")) .desired_width(ui.available_width()) - .desired_rows(3) - .font(egui::TextStyle::Monospace), + .desired_rows(2) + .font(fonts::text_style(style::DATA)) + .layouter(&mut layouter), ); // Validate on formula change @@ -68,7 +84,11 @@ impl UltraLogApp { // Quick pattern buttons ui.add_space(4.0); ui.horizontal(|ui| { - ui.label(egui::RichText::new(t!("formula.insert")).small().weak()); + ui.label( + egui::RichText::new(t!("formula.insert")) + .small() + .color(t.ink_subtle), + ); ui.add_space(4.0); // Math operators @@ -82,7 +102,11 @@ impl UltraLogApp { ("sqrt", "sqrt("), ] { if ui - .small_button(egui::RichText::new(label).monospace()) + .small_button( + egui::RichText::new(label) + .text_style(fonts::text_style(style::DATA_SM)) + .color(t.ink_muted), + ) .on_hover_text(t!("formula.insert_tooltip", op = insert.trim())) .clicked() { @@ -95,7 +119,11 @@ impl UltraLogApp { // Time-shift operators if ui - .small_button(egui::RichText::new("[-1]").monospace()) + .small_button( + egui::RichText::new("[-1]") + .text_style(fonts::text_style(style::DATA_SM)) + .color(t.orange), + ) .on_hover_text(t!("formula.prev_sample_tooltip")) .clicked() { @@ -103,7 +131,11 @@ impl UltraLogApp { self.validate_current_formula(); } if ui - .small_button(egui::RichText::new("@-0.1s").monospace()) + .small_button( + egui::RichText::new("@-0.1s") + .text_style(fonts::text_style(style::DATA_SM)) + .color(t.orange), + ) .on_hover_text(t!("formula.time_ago_tooltip")) .clicked() { @@ -115,16 +147,25 @@ impl UltraLogApp { // Show validation status ui.add_space(4.0); if let Some(error) = &self.formula_editor_state.validation_error { - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { ui.label( - egui::RichText::new(t!("formula.error")).color(egui::Color32::RED), + egui::RichText::new(format!( + "{} {}", + Status::Danger.icon(), + t!("formula.error") + )) + .color(t.danger), ); - ui.label(egui::RichText::new(error).color(egui::Color32::RED).small()); + ui.label(egui::RichText::new(error).color(t.ink)); }); } else if !self.formula_editor_state.formula.is_empty() { ui.label( - egui::RichText::new(t!("formula.formula_valid")) - .color(egui::Color32::GREEN), + egui::RichText::new(format!( + "{} {}", + Status::Success.icon(), + t!("formula.formula_valid") + )) + .color(t.success), ); } @@ -132,7 +173,7 @@ impl UltraLogApp { // Unit and description in a row ui.horizontal(|ui| { - ui.label(t!("formula.unit")); + ui.label(label(t!("formula.unit"))); ui.add( egui::TextEdit::singleline(&mut self.formula_editor_state.unit) .hint_text(t!("formula.unit_hint")) @@ -141,7 +182,7 @@ impl UltraLogApp { ui.add_space(16.0); - ui.label(t!("formula.description")); + ui.label(label(t!("formula.description"))); ui.add( egui::TextEdit::singleline(&mut self.formula_editor_state.description) .hint_text(t!("formula.description_hint")) @@ -160,13 +201,13 @@ impl UltraLogApp { if channels.is_empty() { ui.label( egui::RichText::new(t!("formula.no_channels_available")) - .color(egui::Color32::GRAY), + .color(t.ink_subtle), ); } else { ui.label( egui::RichText::new(t!("formula.click_to_insert")) .small() - .weak(), + .color(t.ink_subtle), ); egui::ScrollArea::vertical() .id_salt("channel_browser") @@ -174,7 +215,16 @@ impl UltraLogApp { .show(ui, |ui| { ui.horizontal_wrapped(|ui| { for name in &channels { - if ui.small_button(name).clicked() { + if ui + .small_button( + egui::RichText::new(name) + .text_style(fonts::text_style( + style::DATA_SM, + )) + .color(t.cyan), + ) + .clicked() + { // Insert channel name into formula let insert = if name.contains(' ') { format!("\"{}\"", name) @@ -221,45 +271,61 @@ impl UltraLogApp { ui.add_space(8.0); // Stats in a compact row - ui.label(egui::RichText::new(t!("formula.min")).small().weak()); + ui.label( + egui::RichText::new(t!("formula.min")) + .text_style(fonts::text_style(style::DATA_SM)) + .color(t.ink_subtle), + ); ui.label( egui::RichText::new(format!("{:.2}", min)) - .monospace() - .color(egui::Color32::LIGHT_BLUE), + .text_style(fonts::text_style(style::DATA)) + .color(t.ink), ); ui.add_space(8.0); - ui.label(egui::RichText::new(t!("formula.avg")).small().weak()); + ui.label( + egui::RichText::new(t!("formula.avg")) + .text_style(fonts::text_style(style::DATA_SM)) + .color(t.ink_subtle), + ); ui.label( egui::RichText::new(format!("{:.2}", avg)) - .monospace() - .color(egui::Color32::LIGHT_GREEN), + .text_style(fonts::text_style(style::DATA)) + .color(t.ink), ); ui.add_space(8.0); - ui.label(egui::RichText::new(t!("formula.max")).small().weak()); + ui.label( + egui::RichText::new(t!("formula.max")) + .text_style(fonts::text_style(style::DATA_SM)) + .color(t.ink_subtle), + ); ui.label( egui::RichText::new(format!("{:.2}", max)) - .monospace() - .color(egui::Color32::from_rgb(255, 180, 100)), + .text_style(fonts::text_style(style::DATA)) + .color(t.ink), ); }); // Sample values ui.horizontal(|ui| { - ui.label(egui::RichText::new(t!("formula.sample")).small().weak()); + ui.label( + egui::RichText::new(t!("formula.sample")) + .small() + .color(t.ink_subtle), + ); for (i, val) in preview_values.iter().take(5).enumerate() { if i > 0 { - ui.label(egui::RichText::new(",").weak()); + ui.label(egui::RichText::new(",").color(t.ink_subtle)); } ui.label( egui::RichText::new(format!("{:.2}", val)) - .monospace() - .color(egui::Color32::GRAY), + .text_style(fonts::text_style(style::DATA)) + .color(t.ink_muted), ); } if preview_values.len() > 5 { - ui.label(egui::RichText::new("...").weak()); + ui.label(egui::RichText::new("…").color(t.ink_subtle)); } }); } @@ -269,28 +335,26 @@ impl UltraLogApp { ui.separator(); ui.add_space(8.0); - // Buttons - ui.horizontal(|ui| { - if ui.button(t!("formula.cancel")).clicked() { - self.formula_editor_state.close(); - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let can_save = !self.formula_editor_state.name.is_empty() - && !self.formula_editor_state.formula.is_empty() - && self.formula_editor_state.validation_error.is_none(); + // Footer: actions right-aligned, ghost Cancel, secondary, one primary. + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let can_save = !self.formula_editor_state.name.is_empty() + && !self.formula_editor_state.formula.is_empty() + && self.formula_editor_state.validation_error.is_none(); - ui.add_enabled_ui(can_save, |ui| { - if ui.button(t!("formula.save")).clicked() { - should_save = true; - } - }); - - // Validate button - if ui.button(t!("formula.validate")).clicked() { - self.validate_current_formula(); + ui.add_enabled_ui(can_save, |ui| { + if components::button(ui, Kind::Primary, None, t!("formula.save")).clicked() + { + should_save = true; } }); + if components::button(ui, Kind::Secondary, None, t!("formula.validate")) + .clicked() + { + self.validate_current_formula(); + } + if components::button(ui, Kind::Ghost, None, t!("formula.cancel")).clicked() { + self.formula_editor_state.close(); + } }); }); @@ -299,6 +363,11 @@ impl UltraLogApp { self.save_formula_editor(); } + components::finish_window( + ctx, + window_response.as_ref().map(|r| &r.response), + &mut open, + ); if !open { self.formula_editor_state.close(); } diff --git a/src/ui/formula_syntax.rs b/src/ui/formula_syntax.rs new file mode 100644 index 00000000..925da4b2 --- /dev/null +++ b/src/ui/formula_syntax.rs @@ -0,0 +1,203 @@ +//! Syntax colouring for computed-channel formulas +//! (`design/design-system/components/FormulaEditor/README.md`). +//! +//! Roles: channel names `cyan`, numbers and constants `gold`, functions `violet`, time shifts +//! (`@-0.1s`, `[-1]`) `orange`, operators `ink_muted`. Everything is set in mono, so a token's +//! role also reads from its shape (quotes, brackets, `@`, a following parenthesis), never from +//! colour alone. + +use std::ops::Range; + +use eframe::egui::{Color32, FontId, TextFormat, text::LayoutJob}; + +use crate::ui::theme::Tokens; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Role { + Channel, + Number, + Function, + TimeShift, + Operator, + Space, +} + +const CONSTANTS: [&str; 4] = ["pi", "e", "tau", "phi"]; + +/// Split `text` into coloured runs. Unknown characters fall into `Operator`. +pub fn tokens(text: &str) -> Vec<(Range, Role)> { + let bytes = text.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < text.len() { + let c = text[i..].chars().next().unwrap_or(' '); + let start = i; + let role = if c.is_whitespace() { + i += c.len_utf8(); + while let Some(n) = text[i..].chars().next().filter(|n| n.is_whitespace()) { + i += n.len_utf8(); + } + Role::Space + } else if c == '"' { + // Quoted channel name, up to the closing quote (or the end while typing). + i += 1; + while i < text.len() && bytes[i] != b'"' { + i += text[i..].chars().next().map_or(1, char::len_utf8); + } + i = (i + 1).min(text.len()); + Role::Channel + } else if c == '@' { + // Time offset: @-0.1s, @0.5s + i += 1; + if i < text.len() && matches!(bytes[i], b'-' | b'+') { + i += 1; + } + while i < text.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') { + i += 1; + } + if i < text.len() && bytes[i] == b's' { + i += 1; + } + Role::TimeShift + } else if c == '[' { + // Index offset: [-1] + i += 1; + while i < text.len() && bytes[i] != b']' && !bytes[i].is_ascii_whitespace() { + i += 1; + } + if i < text.len() && bytes[i] == b']' { + i += 1; + } + Role::TimeShift + } else if c.is_ascii_digit() || (c == '.' && next_is_digit(bytes, i + 1)) { + i += 1; + while i < text.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') { + i += 1; + } + // Exponent: 1e-3 + if i + 1 < text.len() + && matches!(bytes[i], b'e' | b'E') + && (bytes[i + 1].is_ascii_digit() + || (matches!(bytes[i + 1], b'-' | b'+') && next_is_digit(bytes, i + 2))) + { + i += 2; + while i < text.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } + Role::Number + } else if c.is_alphabetic() || c == '_' { + while let Some(n) = text[i..] + .chars() + .next() + .filter(|n| n.is_alphanumeric() || *n == '_') + { + i += n.len_utf8(); + } + let word = &text[start..i]; + let rest = text[i..].trim_start(); + if rest.starts_with('(') { + Role::Function + } else if CONSTANTS.contains(&word) { + Role::Number + } else { + Role::Channel + } + } else { + i += c.len_utf8(); + Role::Operator + }; + out.push((start..i, role)); + } + out +} + +fn next_is_digit(bytes: &[u8], at: usize) -> bool { + bytes.get(at).is_some_and(u8::is_ascii_digit) +} + +/// The colour of a role. +pub fn color(role: Role, t: &Tokens) -> Color32 { + match role { + Role::Channel => t.cyan, + Role::Number => t.gold, + Role::Function => t.violet, + Role::TimeShift => t.orange, + Role::Operator | Role::Space => t.ink_muted, + } +} + +/// A syntax-coloured layout of `text` for a `TextEdit` layouter. +pub fn layout(text: &str, font: FontId, t: &Tokens, wrap_width: f32) -> LayoutJob { + let mut job = LayoutJob::default(); + for (range, role) in tokens(text) { + job.append( + &text[range], + 0.0, + TextFormat { + font_id: font.clone(), + color: color(role, t), + ..Default::default() + }, + ); + } + job.wrap.max_width = wrap_width; + job +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roles(text: &str) -> Vec<(&str, Role)> { + tokens(text) + .into_iter() + .filter(|(_, r)| *r != Role::Space) + .map(|(range, r)| (&text[range], r)) + .collect() + } + + #[test] + fn colours_each_role_of_the_reference_formula() { + let f = "\"Manifold Pressure\" - \"Manifold Pressure\"@-0.1s * 10 / abs(RPM[-1])"; + assert_eq!( + roles(f), + vec![ + ("\"Manifold Pressure\"", Role::Channel), + ("-", Role::Operator), + ("\"Manifold Pressure\"", Role::Channel), + ("@-0.1s", Role::TimeShift), + ("*", Role::Operator), + ("10", Role::Number), + ("/", Role::Operator), + ("abs", Role::Function), + ("(", Role::Operator), + ("RPM", Role::Channel), + ("[-1]", Role::TimeShift), + (")", Role::Operator), + ] + ); + } + + #[test] + fn constants_exponents_and_stat_variables() { + assert_eq!( + roles("_mean_RPM * pi + 1e-3"), + vec![ + ("_mean_RPM", Role::Channel), + ("*", Role::Operator), + ("pi", Role::Number), + ("+", Role::Operator), + ("1e-3", Role::Number), + ] + ); + } + + #[test] + fn covers_every_byte_even_while_typing() { + for text in ["\"Unclosed", "RPM@-", "abs(", "[", "ö + 1", ""] { + let total: usize = tokens(text).iter().map(|(r, _)| r.len()).sum(); + assert_eq!(total, text.len(), "{text:?}"); + } + } +} diff --git a/src/ui/heatmap.rs b/src/ui/heatmap.rs new file mode 100644 index 00000000..367c60ae --- /dev/null +++ b/src/ui/heatmap.rs @@ -0,0 +1,330 @@ +//! Shared painting for heatmaps and tables: Histogram, scatter density, Lambda Delay and Accel +//! Enrichment (`design/design-system/components/{HeatmapTable,ColorScale}/README.md`, +//! `3-data-visualization.md` → Colour maps, Tables and heatmaps). +//! +//! - Cells fill from the sequential (`seq`) or diverging (`div`) map, interpolated in OKLab. +//! - Value text uses the nearest stop's `on_seq_N` / `on_div_N`, on a `cell_plate` when the +//! theme shows one (Color-blind safe, High contrast), so every value reaches 7:1. +//! - Empty cells are `cell_empty` with a 45° hatch, so "no samples" never reads as a low value. +//! - Confidence is a fill style on the count badge: filled high, outlined medium, dashed low. +//! - The selected cell gets a 2px `cyan` ring with a `bg_void` gap. + +use eframe::egui::{ + self, Align2, Color32, CornerRadius, FontId, Painter, Pos2, Rect, Stroke, StrokeKind, Ui, pos2, + vec2, +}; + +use rust_i18n::t; + +use crate::ui::fonts::{self, style}; +use crate::ui::theme::{ + self, Tokens, paint, + tokens::{radius, space, stroke}, +}; + +/// Which colour map a heatmap uses. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MapKind { + /// Magnitudes: hit counts, dead time, RPM, density. + Sequential, + /// Signed values around a target: AFR / λ vs target, correction %. 0.5 is on target. + Diverging, +} + +/// Fill colour for `v` in 0..=1. +pub fn fill(t: &Tokens, kind: MapKind, v: f32) -> Color32 { + match kind { + MapKind::Sequential => theme::seq(t, v), + MapKind::Diverging => theme::div(t, v), + } +} + +/// Text colour for a value drawn on `fill(.., v)`. +pub fn text_on(t: &Tokens, kind: MapKind, v: f32) -> Color32 { + match kind { + MapKind::Sequential => theme::on_seq(t, v), + MapKind::Diverging => theme::on_div(t, v), + } +} + +/// Paint a filled cell with an optional value label. +pub fn cell( + painter: &Painter, + rect: Rect, + t: &Tokens, + kind: MapKind, + v: f32, + label: Option<&str>, + font: &FontId, +) { + painter.rect_filled( + rect, + CornerRadius::same(radius::RADIUS_XS), + fill(t, kind, v), + ); + if let Some(label) = label { + value_label(painter, rect.center(), label, font, t, text_on(t, kind, v)); + } +} + +/// A value centred at `center`, on the theme's cell plate when it has one. +pub fn value_label( + painter: &Painter, + center: Pos2, + label: &str, + font: &FontId, + t: &Tokens, + color: Color32, +) { + // On a plate the text is `ink`; the galley carries its colour, so pick it before layout. + let plated = t.cell_plate.a() > 0; + let galley = painter.layout_no_wrap( + label.to_owned(), + font.clone(), + if plated { t.ink } else { color }, + ); + if plated { + let plate = Rect::from_center_size(center, galley.size() + vec2(6.0, 2.0)); + painter.rect_filled(plate, CornerRadius::same(radius::RADIUS_XS), t.cell_plate); + painter.galley(center - galley.size() / 2.0, galley, t.ink); + } else { + painter.galley(center - galley.size() / 2.0, galley, color); + } +} + +/// Cells smaller than this (either side) skip the hatch: it cannot be read at that size, and a +/// dense sparse grid (256² histogram) would otherwise add a clipped painter and lines per cell. +pub const MIN_HATCH_CELL: f32 = 12.0; + +/// An empty cell: `cell_empty` with a 45° hatch (plain fill below [`MIN_HATCH_CELL`]). +pub fn empty_cell(painter: &Painter, rect: Rect, t: &Tokens) { + painter.rect_filled(rect, CornerRadius::same(radius::RADIUS_XS), t.cell_empty); + if rect.width() < MIN_HATCH_CELL || rect.height() < MIN_HATCH_CELL { + return; + } + let p = painter.with_clip_rect(rect.intersect(painter.clip_rect())); + let s = Stroke::new(stroke::STROKE_HAIRLINE, t.line_subtle); + let step = 6.0; + let mut x = rect.left() - rect.height(); + while x < rect.right() { + p.line_segment( + [pos2(x, rect.bottom()), pos2(x + rect.height(), rect.top())], + s, + ); + x += step; + } +} + +/// How sure a cell is; drawn as a fill style, never a hue. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BadgeStyle { + High, + Medium, + Low, +} + +/// An event-count badge anchored at its top-right corner. +pub fn count_badge( + painter: &Painter, + top_right: Pos2, + count: usize, + style: BadgeStyle, + t: &Tokens, + font: &FontId, +) { + let text = count.to_string(); + let galley = painter.layout_no_wrap(text.clone(), font.clone(), t.ink); + let size = galley.size() + vec2(5.0, 1.0); + let rect = Rect::from_min_size(pos2(top_right.x - size.x, top_right.y), size); + let corner = CornerRadius::same(radius::RADIUS_XS); + match style { + BadgeStyle::High => { + painter.rect_filled(rect, corner, t.ink); + painter.text( + rect.center(), + Align2::CENTER_CENTER, + text, + font.clone(), + t.bg_void, + ); + } + BadgeStyle::Medium => { + painter.rect( + rect, + corner, + t.bg_void, + Stroke::new(1.0, t.ink), + StrokeKind::Inside, + ); + painter.text( + rect.center(), + Align2::CENTER_CENTER, + text, + font.clone(), + t.ink, + ); + } + BadgeStyle::Low => { + painter.rect_filled(rect, corner, t.bg_void); + paint::dashed_rect( + painter, + rect.shrink(0.5), + 1.0, + Stroke::new(1.0, t.ink_muted), + 2.0, + ); + painter.text( + rect.center(), + Align2::CENTER_CENTER, + text, + font.clone(), + t.ink_muted, + ); + } + } +} + +/// The selected cell: a `bg_void` gap, then a 2px `cyan` ring. +pub fn selected_ring(painter: &Painter, rect: Rect, t: &Tokens) { + let corner = |e: f32| CornerRadius::same((f32::from(radius::RADIUS_XS) + e) as u8); + painter.rect_stroke( + rect.expand(1.0), + corner(1.0), + Stroke::new(2.0, t.bg_void), + StrokeKind::Middle, + ); + painter.rect_stroke( + rect.expand(3.0), + corner(3.0), + Stroke::new(stroke::STROKE_INDICATOR, t.cyan), + StrokeKind::Middle, + ); +} + +/// The ColorScale beside every map: an overline title, the real min / max in mono, and the +/// gradient. A diverging scale of a lambda deviation (`rich_lean`) labels its ends and middle +/// (rich · target · lean); other signed values keep the plain ± ends. +pub fn color_scale( + ui: &mut Ui, + kind: MapKind, + title: &str, + min_label: &str, + max_label: &str, + rich_lean: bool, +) { + let t = theme::current(ui.ctx()); + ui.label( + egui::RichText::new(title.to_uppercase()) + .text_style(fonts::text_style(style::OVERLINE)) + .extra_letter_spacing(0.9) + .color(t.ink_muted), + ); + let mono = fonts::text_style(style::DATA_SM).resolve(ui.style()); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(min_label) + .font(mono.clone()) + .color(t.ink_muted), + ); + let (rect, _) = ui.allocate_exact_size(vec2(180.0, 8.0), egui::Sense::hover()); + gradient_bar(ui.painter(), rect, &t, kind); + ui.label( + egui::RichText::new(max_label) + .font(mono.clone()) + .color(t.ink_muted), + ); + if kind == MapKind::Diverging && rich_lean { + let y = rect.bottom() + space::SPACE_1; + for (x, text, align) in [ + (rect.left(), t!("chart.rich"), Align2::LEFT_TOP), + (rect.center().x, t!("chart.target"), Align2::CENTER_TOP), + (rect.right(), t!("chart.lean"), Align2::RIGHT_TOP), + ] { + ui.painter() + .text(pos2(x, y), align, text, mono.clone(), t.ink_subtle); + } + } + }); + if kind == MapKind::Diverging && rich_lean { + ui.add_space(mono.size); + } +} + +/// A horizontal gradient of the map across `rect` (pill ends). +pub fn gradient_bar(painter: &Painter, rect: Rect, t: &Tokens, kind: MapKind) { + let steps = 48; + let mut mesh = egui::Mesh::default(); + for i in 0..=steps { + let k = i as f32 / steps as f32; + let x = rect.left() + rect.width() * k; + let c = fill(t, kind, k); + mesh.colored_vertex(pos2(x, rect.top()), c); + mesh.colored_vertex(pos2(x, rect.bottom()), c); + } + for i in 0..steps as u32 { + let a = i * 2; + mesh.add_triangle(a, a + 1, a + 2); + mesh.add_triangle(a + 1, a + 3, a + 2); + } + painter.add(egui::Shape::mesh(mesh)); +} + +/// A vertical gradient (low at the bottom) for scales that sit beside a square map. +pub fn gradient_bar_vertical(painter: &Painter, rect: Rect, t: &Tokens, kind: MapKind) { + let steps = 48; + let mut mesh = egui::Mesh::default(); + for i in 0..=steps { + let k = i as f32 / steps as f32; + let y = rect.bottom() - rect.height() * k; + let c = fill(t, kind, k); + mesh.colored_vertex(pos2(rect.left(), y), c); + mesh.colored_vertex(pos2(rect.right(), y), c); + } + for i in 0..steps as u32 { + let a = i * 2; + mesh.add_triangle(a, a + 1, a + 2); + mesh.add_triangle(a + 1, a + 3, a + 2); + } + painter.add(egui::Shape::mesh(mesh)); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::theme::tokens as ut; + + #[test] + fn cell_text_reaches_contrast_on_every_stop() { + fn lum(c: Color32) -> f32 { + let lin = |v: u8| { + let v = v as f32 / 255.0; + if v <= 0.04045 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + }; + let [r, g, b, _] = c.to_srgba_unmultiplied(); + 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) + } + let ratio = |a: Color32, b: Color32| { + let (x, y) = (lum(a), lum(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) + }; + for t in [ut::NIGHT, ut::CVD, ut::HC] { + for kind in [MapKind::Sequential, MapKind::Diverging] { + let stops = if kind == MapKind::Sequential { 6 } else { 4 }; + for i in 0..=stops { + let v = i as f32 / stops as f32; + // With a plate the text is ink on the plate; without, on_* on the fill. + let r = if t.cell_plate.a() > 0 { + ratio(t.ink, t.cell_plate.to_opaque()) + } else { + ratio(text_on(&t, kind, v), fill(&t, kind, v)) + }; + assert!(r >= 4.5, "{kind:?} stop {i}: {r:.2}"); + } + } + } + } +} diff --git a/src/ui/histogram.rs b/src/ui/histogram.rs index 9f402a4d..1d5c406d 100644 --- a/src/ui/histogram.rs +++ b/src/ui/histogram.rs @@ -12,21 +12,9 @@ use crate::normalize::sort_channels_by_priority; use crate::state::{ HistogramMode, PastedTable, SampleFilter, SelectedHistogramCell, TableOperation, }; - -/// Heat map color gradient from blue (low) to red (high) -const HEAT_COLORS: &[[u8; 3]] = &[ - [0, 0, 80], // Dark blue (0.0) - [0, 0, 180], // Blue - [0, 100, 255], // Light blue - [0, 200, 255], // Cyan - [0, 255, 200], // Cyan-green - [0, 255, 100], // Green - [100, 255, 0], // Yellow-green - [200, 255, 0], // Yellow - [255, 200, 0], // Orange - [255, 100, 0], // Red-orange - [255, 0, 0], // Red (1.0) -]; +use crate::ui::fonts::{self, style}; +use crate::ui::heatmap::{self, MapKind}; +use crate::ui::theme::{self, tokens::radius}; /// Margin for axis labels and titles const AXIS_LABEL_MARGIN_LEFT: f32 = 75.0; @@ -37,18 +25,6 @@ const AXIS_LABEL_MARGIN_RIGHT: f32 = 25.0; /// Height reserved for legend at bottom const LEGEND_HEIGHT: f32 = 55.0; -/// Current position indicator color (cyan, matches chart cursor) -const CURSOR_COLOR: egui::Color32 = egui::Color32::from_rgb(0, 255, 255); - -/// Cell highlight color for current position -const CELL_HIGHLIGHT_COLOR: egui::Color32 = egui::Color32::WHITE; - -/// Selected cell highlight color -const SELECTED_CELL_COLOR: egui::Color32 = egui::Color32::from_rgb(255, 165, 0); // Orange - -/// Crosshair color for cursor tracking during playback -const CURSOR_CROSSHAIR_COLOR: egui::Color32 = egui::Color32::from_rgb(128, 128, 128); // Grey - /// Maximum length for axis labels before truncation const MAX_AXIS_LABEL_LENGTH: usize = 20; @@ -75,48 +51,6 @@ fn truncate_label(s: &str, max_len: usize) -> String { } } -/// Calculate relative luminance for WCAG contrast ratio -/// Uses the sRGB colorspace formula from WCAG 2.1 -fn calculate_luminance(color: egui::Color32) -> f64 { - let r = linearize_channel(color.r()); - let g = linearize_channel(color.g()); - let b = linearize_channel(color.b()); - 0.2126 * r + 0.7152 * g + 0.0722 * b -} - -/// Linearize an sRGB channel value (0-255) to linear RGB -fn linearize_channel(value: u8) -> f64 { - let v = value as f64 / 255.0; - if v <= 0.03928 { - v / 12.92 - } else { - ((v + 0.055) / 1.055).powf(2.4) - } -} - -/// Calculate WCAG contrast ratio between two colors -fn contrast_ratio(color1: egui::Color32, color2: egui::Color32) -> f64 { - let l1 = calculate_luminance(color1); - let l2 = calculate_luminance(color2); - let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) }; - (lighter + 0.05) / (darker + 0.05) -} - -/// Get the best text color (black or white) for AAA compliance on given background -/// Returns the color that provides the highest contrast ratio -pub(crate) fn get_aaa_text_color(background: egui::Color32) -> egui::Color32 { - let white_contrast = contrast_ratio(egui::Color32::WHITE, background); - let black_contrast = contrast_ratio(egui::Color32::BLACK, background); - - // Choose whichever provides better contrast - // AAA requires 7:1 for normal text, but we pick the better option regardless - if white_contrast >= black_contrast { - egui::Color32::WHITE - } else { - egui::Color32::BLACK - } -} - impl UltraLogApp { /// Main entry point: render the histogram view pub fn render_histogram_view(&mut self, ui: &mut egui::Ui) { @@ -124,8 +58,8 @@ impl UltraLogApp { ui.centered_and_justified(|ui| { ui.label( egui::RichText::new(t!("histogram.no_file_loaded")) - .size(self.scaled_font(20.0)) - .color(egui::Color32::GRAY), + .heading() + .color(self.tokens.ink_subtle), ); }); return; @@ -243,17 +177,15 @@ impl UltraLogApp { let mut new_sample_filter: Option = None; // Pre-compute scaled font sizes - let font_14 = self.scaled_font(14.0); - let font_15 = self.scaled_font(15.0); // ============================================================================ // AXES SECTION // ============================================================================ - ui.label(egui::RichText::new("📊 Axes").size(font_15).strong()); + ui.label(egui::RichText::new("📊 Axes").heading().strong()); ui.add_space(8.0); // X Axis - ui.label(egui::RichText::new(t!("histogram.x_axis")).size(font_14)); + ui.label(egui::RichText::new(t!("histogram.x_axis")).text_style(egui::TextStyle::Body)); egui::ComboBox::from_id_salt("histogram_x") .selected_text( current_x @@ -272,7 +204,7 @@ impl UltraLogApp { ui.add_space(8.0); // Y Axis - ui.label(egui::RichText::new(t!("histogram.y_axis")).size(font_14)); + ui.label(egui::RichText::new(t!("histogram.y_axis")).text_style(egui::TextStyle::Body)); egui::ComboBox::from_id_salt("histogram_y") .selected_text( current_y @@ -295,11 +227,11 @@ impl UltraLogApp { ui.add_enabled_ui(z_enabled, |ui| { ui.label( egui::RichText::new(t!("histogram.z_axis")) - .size(font_14) + .text_style(egui::TextStyle::Body) .color(if z_enabled { - egui::Color32::WHITE + self.tokens.ink } else { - egui::Color32::GRAY + self.tokens.ink_subtle }), ); egui::ComboBox::from_id_salt("histogram_z") @@ -325,44 +257,40 @@ impl UltraLogApp { // ============================================================================ // DISPLAY OPTIONS SECTION // ============================================================================ - ui.label(egui::RichText::new("⚙ Display").size(font_15).strong()); + ui.label(egui::RichText::new("⚙ Display").heading().strong()); ui.add_space(8.0); // Mode selector - Pill style - ui.label( - egui::RichText::new(t!("histogram.mode")) - .size(font_14) - .strong(), - ); + ui.label(egui::RichText::new(t!("histogram.mode")).heading().strong()); ui.horizontal(|ui| { // Hit Count button let is_hit_count = current_mode == HistogramMode::HitCount; let hit_fill = if is_hit_count { - egui::Color32::from_rgb(70, 70, 70) + self.tokens.cyan_soft } else { - egui::Color32::from_rgb(45, 45, 45) + self.tokens.bg_field }; let hit_text = if is_hit_count { - egui::Color32::WHITE + self.tokens.cyan } else { - egui::Color32::from_rgb(180, 180, 180) + self.tokens.ink_muted }; let hit_stroke = if is_hit_count { - egui::Stroke::new(1.5, egui::Color32::from_rgb(113, 120, 78)) + egui::Stroke::new(1.0, self.tokens.cyan) } else { - egui::Stroke::new(1.0, egui::Color32::from_rgb(80, 80, 80)) + egui::Stroke::new(1.0, self.tokens.line_control) }; if ui .add( egui::Button::new( egui::RichText::new(t!("histogram.hit_count")) - .size(font_14) + .text_style(egui::TextStyle::Body) .color(hit_text), ) .fill(hit_fill) .stroke(hit_stroke) - .corner_radius(egui::CornerRadius::same(16)) + .corner_radius(egui::CornerRadius::same(radius::RADIUS_SM)) .min_size(egui::vec2(80.0, 28.0)), ) .clicked() @@ -375,31 +303,31 @@ impl UltraLogApp { // Average Z button let is_avg_z = current_mode == HistogramMode::AverageZ; let avg_fill = if is_avg_z { - egui::Color32::from_rgb(70, 70, 70) + self.tokens.cyan_soft } else { - egui::Color32::from_rgb(45, 45, 45) + self.tokens.bg_field }; let avg_text = if is_avg_z { - egui::Color32::WHITE + self.tokens.cyan } else { - egui::Color32::from_rgb(180, 180, 180) + self.tokens.ink_muted }; let avg_stroke = if is_avg_z { - egui::Stroke::new(1.5, egui::Color32::from_rgb(113, 120, 78)) + egui::Stroke::new(1.0, self.tokens.cyan) } else { - egui::Stroke::new(1.0, egui::Color32::from_rgb(80, 80, 80)) + egui::Stroke::new(1.0, self.tokens.line_control) }; if ui .add( egui::Button::new( egui::RichText::new(t!("histogram.average_z")) - .size(font_14) + .text_style(egui::TextStyle::Body) .color(avg_text), ) .fill(avg_fill) .stroke(avg_stroke) - .corner_radius(egui::CornerRadius::same(16)) + .corner_radius(egui::CornerRadius::same(radius::RADIUS_SM)) .min_size(egui::vec2(80.0, 28.0)), ) .clicked() @@ -411,7 +339,7 @@ impl UltraLogApp { ui.add_space(8.0); // Grid columns - ui.label(egui::RichText::new("Columns (X bins)").size(font_14)); + ui.label(egui::RichText::new("Columns (X bins)").text_style(egui::TextStyle::Body)); let effective_cols = if current_custom_grid_cols > 0 { current_custom_grid_cols } else { @@ -430,7 +358,7 @@ impl UltraLogApp { ui.add_space(8.0); // Grid rows - ui.label(egui::RichText::new("Rows (Y bins)").size(font_14)); + ui.label(egui::RichText::new("Rows (Y bins)").text_style(egui::TextStyle::Body)); let effective_rows = if current_custom_grid_rows > 0 { current_custom_grid_rows } else { @@ -449,7 +377,7 @@ impl UltraLogApp { ui.add_space(8.0); // Min hits filter - ui.label(egui::RichText::new(t!("histogram.min_hits")).size(font_14)); + ui.label(egui::RichText::new(t!("histogram.min_hits")).text_style(egui::TextStyle::Body)); let mut min_hits_value = current_min_hits as i32; ui.add( egui::DragValue::new(&mut min_hits_value) @@ -467,11 +395,11 @@ impl UltraLogApp { // ============================================================================ // DATA RANGE SECTION // ============================================================================ - ui.label(egui::RichText::new("📏 Data Range").size(font_15).strong()); + ui.label(egui::RichText::new("📏 Data Range").heading().strong()); ui.add_space(8.0); // X Range - ui.label(egui::RichText::new(t!("histogram.x_range")).size(font_14)); + ui.label(egui::RichText::new(t!("histogram.x_range")).text_style(egui::TextStyle::Body)); let mut x_auto = current_custom_x.is_none(); if ui.checkbox(&mut x_auto, t!("histogram.auto")).changed() { if x_auto { @@ -496,7 +424,7 @@ impl UltraLogApp { ui.add_space(8.0); // Y Range - ui.label(egui::RichText::new(t!("histogram.y_range")).size(font_14)); + ui.label(egui::RichText::new(t!("histogram.y_range")).text_style(egui::TextStyle::Body)); let mut y_auto = current_custom_y.is_none(); if ui.checkbox(&mut y_auto, t!("histogram.auto")).changed() { if y_auto { @@ -527,7 +455,7 @@ impl UltraLogApp { // ============================================================================ let current_sample_filters = config.sample_filters.clone(); egui::CollapsingHeader::new( - egui::RichText::new(format!("🔍 {}", t!("histogram.sample_filters"))).size(font_15), + egui::RichText::new(format!("🔍 {}", t!("histogram.sample_filters"))).heading(), ) .default_open(false) .show(ui, |ui| { @@ -536,8 +464,8 @@ impl UltraLogApp { if current_sample_filters.is_empty() { ui.label( egui::RichText::new(t!("histogram.no_filters")) - .size(font_14) - .color(egui::Color32::GRAY), + .text_style(egui::TextStyle::Body) + .color(self.tokens.ink_subtle), ); } else { for (filter_idx, filter) in current_sample_filters.iter().enumerate() { @@ -551,16 +479,16 @@ impl UltraLogApp { ui.label( egui::RichText::new(&filter.channel_name) - .size(font_14) + .text_style(egui::TextStyle::Body) .color(if enabled { - egui::Color32::WHITE + self.tokens.ink } else { - egui::Color32::GRAY + self.tokens.ink_subtle }), ); if ui - .button(egui::RichText::new("❌").size(font_14)) + .button(egui::RichText::new("❌").text_style(egui::TextStyle::Body)) .on_hover_text(t!("histogram.remove_filter")) .clicked() { @@ -624,7 +552,9 @@ impl UltraLogApp { ui.add_space(8.0); // Add filter - ui.label(egui::RichText::new(t!("histogram.add_filter")).size(font_14)); + ui.label( + egui::RichText::new(t!("histogram.add_filter")).text_style(egui::TextStyle::Body), + ); egui::ComboBox::from_id_salt("add_sample_filter") .selected_text(t!("histogram.filter_channel")) .width(ui.available_width()) @@ -646,7 +576,7 @@ impl UltraLogApp { // ============================================================================ ui.label( egui::RichText::new("📋 Table Operations") - .size(font_15) + .heading() .strong(), ); ui.add_space(8.0); @@ -654,10 +584,11 @@ impl UltraLogApp { // Copy/Paste buttons ui.horizontal(|ui| { let copy_button = egui::Button::new( - egui::RichText::new(format!("📋 {}", t!("histogram.copy"))).size(font_14), + egui::RichText::new(format!("📋 {}", t!("histogram.copy"))) + .text_style(egui::TextStyle::Body), ) - .fill(egui::Color32::from_rgb(45, 45, 45)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(80, 80, 80))) + .fill(self.tokens.bg_raised) + .stroke(egui::Stroke::new(1.0, self.tokens.line)) .corner_radius(4); if ui.add(copy_button).clicked() { @@ -667,10 +598,11 @@ impl UltraLogApp { ui.add_space(6.0); let paste_button = egui::Button::new( - egui::RichText::new(format!("📥 {}", t!("histogram.paste"))).size(font_14), + egui::RichText::new(format!("📥 {}", t!("histogram.paste"))) + .text_style(egui::TextStyle::Body), ) - .fill(egui::Color32::from_rgb(45, 45, 45)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(80, 80, 80))) + .fill(self.tokens.bg_raised) + .stroke(egui::Stroke::new(1.0, self.tokens.line)) .corner_radius(4); if ui.add(paste_button).clicked() { @@ -684,7 +616,7 @@ impl UltraLogApp { ui.label( egui::RichText::new(t!("histogram.operation")) - .size(font_14) + .heading() .strong(), ); ui.horizontal(|ui| { @@ -692,24 +624,26 @@ impl UltraLogApp { let is_selected = current_table_op == TableOperation::Add; if ui .add( - egui::Button::new(egui::RichText::new("+").size(font_14).color( - if is_selected { - egui::Color32::WHITE - } else { - egui::Color32::from_rgb(180, 180, 180) - }, - )) + egui::Button::new( + egui::RichText::new("+") + .text_style(egui::TextStyle::Body) + .color(if is_selected { + self.tokens.cyan + } else { + self.tokens.ink_muted + }), + ) .fill(if is_selected { - egui::Color32::from_rgb(70, 70, 70) + self.tokens.cyan_soft } else { - egui::Color32::from_rgb(45, 45, 45) + self.tokens.bg_field }) .stroke(if is_selected { - egui::Stroke::new(1.5, egui::Color32::from_rgb(113, 120, 78)) + egui::Stroke::new(1.0, self.tokens.cyan) } else { - egui::Stroke::new(1.0, egui::Color32::from_rgb(80, 80, 80)) + egui::Stroke::new(1.0, self.tokens.line_control) }) - .corner_radius(egui::CornerRadius::same(16)) + .corner_radius(egui::CornerRadius::same(radius::RADIUS_SM)) .min_size(egui::vec2(40.0, 28.0)), ) .clicked() @@ -723,24 +657,26 @@ impl UltraLogApp { let is_selected = current_table_op == TableOperation::Subtract; if ui .add( - egui::Button::new(egui::RichText::new("−").size(font_14).color( - if is_selected { - egui::Color32::WHITE - } else { - egui::Color32::from_rgb(180, 180, 180) - }, - )) + egui::Button::new( + egui::RichText::new("−") + .text_style(egui::TextStyle::Body) + .color(if is_selected { + self.tokens.cyan + } else { + self.tokens.ink_muted + }), + ) .fill(if is_selected { - egui::Color32::from_rgb(70, 70, 70) + self.tokens.cyan_soft } else { - egui::Color32::from_rgb(45, 45, 45) + self.tokens.bg_field }) .stroke(if is_selected { - egui::Stroke::new(1.5, egui::Color32::from_rgb(113, 120, 78)) + egui::Stroke::new(1.0, self.tokens.cyan) } else { - egui::Stroke::new(1.0, egui::Color32::from_rgb(80, 80, 80)) + egui::Stroke::new(1.0, self.tokens.line_control) }) - .corner_radius(egui::CornerRadius::same(16)) + .corner_radius(egui::CornerRadius::same(radius::RADIUS_SM)) .min_size(egui::vec2(40.0, 28.0)), ) .clicked() @@ -754,24 +690,26 @@ impl UltraLogApp { let is_selected = current_table_op == TableOperation::Multiply; if ui .add( - egui::Button::new(egui::RichText::new("×").size(font_14).color( - if is_selected { - egui::Color32::WHITE - } else { - egui::Color32::from_rgb(180, 180, 180) - }, - )) + egui::Button::new( + egui::RichText::new("×") + .text_style(egui::TextStyle::Body) + .color(if is_selected { + self.tokens.cyan + } else { + self.tokens.ink_muted + }), + ) .fill(if is_selected { - egui::Color32::from_rgb(70, 70, 70) + self.tokens.cyan_soft } else { - egui::Color32::from_rgb(45, 45, 45) + self.tokens.bg_field }) .stroke(if is_selected { - egui::Stroke::new(1.5, egui::Color32::from_rgb(113, 120, 78)) + egui::Stroke::new(1.0, self.tokens.cyan) } else { - egui::Stroke::new(1.0, egui::Color32::from_rgb(80, 80, 80)) + egui::Stroke::new(1.0, self.tokens.line_control) }) - .corner_radius(egui::CornerRadius::same(16)) + .corner_radius(egui::CornerRadius::same(radius::RADIUS_SM)) .min_size(egui::vec2(40.0, 28.0)), ) .clicked() @@ -785,24 +723,26 @@ impl UltraLogApp { let is_selected = current_table_op == TableOperation::Divide; if ui .add( - egui::Button::new(egui::RichText::new("÷").size(font_14).color( - if is_selected { - egui::Color32::WHITE - } else { - egui::Color32::from_rgb(180, 180, 180) - }, - )) + egui::Button::new( + egui::RichText::new("÷") + .text_style(egui::TextStyle::Body) + .color(if is_selected { + self.tokens.cyan + } else { + self.tokens.ink_muted + }), + ) .fill(if is_selected { - egui::Color32::from_rgb(70, 70, 70) + self.tokens.cyan_soft } else { - egui::Color32::from_rgb(45, 45, 45) + self.tokens.bg_field }) .stroke(if is_selected { - egui::Stroke::new(1.5, egui::Color32::from_rgb(113, 120, 78)) + egui::Stroke::new(1.0, self.tokens.cyan) } else { - egui::Stroke::new(1.0, egui::Color32::from_rgb(80, 80, 80)) + egui::Stroke::new(1.0, self.tokens.line_control) }) - .corner_radius(egui::CornerRadius::same(16)) + .corner_radius(egui::CornerRadius::same(radius::RADIUS_SM)) .min_size(egui::vec2(40.0, 28.0)), ) .clicked() @@ -825,10 +765,11 @@ impl UltraLogApp { ui.horizontal(|ui| { let clear_button = egui::Button::new( - egui::RichText::new(format!("❌ {}", t!("histogram.clear"))).size(font_14), + egui::RichText::new(format!("❌ {}", t!("histogram.clear"))) + .text_style(egui::TextStyle::Body), ) - .fill(egui::Color32::from_rgb(45, 45, 45)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(80, 80, 80))) + .fill(self.tokens.bg_raised) + .stroke(egui::Stroke::new(1.0, self.tokens.line)) .corner_radius(4); if ui.add(clear_button).clicked() { @@ -839,10 +780,10 @@ impl UltraLogApp { let copy_result_button = egui::Button::new( egui::RichText::new(format!("📋 {}", t!("histogram.copy_result"))) - .size(font_14), + .text_style(egui::TextStyle::Body), ) - .fill(egui::Color32::from_rgb(45, 45, 45)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(80, 80, 80))) + .fill(self.tokens.bg_raised) + .stroke(egui::Stroke::new(1.0, self.tokens.line)) .corner_radius(4); if ui.add(copy_result_button).clicked() { @@ -957,7 +898,7 @@ impl UltraLogApp { egui::Align2::CENTER_CENTER, "Select X and Y axes", egui::FontId::proportional(font_16), - egui::Color32::GRAY, + self.tokens.ink_subtle, ); return; } @@ -975,7 +916,7 @@ impl UltraLogApp { egui::Align2::CENTER_CENTER, "Select Z axis for Average mode", egui::FontId::proportional(font_16), - egui::Color32::GRAY, + self.tokens.ink_subtle, ); return; } @@ -1221,8 +1162,8 @@ impl UltraLogApp { min_value, max_value )) - .size(font_12) - .color(egui::Color32::WHITE), + .small() + .color(self.tokens.ink), ); ui.add_space(16.0); ui.label( @@ -1232,8 +1173,8 @@ impl UltraLogApp { pasted_min, pasted_max )) - .size(font_12) - .color(egui::Color32::WHITE), + .small() + .color(self.tokens.ink), ); ui.add_space(16.0); ui.label( @@ -1243,8 +1184,8 @@ impl UltraLogApp { result_min, result_max )) - .size(font_12) - .color(egui::Color32::WHITE), + .small() + .color(self.tokens.ink), ); }); @@ -1268,8 +1209,9 @@ impl UltraLogApp { ), ); + let t = self.tokens; let painter = ui.painter_at(full_rect); - painter.rect_filled(plot_rect, 0.0, egui::Color32::BLACK); + painter.rect_filled(plot_rect, 0.0, t.plot_bg); let cell_width = plot_rect.width() / grid_cols as f32; let cell_height = plot_rect.height() / grid_rows as f32; @@ -1281,6 +1223,25 @@ impl UltraLogApp { .selected_cell .clone(); + // Cell value font: mono, smaller as the grid gets denser + let max_dim = grid_cols.max(grid_rows); + let base_font_size = if max_dim <= 16 { + 11.0 + } else if max_dim <= 32 { + 9.0 + } else { + 7.0 + }; + let cell_font = egui::FontId::monospace(self.scaled_font(base_font_size)); + + // Small cells: one empty-ground fill for the whole grid instead of a shape per empty + // cell (a sparse 256² grid would otherwise add ~65k shapes every frame). + let small_cells = + cell_width < heatmap::MIN_HATCH_CELL || cell_height < heatmap::MIN_HATCH_CELL; + if small_cells { + painter.rect_filled(plot_rect, 0.0, t.cell_empty); + } + // Draw grid cells with values for y_bin in 0..grid_rows { for x_bin in 0..grid_cols { @@ -1292,89 +1253,49 @@ impl UltraLogApp { ); let hits = hit_counts[y_bin][x_bin]; - - // Skip cells below minimum hits threshold (draw grayed out) - if min_hits_filter > 0 && hits < min_hits_filter { - painter.rect_filled(cell_rect, 0.0, egui::Color32::from_rgb(30, 30, 30)); + let rect = cell_rect.shrink(1.0); + + // Cells below the minimum-hits threshold read as empty, like cells with no data + let value = cell_values[y_bin][x_bin] + .filter(|_| !(min_hits_filter > 0 && hits < min_hits_filter)); + let Some(value) = value else { + if !small_cells { + heatmap::empty_cell(&painter, rect, &t); + } continue; - } - - if let Some(value) = cell_values[y_bin][x_bin] { - // Normalize to 0-1 for color scaling - let normalized = if mode == HistogramMode::HitCount && max_value > 1.0 { - (value.ln() / max_value.ln()).clamp(0.0, 1.0) + }; + let normalized = if mode == HistogramMode::HitCount && max_value > 1.0 { + (value.ln() / max_value.ln()).clamp(0.0, 1.0) + } else { + ((value - min_value) / value_range).clamp(0.0, 1.0) + }; + // Value text only where the cell is large enough to hold it + let label = (cell_width > 25.0 && cell_height > 18.0).then(|| { + if mode == HistogramMode::HitCount { + format!("{}", hits) } else { - ((value - min_value) / value_range).clamp(0.0, 1.0) - }; - let color = Self::get_histogram_color(normalized); - - painter.rect_filled(cell_rect, 0.0, color); - - // Draw value text in center of cell (only if cell is large enough) - if cell_width > 25.0 && cell_height > 18.0 { - let text = if mode == HistogramMode::HitCount { - format!("{}", hit_counts[y_bin][x_bin]) - } else { - format!("{:.1}", value) - }; - - // Choose text color for AAA contrast compliance - let text_color = get_aaa_text_color(color); - - let max_dim = grid_cols.max(grid_rows); - let base_font_size = if max_dim <= 16 { - 11.0 - } else if max_dim <= 32 { - 9.0 - } else { - 7.0 - }; - let cell_font_size = self.scaled_font(base_font_size); - - painter.text( - cell_rect.center(), - egui::Align2::CENTER_CENTER, - text, - egui::FontId::proportional(cell_font_size), - text_color, - ); + format!("{:.1}", value) } - } + }); + heatmap::cell( + &painter, + rect, + &t, + MapKind::Sequential, + normalized as f32, + label.as_deref(), + &cell_font, + ); } } - // Draw grid lines - let grid_color = egui::Color32::from_rgb(60, 60, 60); - // Vertical lines (X axis divisions) - for i in 0..=grid_cols { - let x = plot_rect.left() + i as f32 * cell_width; - painter.line_segment( - [ - egui::pos2(x, plot_rect.top()), - egui::pos2(x, plot_rect.bottom()), - ], - egui::Stroke::new(0.5, grid_color), - ); - } - // Horizontal lines (Y axis divisions) - for i in 0..=grid_rows { - let y = plot_rect.top() + i as f32 * cell_height; - painter.line_segment( - [ - egui::pos2(plot_rect.left(), y), - egui::pos2(plot_rect.right(), y), - ], - egui::Stroke::new(0.5, grid_color), - ); - } - // Get channel names for axis labels (handles computed channels) let x_channel_name = self.get_channel_name(file_idx, x_idx); let y_channel_name = self.get_channel_name(file_idx, y_idx); - // Draw axis labels - let text_color = egui::Color32::from_rgb(200, 200, 200); - let axis_title_color = egui::Color32::from_rgb(255, 255, 255); + // Draw axis labels: values in mono `plot_axis`, titles in `ink_muted` + let text_color = t.plot_axis; + let axis_title_color = t.ink_muted; // Y axis value labels for i in 0..=4 { @@ -1385,7 +1306,7 @@ impl UltraLogApp { egui::pos2(plot_rect.left() - 8.0, y_pos), egui::Align2::RIGHT_CENTER, format!("{:.1}", value), - egui::FontId::proportional(font_10), + egui::FontId::monospace(font_10), text_color, ); } @@ -1463,7 +1384,7 @@ impl UltraLogApp { egui::pos2(x_pos, plot_rect.bottom() + 5.0), egui::Align2::CENTER_TOP, format!("{:.0}", value), - egui::FontId::proportional(font_10), + egui::FontId::monospace(font_10), text_color, ); } @@ -1500,11 +1421,7 @@ impl UltraLogApp { egui::pos2(sel_x, sel_y), egui::vec2(cell_width, cell_height), ); - let stroke = egui::Stroke::new(3.0, SELECTED_CELL_COLOR); - painter.line_segment([sel_rect.left_top(), sel_rect.right_top()], stroke); - painter.line_segment([sel_rect.left_bottom(), sel_rect.right_bottom()], stroke); - painter.line_segment([sel_rect.left_top(), sel_rect.left_bottom()], stroke); - painter.line_segment([sel_rect.right_top(), sel_rect.right_bottom()], stroke); + heatmap::selected_ring(&painter, sel_rect.shrink(1.0), &t); } // Draw current position indicator (cursor time) @@ -1527,14 +1444,14 @@ impl UltraLogApp { egui::pos2(pos_x, plot_rect.top()), egui::pos2(pos_x, plot_rect.bottom()), ], - egui::Stroke::new(1.0, CURSOR_CROSSHAIR_COLOR), + egui::Stroke::new(1.0, t.plot_grid_major), ); painter.line_segment( [ egui::pos2(plot_rect.left(), pos_y), egui::pos2(plot_rect.right(), pos_y), ], - egui::Stroke::new(1.0, CURSOR_CROSSHAIR_COLOR), + egui::Stroke::new(1.0, t.plot_grid_major), ); // Highlight the cell containing the cursor @@ -1548,30 +1465,20 @@ impl UltraLogApp { egui::pos2(highlight_x, highlight_y), egui::vec2(cell_width, cell_height), ); - let stroke = egui::Stroke::new(2.0, CELL_HIGHLIGHT_COLOR); - painter.line_segment( - [highlight_rect.left_top(), highlight_rect.right_top()], - stroke, - ); - painter.line_segment( - [highlight_rect.left_bottom(), highlight_rect.right_bottom()], - stroke, - ); - painter.line_segment( - [highlight_rect.left_top(), highlight_rect.left_bottom()], - stroke, - ); - painter.line_segment( - [highlight_rect.right_top(), highlight_rect.right_bottom()], - stroke, + painter.rect_stroke( + highlight_rect.shrink(1.0), + egui::CornerRadius::same(2), + egui::Stroke::new(1.0, t.ink), + egui::StrokeKind::Inside, ); // Draw circle at exact position - painter.circle_filled(egui::pos2(pos_x, pos_y), 6.0, CURSOR_COLOR); - painter.circle_stroke( + // Current position: a plot_cursor dot with a bg_void ring + painter.circle( egui::pos2(pos_x, pos_y), - 6.0, - egui::Stroke::new(2.0, egui::Color32::WHITE), + 5.0, + t.plot_cursor, + egui::Stroke::new(2.0, t.bg_void), ); } } @@ -1618,7 +1525,7 @@ impl UltraLogApp { // Draw hover crosshairs let hover_x = plot_rect.left() + rel_x * plot_rect.width(); let hover_y = plot_rect.top() + (1.0 - rel_y) * plot_rect.height(); - let crosshair_color = egui::Color32::from_rgb(255, 255, 0); + let crosshair_color = t.ink_subtle; painter.line_segment( [ @@ -1639,8 +1546,8 @@ impl UltraLogApp { egui::pos2(plot_rect.right() - 10.0, plot_rect.top() + 15.0), egui::Align2::RIGHT_TOP, tooltip_text, - egui::FontId::proportional(font_12), - egui::Color32::WHITE, + egui::FontId::monospace(font_12), + t.ink, ); } } @@ -1724,28 +1631,6 @@ impl UltraLogApp { self.render_histogram_legend(ui, min_value, max_value, mode, selected_cell); } - /// Get a color from the heat map gradient based on normalized value (0-1) - fn get_histogram_color(normalized: f64) -> egui::Color32 { - let t = normalized.clamp(0.0, 1.0); - let scaled = t * (HEAT_COLORS.len() - 1) as f64; - let idx = scaled.floor() as usize; - let frac = scaled - idx as f64; - - if idx >= HEAT_COLORS.len() - 1 { - let c = HEAT_COLORS[HEAT_COLORS.len() - 1]; - return egui::Color32::from_rgb(c[0], c[1], c[2]); - } - - let c1 = HEAT_COLORS[idx]; - let c2 = HEAT_COLORS[idx + 1]; - - let r = (c1[0] as f64 + (c2[0] as f64 - c1[0] as f64) * frac) as u8; - let g = (c1[1] as f64 + (c2[1] as f64 - c1[1] as f64) * frac) as u8; - let b = (c1[2] as f64 + (c2[2] as f64 - c1[2] as f64) * frac) as u8; - - egui::Color32::from_rgb(r, g, b) - } - /// Render the legend with color scale and cell statistics fn render_histogram_legend( &self, @@ -1755,74 +1640,42 @@ impl UltraLogApp { mode: HistogramMode, selected_cell: Option<&SelectedHistogramCell>, ) { - let font_12 = self.scaled_font(12.0); - let font_13 = self.scaled_font(13.0); - ui.horizontal(|ui| { ui.add_space(4.0); - // Color scale legend - egui::Frame::NONE - .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 220)) - .corner_radius(4) - .inner_margin(8.0) - .show(ui, |ui| { - ui.horizontal(|ui| { - let label = match mode { - HistogramMode::HitCount => "Hits:", - HistogramMode::AverageZ => "Value:", - }; - ui.label( - egui::RichText::new(label) - .size(font_13) - .color(egui::Color32::WHITE), - ); - - // Color gradient bar - let (rect, _) = - ui.allocate_exact_size(egui::vec2(120.0, 18.0), egui::Sense::hover()); - - let painter = ui.painter(); - let steps = 30; - let step_width = rect.width() / steps as f32; - - for i in 0..steps { - let t = i as f64 / steps as f64; - let color = Self::get_histogram_color(t); - let x = rect.left() + i as f32 * step_width; - painter.rect_filled( - egui::Rect::from_min_size( - egui::pos2(x, rect.top()), - egui::vec2(step_width + 1.0, rect.height()), - ), - 0.0, - color, - ); - } - - ui.add_space(6.0); - let range_text = if mode == HistogramMode::HitCount { - format!("0-{:.0}", max_value) - } else { - format!("{:.1}-{:.1}", min_value, max_value) - }; - ui.label( - egui::RichText::new(range_text) - .size(font_13) - .color(egui::Color32::WHITE), - ); - }); - }); + // ColorScale with the real min / max + ui.vertical(|ui| { + let (title, min_label, max_label) = match mode { + HistogramMode::HitCount => ( + t!("histogram.scale_hits"), + "0".to_string(), + format!("{:.0}", max_value), + ), + HistogramMode::AverageZ => ( + t!("histogram.scale_average"), + format!("{:.1}", min_value), + format!("{:.1}", max_value), + ), + }; + heatmap::color_scale( + ui, + MapKind::Sequential, + &title, + &min_label, + &max_label, + false, + ); + }); ui.add_space(16.0); // Cell statistics panel (only shown when a cell is selected) if let Some(cell) = selected_cell { egui::Frame::NONE - .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 220)) + .fill(self.tokens.bg_raised) .corner_radius(4) .inner_margin(8.0) - .stroke(egui::Stroke::new(1.0, SELECTED_CELL_COLOR)) + .stroke(egui::Stroke::new(1.0, self.tokens.cyan)) .show(ui, |ui| { ui.horizontal(|ui| { // Cell identifier @@ -1831,19 +1684,19 @@ impl UltraLogApp { "Cell [{}, {}]", cell.x_bin, cell.y_bin )) - .size(font_13) - .color(SELECTED_CELL_COLOR), + .text_style(fonts::text_style(style::LABEL)) + .color(self.tokens.cyan), ); ui.add_space(12.0); // Key statistics inline - let stat_color = egui::Color32::from_rgb(180, 180, 180); - let value_color = egui::Color32::WHITE; + let stat_color = self.tokens.ink_subtle; + let value_color = self.tokens.ink; - ui.label(egui::RichText::new("Hits:").size(font_12).color(stat_color)); + ui.label(egui::RichText::new("Hits:").small().color(stat_color)); ui.label( egui::RichText::new(format!("{}", cell.hit_count)) - .size(font_12) + .monospace() .color(value_color), ); @@ -1851,40 +1704,34 @@ impl UltraLogApp { if mode == HistogramMode::AverageZ { ui.add_space(8.0); - ui.label( - egui::RichText::new("Mean:").size(font_12).color(stat_color), - ); + ui.label(egui::RichText::new("Mean:").small().color(stat_color)); ui.label( egui::RichText::new(format!("{:.2}", cell.mean)) - .size(font_12) + .monospace() .color(value_color), ); ui.add_space(8.0); - ui.label( - egui::RichText::new("Min:").size(font_12).color(stat_color), - ); + ui.label(egui::RichText::new("Min:").small().color(stat_color)); ui.label( egui::RichText::new(format!("{:.2}", cell.minimum)) - .size(font_12) + .monospace() .color(value_color), ); ui.add_space(8.0); - ui.label( - egui::RichText::new("Max:").size(font_12).color(stat_color), - ); + ui.label(egui::RichText::new("Max:").small().color(stat_color)); ui.label( egui::RichText::new(format!("{:.2}", cell.maximum)) - .size(font_12) + .monospace() .color(value_color), ); ui.add_space(8.0); - ui.label(egui::RichText::new("σ:").size(font_12).color(stat_color)); + ui.label(egui::RichText::new("σ:").small().color(stat_color)); ui.label( egui::RichText::new(format!("{:.2}", cell.std_dev)) - .size(font_12) + .monospace() .color(value_color), ); } @@ -1893,15 +1740,15 @@ impl UltraLogApp { } else { // Hint when no cell is selected egui::Frame::NONE - .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 220)) + .fill(self.tokens.bg_raised) .corner_radius(4) .inner_margin(8.0) .show(ui, |ui| { ui.label( egui::RichText::new("Click a cell to view statistics") - .size(font_12) + .small() .italics() - .color(egui::Color32::GRAY), + .color(self.tokens.ink_subtle), ); }); } @@ -1918,36 +1765,31 @@ impl UltraLogApp { let selected = &config.selected_cell; let mode = config.mode; - // Pre-compute scaled font sizes - let font_12 = self.scaled_font(12.0); - let font_13 = self.scaled_font(13.0); - let font_14 = self.scaled_font(14.0); - ui.add_space(10.0); ui.separator(); ui.add_space(5.0); ui.label( egui::RichText::new("Cell Statistics") - .size(font_14) + .heading() .strong() - .color(egui::Color32::WHITE), + .color(self.tokens.ink), ); ui.add_space(5.0); if let Some(cell) = selected { egui::Frame::NONE - .fill(egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200)) + .fill(self.tokens.bg_raised) .corner_radius(4) .inner_margin(8.0) - .stroke(egui::Stroke::new(1.0, SELECTED_CELL_COLOR)) + .stroke(egui::Stroke::new(1.0, self.tokens.cyan)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label( egui::RichText::new(format!("Cell [{}, {}]", cell.x_bin, cell.y_bin)) - .size(font_13) - .color(SELECTED_CELL_COLOR), + .text_style(fonts::text_style(style::LABEL)) + .color(self.tokens.cyan), ); }); @@ -1982,16 +1824,16 @@ impl UltraLogApp { ui.horizontal(|ui| { ui.label( egui::RichText::new(format!("{}:", label)) - .size(font_12) - .color(egui::Color32::from_rgb(180, 180, 180)), + .small() + .color(self.tokens.ink_subtle), ); ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label( egui::RichText::new(&value) - .size(font_12) - .color(egui::Color32::WHITE), + .monospace() + .color(self.tokens.ink), ); }, ); @@ -2007,9 +1849,9 @@ impl UltraLogApp { } else { ui.label( egui::RichText::new("Click a cell to view statistics") - .size(font_12) + .small() .italics() - .color(egui::Color32::GRAY), + .color(self.tokens.ink_subtle), ); } } @@ -2165,13 +2007,14 @@ impl UltraLogApp { return; } + let t = theme::current(&painter.ctx().clone()); // Draw title painter.text( egui::pos2(rect.center().x, rect.top() + 15.0), egui::Align2::CENTER_CENTER, title, egui::FontId::proportional(font_size), - egui::Color32::WHITE, + t.ink_muted, ); // Calculate plot area @@ -2180,7 +2023,7 @@ impl UltraLogApp { egui::pos2(rect.right() - 10.0, rect.bottom() - 10.0), ); - painter.rect_filled(plot_rect, 0.0, egui::Color32::BLACK); + painter.rect_filled(plot_rect, 0.0, t.plot_bg); let cell_width = plot_rect.width() / grid_cols as f32; let cell_height = plot_rect.height() / grid_rows as f32; @@ -2190,80 +2033,47 @@ impl UltraLogApp { } else { max_value - min_value }; + let max_dim = grid_cols.max(grid_rows); + let font = egui::FontId::monospace(if max_dim <= 16 { 9.0 } else { 7.0 }); for (y_bin, row) in values.iter().enumerate().take(grid_rows) { for (x_bin, cell_value) in row.iter().enumerate().take(grid_cols) { let cell_x = plot_rect.left() + x_bin as f32 * cell_width; let cell_y = plot_rect.bottom() - (y_bin + 1) as f32 * cell_height; - let cell_rect = egui::Rect::from_min_size( + let rect = egui::Rect::from_min_size( egui::pos2(cell_x, cell_y), egui::vec2(cell_width, cell_height), - ); - - // Check min hits filter if hit_counts provided - if let Some(hits) = hit_counts - && min_hits_filter > 0 - { - let cell_hits = hits - .get(y_bin) - .and_then(|r| r.get(x_bin)) - .copied() - .unwrap_or(0); - if cell_hits < min_hits_filter { - painter.rect_filled(cell_rect, 0.0, egui::Color32::from_rgb(30, 30, 30)); - continue; - } - } - - if let Some(value) = cell_value { - let normalized = ((value - min_value) / value_range).clamp(0.0, 1.0); - let color = Self::get_histogram_color(normalized); - painter.rect_filled(cell_rect, 0.0, color); - - // Draw value text if cell is large enough - if cell_width > 20.0 && cell_height > 14.0 { - let text = format!("{:.1}", value); - let text_color = get_aaa_text_color(color); - let max_dim = grid_cols.max(grid_rows); - let text_size = if max_dim <= 16 { 9.0 } else { 7.0 }; - painter.text( - cell_rect.center(), - egui::Align2::CENTER_CENTER, - text, - egui::FontId::proportional(text_size), - text_color, + ) + .shrink(0.5); + + let filtered = hit_counts.is_some_and(|hits| { + min_hits_filter > 0 + && hits + .get(y_bin) + .and_then(|r| r.get(x_bin)) + .copied() + .unwrap_or(0) + < min_hits_filter + }); + match cell_value { + Some(value) if !filtered => { + let normalized = ((value - min_value) / value_range).clamp(0.0, 1.0); + let label = (cell_width > 20.0 && cell_height > 14.0) + .then(|| format!("{:.1}", value)); + heatmap::cell( + painter, + rect, + &t, + MapKind::Sequential, + normalized as f32, + label.as_deref(), + &font, ); } - } else { - painter.rect_filled(cell_rect, 0.0, egui::Color32::from_rgb(30, 30, 30)); + _ => heatmap::empty_cell(painter, rect, &t), } } } - - // Draw grid lines - let grid_color = egui::Color32::from_rgb(60, 60, 60); - // Vertical lines (X axis divisions) - for i in 0..=grid_cols { - let x = plot_rect.left() + i as f32 * cell_width; - painter.line_segment( - [ - egui::pos2(x, plot_rect.top()), - egui::pos2(x, plot_rect.bottom()), - ], - egui::Stroke::new(0.5, grid_color), - ); - } - // Horizontal lines (Y axis divisions) - for i in 0..=grid_rows { - let y = plot_rect.top() + i as f32 * cell_height; - painter.line_segment( - [ - egui::pos2(plot_rect.left(), y), - egui::pos2(plot_rect.right(), y), - ], - egui::Stroke::new(0.5, grid_color), - ); - } } /// Copy histogram data to clipboard as TSV (tab-separated values) diff --git a/src/ui/icons.rs b/src/ui/icons.rs deleted file mode 100644 index 4f92274b..00000000 --- a/src/ui/icons.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Custom icon drawing utilities. - -use eframe::egui; - -/// Draw a right-pointing triangle (for collapsed state) -pub fn draw_triangle_right(ui: &mut egui::Ui, center: egui::Pos2, size: f32, color: egui::Color32) { - let painter = ui.painter(); - let half = size / 2.0; - let points = [ - egui::pos2(center.x - half * 0.3, center.y - half), - egui::pos2(center.x + half * 0.6, center.y), - egui::pos2(center.x - half * 0.3, center.y + half), - ]; - painter.add(egui::Shape::convex_polygon( - points.to_vec(), - color, - egui::Stroke::NONE, - )); -} - -/// Draw a down-pointing triangle (for expanded state) -pub fn draw_triangle_down(ui: &mut egui::Ui, center: egui::Pos2, size: f32, color: egui::Color32) { - let painter = ui.painter(); - let half = size / 2.0; - let points = [ - egui::pos2(center.x - half, center.y - half * 0.3), - egui::pos2(center.x + half, center.y - half * 0.3), - egui::pos2(center.x, center.y + half * 0.6), - ]; - painter.add(egui::Shape::convex_polygon( - points.to_vec(), - color, - egui::Stroke::NONE, - )); -} - -/// Draw an upload icon (circle with upward arrow) -pub fn draw_upload_icon(ui: &mut egui::Ui, center: egui::Pos2, size: f32, color: egui::Color32) { - let painter = ui.painter(); - let radius = size / 2.0; - - // Draw circle outline - painter.circle_stroke(center, radius, egui::Stroke::new(2.0, color)); - - // Draw arrow pointing up - let arrow_size = size * 0.35; - let arrow_top = egui::pos2(center.x, center.y - arrow_size * 0.6); - let arrow_bottom = egui::pos2(center.x, center.y + arrow_size * 0.4); - - // Arrow shaft - painter.line_segment([arrow_bottom, arrow_top], egui::Stroke::new(2.0, color)); - - // Arrow head - let head_size = arrow_size * 0.4; - painter.line_segment( - [ - arrow_top, - egui::pos2(arrow_top.x - head_size, arrow_top.y + head_size), - ], - egui::Stroke::new(2.0, color), - ); - painter.line_segment( - [ - arrow_top, - egui::pos2(arrow_top.x + head_size, arrow_top.y + head_size), - ], - egui::Stroke::new(2.0, color), - ); -} diff --git a/src/ui/menu.rs b/src/ui/menu.rs index ea09ba31..b73e26d8 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -1,4 +1,4 @@ -//! Menu bar UI components (File, View, Help menus). +//! Menu bar UI components (File, View, Help menus and the theme menu). //! //! Simplified menu structure - settings moved to Settings panel. @@ -7,32 +7,22 @@ use rust_i18n::t; use crate::app::UltraLogApp; use crate::state::{ActivePanel, ActiveTool, LoadingState}; +use crate::ui::components::icon; +use crate::ui::theme; + +/// Width of the theme menu, enough for the four stop names under the slider. +const THEME_MENU_WIDTH: f32 = 280.0; impl UltraLogApp { /// Render the application menu bar pub fn render_menu_bar(&mut self, ui: &mut egui::Ui) { // Pre-compute scaled font sizes for use in closures - let font_14 = self.scaled_font(14.0); - let font_15 = self.scaled_font(15.0); egui::MenuBar::new().ui(ui, |ui| { - // Increase font size for menu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_15)); - // File menu ui.menu_button(t!("menu.file"), |ui| { ui.set_min_width(180.0); - // Increase font size for dropdown items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); - let is_loading = matches!(self.loading_state, LoadingState::Loading(_)); // Open file option @@ -50,6 +40,40 @@ impl UltraLogApp { ui.close(); } + // Compare the active tab with another log (issue #98) + if let Some(tab_idx) = self.active_tab { + ui.add_enabled_ui(!is_loading, |ui| { + ui.menu_button(t!("compare.compare_with"), |ui| { + let tab_file = self.tabs[tab_idx].file_index; + let current = self.tabs[tab_idx].compare_file(); + let mut choice: Option> = None; + if ui + .radio(current.is_none(), t!("compare.none").as_ref()) + .clicked() + { + choice = Some(None); + } + for (i, file) in self.files.iter().enumerate() { + if i != tab_file + && ui.radio(current == Some(i), file.name.as_str()).clicked() + { + choice = Some(Some(i)); + } + } + ui.separator(); + if ui.button(t!("compare.open_file")).clicked() { + ui.close(); + self.pick_compare_file(tab_idx); + } + if let Some(file) = choice { + self.choose_compare_file(tab_idx, file); + self.set_active_tool(ActiveTool::LogViewer); + ui.close(); + } + }); + }); + } + ui.separator(); // Close current tab @@ -67,7 +91,7 @@ impl UltraLogApp { ui.separator(); - // Export submenu - context-aware based on active tool + // Export opens the ExportDialog for the active tool let has_chart_data = !self.files.is_empty() && !self.get_selected_channels().is_empty(); let has_histogram_data = !self.files.is_empty() @@ -84,39 +108,16 @@ impl UltraLogApp { .generator_kind() .is_some_and(|k| self.table_generator.table(k).is_some()); - let can_export = has_chart_data || has_histogram_data || has_table_data; + let has_scatter_data = + !self.files.is_empty() && self.active_tool == ActiveTool::ScatterPlot; + let can_export = + has_chart_data || has_histogram_data || has_scatter_data || has_table_data; ui.add_enabled_ui(can_export, |ui| { - ui.menu_button(t!("menu.export"), |ui| { - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - - if has_table_data { - if ui.button(t!("menu.export_table_png")).clicked() { - self.export_table_png(); - ui.close(); - } - if ui.button(t!("menu.export_table_pdf")).clicked() { - self.export_table_pdf(); - ui.close(); - } - } else if self.active_tool == ActiveTool::Histogram && has_histogram_data { - if ui.button(t!("menu.export_histogram_pdf")).clicked() { - self.export_histogram_pdf(); - ui.close(); - } - } else if has_chart_data { - if ui.button(t!("menu.export_png")).clicked() { - self.export_chart_png(); - ui.close(); - } - if ui.button(t!("menu.export_pdf")).clicked() { - self.export_chart_pdf(); - ui.close(); - } - } - }); + if ui.button(t!("menu.export_ellipsis")).clicked() { + self.show_export_dialog = true; + ui.close(); + } }); }); @@ -124,18 +125,11 @@ impl UltraLogApp { ui.menu_button(t!("menu.view"), |ui| { ui.set_min_width(200.0); - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); - // Chart grid toggle let old_show_grid = self.show_grid; ui.checkbox( &mut self.show_grid, - egui::RichText::new(t!("menu.show_grid")).size(font_14), + egui::RichText::new(t!("menu.show_grid")).text_style(egui::TextStyle::Body), ); if self.show_grid != old_show_grid { self.user_settings.show_grid = self.show_grid; @@ -149,8 +143,8 @@ impl UltraLogApp { // Tool modes ui.label( egui::RichText::new(t!("menu.tool_mode")) - .size(font_14) - .color(egui::Color32::GRAY), + .text_style(egui::TextStyle::Body) + .color(self.tokens.ink_subtle), ); for (i, tool) in ActiveTool::ALL.into_iter().enumerate() { @@ -176,8 +170,8 @@ impl UltraLogApp { // Panel navigation ui.label( egui::RichText::new(t!("menu.side_panel")) - .size(font_14) - .color(egui::Color32::GRAY), + .text_style(egui::TextStyle::Body) + .color(self.tokens.ink_subtle), ); if ui @@ -222,13 +216,6 @@ impl UltraLogApp { ui.menu_button(t!("menu.help"), |ui| { ui.set_min_width(200.0); - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); - if ui.button(t!("menu.documentation")).clicked() { let _ = open::that("https://github.com/ClassicMiniDIY/UltraLog/wiki"); ui.close(); @@ -270,16 +257,40 @@ impl UltraLogApp { ui.separator(); + if ui.button(t!("menu.about")).clicked() { + self.show_about = true; + ui.close(); + } + ui.horizontal(|ui| { ui.label( egui::RichText::new(t!( "menu.version", version = env!("CARGO_PKG_VERSION") )) - .color(egui::Color32::GRAY), + .color(self.tokens.ink_subtle), ); }); }); + + // Theme menu: the Ultra intensity control from Settings → Display, labelled with + // the current level. Clicks inside must not close it, or the slider and the stop + // labels could not be used. + let theme_label = format!( + "{} {}", + icon::MOON_STARS, + theme::intensity_name(self.ultra_intensity) + ); + egui::containers::menu::MenuButton::new(theme_label) + .config( + egui::containers::menu::MenuConfig::find(ui) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside), + ) + .ui(ui, |ui| { + ui.set_width(THEME_MENU_WIDTH); + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); + self.render_intensity_control(ui); + }); }); } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7f3d8594..5534f4fb 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -11,13 +11,11 @@ //! - `settings_panel` - Consolidated settings (display, units, normalization, updates) //! //! ## Core UI Components -//! - `sidebar` - Legacy files panel (being replaced by files_panel) //! - `channels` - Legacy channel selection (being replaced by channels_panel) //! - `chart` - Main chart rendering and legends //! - `timeline` - Timeline scrubber and playback controls //! - `menu` - Menu bar (File, Edit, View, Help) //! - `toast` - Toast notification system -//! - `icons` - Custom icon drawing utilities //! - `export` - Chart export functionality (PNG, PDF) //! - `normalization_editor` - Field normalization customization window //! - `tool_switcher` - Tool mode selection (Log Viewer, Scatter Plot, Histogram) @@ -30,9 +28,12 @@ //! - `formula_editor` - Formula creation and editing // New activity bar architecture +pub mod about_window; pub mod activity_bar; +pub mod components; pub mod data_panel; pub mod files_panel; +pub mod fonts; pub mod settings_panel; pub mod side_panel; pub mod tool_properties_panel; @@ -45,16 +46,20 @@ pub mod channels; pub mod chart; pub mod computed_channels_manager; pub mod export; +pub mod export_dialog; +pub mod export_sheet; pub mod formula_editor; +pub mod formula_syntax; +pub mod heatmap; pub mod histogram; -pub mod icons; pub mod menu; pub mod normalization_editor; +pub mod plot_style; pub mod scatter_plot; -pub mod sidebar; pub mod tab_bar; pub mod table_export; pub mod table_generator; +pub mod theme; pub mod timeline; pub mod toast; pub mod tool_switcher; diff --git a/src/ui/normalization_editor.rs b/src/ui/normalization_editor.rs index f14f6b1a..de48f24f 100644 --- a/src/ui/normalization_editor.rs +++ b/src/ui/normalization_editor.rs @@ -7,6 +7,7 @@ use rust_i18n::t; use crate::app::UltraLogApp; use crate::normalize::get_builtin_mappings; +use crate::ui::components; impl UltraLogApp { /// Render the field normalization editor window @@ -17,7 +18,12 @@ impl UltraLogApp { let mut open = true; - egui::Window::new(t!("normalization.title")) + let window_response = components::window(t!("normalization.title")) + // First open is centered; egui's default (0, 0) put the title bar + // over the menu bar. The user can still drag it, and egui keeps + // the moved position after that. + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) .open(&mut open) .resizable(true) .default_width(550.0) @@ -47,7 +53,7 @@ impl UltraLogApp { ui.label(egui::RichText::new(t!("normalization.extend_builtin")).strong()); ui.label( egui::RichText::new(t!("normalization.extend_description")) - .color(egui::Color32::GRAY), + .color(self.tokens.ink_subtle), ); ui.add_space(8.0); @@ -104,7 +110,7 @@ impl UltraLogApp { ui.label(egui::RichText::new(t!("normalization.create_new")).strong()); ui.label( egui::RichText::new(t!("normalization.create_description")) - .color(egui::Color32::GRAY), + .color(self.tokens.ink_subtle), ); ui.add_space(8.0); @@ -145,7 +151,7 @@ impl UltraLogApp { ui.label(egui::RichText::new(t!("normalization.your_mappings")).strong()); ui.label( egui::RichText::new(format!("({})", self.custom_normalizations.len())) - .color(egui::Color32::GRAY), + .color(self.tokens.ink_subtle), ); }); ui.add_space(4.0); @@ -180,8 +186,7 @@ impl UltraLogApp { for (source, target) in sorted { ui.label(source); ui.label( - egui::RichText::new(target) - .color(egui::Color32::LIGHT_BLUE), + egui::RichText::new(target).color(self.tokens.ink), ); if ui.small_button(t!("common.remove")).clicked() { to_remove = Some(source.clone()); @@ -205,7 +210,7 @@ impl UltraLogApp { .show(ui, |ui| { ui.label( egui::RichText::new(t!("normalization.builtin_description")) - .color(egui::Color32::GRAY), + .color(self.tokens.ink_subtle), ); ui.add_space(8.0); @@ -218,12 +223,12 @@ impl UltraLogApp { ui.label( egui::RichText::new(*normalized) .strong() - .color(egui::Color32::LIGHT_BLUE), + .color(self.tokens.ink), ); ui.label("←"); ui.label( egui::RichText::new(sources.join(", ")) - .color(egui::Color32::GRAY), + .color(self.tokens.ink_subtle), ); }); ui.add_space(2.0); @@ -232,6 +237,11 @@ impl UltraLogApp { }); }); + components::finish_window( + ctx, + window_response.as_ref().map(|r| &r.response), + &mut open, + ); if !open { self.show_normalization_editor = false; } diff --git a/src/ui/plot_style.rs b/src/ui/plot_style.rs new file mode 100644 index 00000000..149df0cc --- /dev/null +++ b/src/ui/plot_style.rs @@ -0,0 +1,427 @@ +//! Night Drive plot chrome shared by the single and stacked chart views +//! (`design/design-system/components/Plot/README.md`, `3-data-visualization.md`). +//! +//! - Series: colour and stroke from `theme::series`, 1.5px; a soloed series goes to 2px and the +//! others drop to 40 %. At Neon and above a wider translucent copy (bloom) sits under each line. +//! At Ultra a perspective floor sits behind the data. Color-blind safe turns both off. +//! - Cursor: a full-height `plot_cursor` line, never a series colour. +//! - Legend: a floating `plot_legend_bg` box (top-right; top-left plus direct end labels when the +//! palette is color-blind safe) with the Cursor row first, then swatch, name and live value in +//! mono. Hovering a row solos that series. + +use eframe::egui::{ + self, Align2, Color32, CornerRadius, FontId, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2, +}; +use egui_plot::{Line, LineStyle, PlotBounds, PlotPoint, PlotPoints, PlotTransform, PlotUi, VLine}; + +use rust_i18n::t; + +use crate::ui::fonts::{self, style}; +use crate::ui::theme::{ + SeriesStroke, Tokens, + tokens::{radius, space, stroke}, +}; + +/// One series as the chart draws and lists it. +pub struct SeriesRow { + pub name: String, + /// "A" / "B" while the tab compares two logs (issue #98); drawn before the name. + pub tag: Option<&'static str>, + /// Value at the cursor, already unit-converted and formatted (mono column). + pub value: Option, + pub color: Color32, + pub stroke: SeriesStroke, +} + +/// What the chart needs to style one plot. +pub struct PlotStyle<'a> { + pub tokens: &'a Tokens, + /// Color blind mode, High contrast (and exports): no bloom or floor, direct end labels. + pub cvd_like: bool, + /// Series index soloed by hovering its legend row last frame. + pub solo: Option, +} + +fn line_style(stroke: SeriesStroke) -> LineStyle { + match stroke { + SeriesStroke::Solid => LineStyle::Solid, + // 6/4 dash per the spec (egui_plot's gap is a fixed fraction of the length). + SeriesStroke::Dashed => LineStyle::Dashed { length: 6.0 }, + SeriesStroke::Dotted => LineStyle::Dotted { spacing: 4.0 }, + } +} + +/// Draw the floor (Ultra), the series with bloom and solo, and the cursor. Call inside +/// `Plot::show` after setting the bounds. `points[i]` belongs to `rows[i]`. +pub fn draw_series( + plot_ui: &mut PlotUi<'_>, + style: &PlotStyle<'_>, + rows: &[SeriesRow], + points: &[Option>], + cursor_time: Option, +) { + let t = style.tokens; + if t.fx.plot_floor && !style.cvd_like { + for segment in floor_segments(plot_ui.plot_bounds()) { + plot_ui.line( + Line::new("", PlotPoints::from(segment.to_vec())) + .color(t.plot_floor) + .width(stroke::STROKE_HAIRLINE) + .allow_hover(false), + ); + } + } + + let bloom = if style.cvd_like { 0.0 } else { t.fx.bloom }; + for (i, row) in rows.iter().enumerate() { + let Some(points) = points.get(i).and_then(|p| p.as_ref()) else { + continue; + }; + let dimmed = style.solo.is_some_and(|s| s != i); + let color = if dimmed { + row.color.gamma_multiply(0.4) + } else { + row.color + }; + if bloom > 0.0 && !dimmed { + plot_ui.line( + Line::new("", points.iter().copied().collect::>()) + .color(row.color.gamma_multiply(0.25)) + .width(stroke::STROKE_SERIES + 2.0 * bloom) + .style(line_style(row.stroke)) + .allow_hover(false), + ); + } + let width = if style.solo == Some(i) { + stroke::STROKE_INDICATOR + } else { + stroke::STROKE_SERIES + }; + plot_ui.line( + Line::new( + row.name.clone(), + points.iter().copied().collect::>(), + ) + .color(color) + .width(width) + .style(line_style(row.stroke)), + ); + } + + if let Some(time) = cursor_time { + plot_ui.vline( + VLine::new("Cursor", time) + .color(t.plot_cursor) + .width(stroke::STROKE_CURSOR), + ); + } +} + +/// Floor segments in plot coordinates: a horizon at 45 % of the height, lines fanning from a +/// vanishing point above it to the bottom edge, and horizontals closing up toward the horizon. +fn floor_segments(bounds: PlotBounds) -> Vec<[[f64; 2]; 2]> { + let [x0, y0] = bounds.min(); + let [x1, y1] = bounds.max(); + let horizon = y0 + (y1 - y0) * 0.45; + let depth = horizon - y0; + let vanish = [(x0 + x1) / 2.0, horizon + depth * 0.9]; + let width = x1 - x0; + let mut out = Vec::new(); + let fan = 16; + for i in 0..=fan { + let x = x0 - width * 0.75 + width * 2.5 * i as f64 / fan as f64; + let k = (horizon - vanish[1]) / (y0 - vanish[1]); + let start = [vanish[0] + (x - vanish[0]) * k, horizon]; + let end = [x, y0]; + if let Some(segment) = clip_x(start, end, x0, x1) { + out.push(segment); + } + } + for i in 1..=5 { + let k = (i as f64 / 5.0).powi(2); + let y = horizon - depth * k; + out.push([[x0, y], [x1, y]]); + } + out +} + +/// Clip the segment `a`-`b` to the band `x0..=x1`. +fn clip_x(a: [f64; 2], b: [f64; 2], x0: f64, x1: f64) -> Option<[[f64; 2]; 2]> { + let dx = b[0] - a[0]; + let (mut t0, mut t1) = (0.0f64, 1.0f64); + for (p, q) in [(-dx, a[0] - x0), (dx, x1 - a[0])] { + if p == 0.0 { + if q < 0.0 { + return None; + } + } else if p < 0.0 { + t0 = t0.max(q / p); + } else { + t1 = t1.min(q / p); + } + } + let at = |t: f64| [a[0] + dx * t, a[1] + (b[1] - a[1]) * t]; + (t0 < t1).then(|| [at(t0), at(t1)]) +} + +/// How many of `series` legend rows to show in `max_rows`, and how many go into a "+N" row. +fn legend_fit(max_rows: usize, series: usize, cursor_rows: usize) -> (usize, usize) { + let shown = if series + cursor_rows <= max_rows { + series + } else { + max_rows.saturating_sub(cursor_rows + 1) + }; + (shown, series - shown) +} + +/// Paint the floating legend over `plot_rect` and return the row the pointer is on, if any +/// (used to solo that series next frame). `cursor` is the formatted cursor time. +pub fn legend_overlay( + ui: &Ui, + plot_rect: Rect, + style: &PlotStyle<'_>, + rows: &[SeriesRow], + cursor: Option, + id: Id, +) -> Option { + let t = style.tokens; + let name_font = fonts::text_style(style::LABEL).resolve(ui.style()); + let value_font = fonts::text_style(style::DATA).resolve(ui.style()); + let row_h = (name_font.size.max(value_font.size) + space::SPACE_2).round(); + let swatch_w = 14.0; + + let measure = |text: &str, font: &FontId| { + ui.painter() + .layout_no_wrap(text.to_owned(), font.clone(), t.ink) + .size() + .x + }; + let cursor_label = t!("chart.cursor").to_string(); + let mut name_w = measure(&cursor_label, &name_font); + let mut value_w = cursor.as_deref().map_or(0.0, |c| measure(c, &value_font)); + let mut tag_w: f32 = 0.0; + for row in rows { + name_w = name_w.max(measure(&row.name, &name_font)); + if let Some(v) = &row.value { + value_w = value_w.max(measure(v, &value_font)); + } + if let Some(tag) = row.tag { + tag_w = tag_w.max(measure(tag, &value_font) + space::SPACE_2); + } + } + let pad = space::SPACE_2 + 2.0; + let width = pad + swatch_w + space::SPACE_2 + tag_w + name_w + space::SPACE_6 + value_w + pad; + let inset = space::SPACE_3; + // The legend is the only cursor readout, so a short plot shows the rows that fit and a + // "+N" row for the rest instead of dropping the legend. + let cursor_rows = usize::from(cursor.is_some()); + let max_rows = ((plot_rect.height() - 2.0 * inset - 2.0 * pad + space::SPACE_2) / row_h) + .floor() + .max(0.0) as usize; + let (shown, hidden) = legend_fit(max_rows, rows.len(), cursor_rows); + let count = cursor_rows + shown + usize::from(hidden > 0); + if count > max_rows { + // Not even the cursor row and a "+N" row fit. + return None; + } + let height = pad * 2.0 + row_h * count as f32 - space::SPACE_2; + let origin = if style.cvd_like { + plot_rect.left_top() + vec2(inset, inset) + } else { + pos2(plot_rect.right() - inset - width, plot_rect.top() + inset) + }; + let rect = Rect::from_min_size(origin, vec2(width, height)); + if !plot_rect.contains_rect(rect) { + return None; + } + + let painter = ui.painter().with_clip_rect(plot_rect); + painter.rect( + rect, + CornerRadius::same(radius::RADIUS_MD), + t.plot_legend_bg, + Stroke::new(stroke::STROKE_HAIRLINE, t.line), + egui::StrokeKind::Inside, + ); + + let mut y = rect.top() + pad; + let swatch_x = rect.left() + pad; + let name_x = swatch_x + swatch_w + space::SPACE_2; + let value_x = rect.right() - pad; + if let Some(cursor) = cursor { + let cy = y + row_h / 2.0 - space::SPACE_1; + painter.circle_filled(pos2(swatch_x + swatch_w / 2.0, cy), 4.0, t.plot_cursor); + painter.text( + pos2(name_x, cy), + Align2::LEFT_CENTER, + &cursor_label, + name_font.clone(), + t.ink_muted, + ); + painter.text( + pos2(value_x, cy), + Align2::RIGHT_CENTER, + cursor, + value_font.clone(), + t.ink, + ); + y += row_h; + } + + let mut hovered = None; + for (i, row) in rows.iter().enumerate().take(shown) { + let row_rect = + Rect::from_min_size(pos2(rect.left(), y - space::SPACE_1), vec2(width, row_h)); + let response = ui.interact(row_rect, id.with(("legend_row", i)), Sense::hover()); + if response.hovered() { + hovered = Some(i); + } + let dimmed = style.solo.is_some_and(|s| s != i); + let cy = y + row_h / 2.0 - space::SPACE_1; + swatch( + &painter, + pos2(swatch_x, cy), + swatch_w, + if dimmed { + row.color.gamma_multiply(0.4) + } else { + row.color + }, + row.stroke, + ); + if let Some(tag) = row.tag { + painter.text( + pos2(name_x, cy), + Align2::LEFT_CENTER, + tag, + value_font.clone(), + if dimmed { t.ink_subtle } else { t.ink_muted }, + ); + } + painter.text( + pos2(name_x + tag_w, cy), + Align2::LEFT_CENTER, + &row.name, + name_font.clone(), + if dimmed { t.ink_subtle } else { t.ink }, + ); + if let Some(v) = &row.value { + painter.text( + pos2(value_x, cy), + Align2::RIGHT_CENTER, + v, + value_font.clone(), + if dimmed { t.ink_subtle } else { t.ink }, + ); + } + y += row_h; + } + if hidden > 0 { + painter.text( + pos2(name_x, y + row_h / 2.0 - space::SPACE_1), + Align2::LEFT_CENTER, + format!("+{hidden}"), + value_font.clone(), + t.ink_subtle, + ); + } + hovered +} + +/// The legend / channel-card swatch: a short line in the series colour, dashed or dotted to +/// match the series stroke, so identity never rests on colour alone. +pub fn swatch( + painter: &egui::Painter, + left_center: Pos2, + width: f32, + color: Color32, + kind: SeriesStroke, +) { + let s = Stroke::new(stroke::STROKE_INDICATOR, color); + let a = left_center; + let b = left_center + vec2(width, 0.0); + match kind { + SeriesStroke::Solid => { + painter.line_segment([a, b], s); + } + SeriesStroke::Dashed => { + painter.extend(egui::Shape::dashed_line(&[a, b], s, 4.0, 2.5)); + } + SeriesStroke::Dotted => { + painter.extend(egui::Shape::dotted_line(&[a, b], color, 3.5, 1.0)); + } + } +} + +/// Direct labels at each line's right end (color-blind safe palettes): the series name in its +/// colour, pushed apart vertically so labels never overlap. +pub fn direct_labels( + ui: &Ui, + transform: &PlotTransform, + rows: &[SeriesRow], + points: &[Option>], +) { + let plot_rect = *transform.frame(); + let x_max = transform.bounds().max()[0]; + let font = fonts::text_style(style::DATA_SM).resolve(ui.style()); + let mut labels: Vec<(f32, usize)> = rows + .iter() + .enumerate() + .filter_map(|(i, _)| { + let pts = points.get(i)?.as_ref()?; + let last = pts.iter().rev().find(|p| p[0] <= x_max)?; + let pos = transform.position_from_point(&PlotPoint::new(last[0], last[1])); + Some((pos.y, i)) + }) + .collect(); + labels.sort_by(|a, b| a.0.total_cmp(&b.0)); + let gap = font.size + 2.0; + for k in 1..labels.len() { + if labels[k].0 - labels[k - 1].0 < gap { + labels[k].0 = labels[k - 1].0 + gap; + } + } + let painter = ui.painter().with_clip_rect(plot_rect); + for (y, i) in labels { + let y = y.clamp(plot_rect.top() + gap / 2.0, plot_rect.bottom() - gap / 2.0); + painter.text( + pos2(plot_rect.right() - space::SPACE_2, y - gap / 2.0), + Align2::RIGHT_CENTER, + // With the tag: in Color blind mode and High contrast a pair shares its colour. + match rows[i].tag { + Some(tag) => format!("{tag} {}", rows[i].name), + None => rows[i].name.clone(), + }, + font.clone(), + rows[i].color, + ); + } +} + +/// Read and store the soloed series for a plot between frames. +pub fn solo_memory(ctx: &egui::Context, id: Id, update: Option>) -> Option { + let key = id.with("solo"); + if let Some(value) = update { + ctx.data_mut(|d| d.insert_temp(key, value)); + value + } else { + ctx.data(|d| d.get_temp::>(key)).flatten() + } +} + +#[cfg(test)] +mod tests { + use super::legend_fit; + + #[test] + fn legend_keeps_the_rows_that_fit_and_counts_the_rest() { + // Everything fits: cursor row + 4 series in 6 rows. + assert_eq!(legend_fit(6, 4, 1), (4, 0)); + // 10 series + cursor in 6 rows: cursor, 4 series, "+6". + assert_eq!(legend_fit(6, 10, 1), (4, 6)); + // Room for the cursor and the "+N" row only. + assert_eq!(legend_fit(2, 3, 1), (0, 3)); + // No room at all. + assert_eq!(legend_fit(0, 3, 1), (0, 3)); + } +} diff --git a/src/ui/scatter_plot.rs b/src/ui/scatter_plot.rs index f5a2a730..2bb94ebb 100644 --- a/src/ui/scatter_plot.rs +++ b/src/ui/scatter_plot.rs @@ -9,21 +9,9 @@ use rust_i18n::t; use crate::app::UltraLogApp; use crate::normalize::{normalize_channel_name_with_custom, sort_channels_by_priority}; use crate::state::{ScatterHistogram, ScatterPlotConfig, SelectedHeatmapPoint}; - -/// Heat map color gradient from blue (low) to red (high) -const HEAT_COLORS: &[[u8; 3]] = &[ - [0, 0, 80], // Dark blue (0.0) - [0, 0, 180], // Blue - [0, 100, 255], // Light blue - [0, 200, 255], // Cyan - [0, 255, 200], // Cyan-green - [0, 255, 100], // Green - [100, 255, 0], // Yellow-green - [200, 255, 0], // Yellow - [255, 200, 0], // Orange - [255, 100, 0], // Red-orange - [255, 0, 0], // Red (1.0) -]; +use crate::ui::components::icon; +use crate::ui::fonts::{self, style}; +use crate::ui::heatmap::{self, MapKind}; /// Number of bins in each dimension for the heatmap grid (higher = more detail) const HEATMAP_BINS: usize = 512; @@ -35,9 +23,6 @@ const AXIS_LABEL_MARGIN_BOTTOM: f32 = 25.0; /// Height reserved for the legend at the bottom const LEGEND_HEIGHT: f32 = 35.0; -/// Crosshair color -const CROSSHAIR_COLOR: egui::Color32 = egui::Color32::from_rgb(255, 255, 0); // Yellow - impl UltraLogApp { /// Render the scatter plot view with two side-by-side plots pub fn render_scatter_plot_view(&mut self, ui: &mut egui::Ui) { @@ -46,8 +31,8 @@ impl UltraLogApp { ui.centered_and_justified(|ui| { ui.label( egui::RichText::new(t!("scatter.no_file_loaded")) - .size(self.scaled_font(20.0)) - .color(egui::Color32::GRAY), + .heading() + .color(self.tokens.ink_subtle), ); }); return; @@ -96,9 +81,9 @@ impl UltraLogApp { ui.horizontal(|ui| { ui.label( egui::RichText::new(&title) - .size(self.scaled_font(16.0)) + .heading() .strong() - .color(egui::Color32::WHITE), + .color(self.tokens.ink), ); }); @@ -172,7 +157,7 @@ impl UltraLogApp { egui::Align2::CENTER_CENTER, "Select X and Y axes", egui::FontId::proportional(font_16), - egui::Color32::GRAY, + self.tokens.ink_subtle, ); return; } @@ -279,14 +264,16 @@ impl UltraLogApp { ); // Fill background with black + let t = self.tokens; let painter = ui.painter_at(full_rect); - painter.rect_filled(plot_rect, 0.0, egui::Color32::BLACK); + painter.rect_filled(plot_rect, 0.0, t.plot_bg); // Calculate cell size based on plot rect (not full rect) let cell_width = plot_rect.width() / HEATMAP_BINS as f32; let cell_height = plot_rect.height() / HEATMAP_BINS as f32; - // Draw heatmap cells + // Draw heatmap cells (colours from a LUT: up to 512² bins every frame) + let lut = crate::colormap::ColormapLut::new(&t, crate::colormap::Colormap::Sequential); #[allow(clippy::needless_range_loop)] for y_bin in 0..HEATMAP_BINS { #[allow(clippy::needless_range_loop)] @@ -299,7 +286,7 @@ impl UltraLogApp { } else { 1.0 }; - let color = Self::get_heat_color(normalized); + let color = lut.get(normalized as f32); // Calculate cell position (Y is inverted - higher values at top) let cell_x = plot_rect.left() + x_bin as f32 * cell_width; @@ -318,7 +305,7 @@ impl UltraLogApp { } // Draw axes labels - let text_color = egui::Color32::from_rgb(200, 200, 200); + let text_color = t.plot_axis; // Y axis labels (left margin, outside plot area) let y_labels = 5; @@ -341,7 +328,7 @@ impl UltraLogApp { egui::pos2(plot_rect.left(), y_pos), egui::pos2(plot_rect.right(), y_pos), ], - egui::Stroke::new(0.5, egui::Color32::from_rgb(60, 60, 60)), + egui::Stroke::new(0.5, self.tokens.plot_grid), ); } @@ -366,7 +353,7 @@ impl UltraLogApp { egui::pos2(x_pos, plot_rect.top()), egui::pos2(x_pos, plot_rect.bottom()), ], - egui::Stroke::new(0.5, egui::Color32::from_rgb(60, 60, 60)), + egui::Stroke::new(0.5, self.tokens.plot_grid), ); } @@ -387,7 +374,7 @@ impl UltraLogApp { let sel_y = plot_rect.bottom() - sel_rel_y * plot_rect.height(); // Draw persistent crosshairs (cyan for selected) - let selected_color = egui::Color32::from_rgb(0, 255, 255); + let selected_color = t.plot_cursor; painter.line_segment( [ egui::pos2(sel_x, plot_rect.top()), @@ -429,14 +416,14 @@ impl UltraLogApp { egui::pos2(pos.x, plot_rect.top()), egui::pos2(pos.x, plot_rect.bottom()), ], - egui::Stroke::new(1.0, CROSSHAIR_COLOR), + egui::Stroke::new(1.0, t.ink_subtle), ); painter.line_segment( [ egui::pos2(plot_rect.left(), pos.y), egui::pos2(plot_rect.right(), pos.y), ], - egui::Stroke::new(1.0, CROSSHAIR_COLOR), + egui::Stroke::new(1.0, t.ink_subtle), ); // Draw tooltip in corner @@ -445,8 +432,8 @@ impl UltraLogApp { egui::pos2(plot_rect.right() - 10.0, plot_rect.top() + 15.0), egui::Align2::RIGHT_TOP, tooltip_text, - egui::FontId::proportional(font_11), - egui::Color32::WHITE, + egui::FontId::monospace(font_11), + self.tokens.ink, ); // Handle click to select point @@ -468,28 +455,6 @@ impl UltraLogApp { self.render_heatmap_legend(ui, max_hits, is_left, x_min, x_max, y_min, y_max); } - /// Get a color from the heat map gradient based on normalized value (0-1) - fn get_heat_color(normalized: f64) -> egui::Color32 { - let t = normalized.clamp(0.0, 1.0); - let scaled = t * (HEAT_COLORS.len() - 1) as f64; - let idx = scaled.floor() as usize; - let frac = scaled - idx as f64; - - if idx >= HEAT_COLORS.len() - 1 { - let c = HEAT_COLORS[HEAT_COLORS.len() - 1]; - return egui::Color32::from_rgb(c[0], c[1], c[2]); - } - - let c1 = HEAT_COLORS[idx]; - let c2 = HEAT_COLORS[idx + 1]; - - let r = (c1[0] as f64 + (c2[0] as f64 - c1[0] as f64) * frac) as u8; - let g = (c1[1] as f64 + (c2[1] as f64 - c1[1] as f64) * frac) as u8; - let b = (c1[2] as f64 + (c2[2] as f64 - c1[2] as f64) * frac) as u8; - - egui::Color32::from_rgb(r, g, b) - } - /// Render the heatmap legend with color scale and selected point info #[allow(clippy::too_many_arguments)] fn render_heatmap_legend( @@ -507,8 +472,6 @@ impl UltraLogApp { }; // Pre-compute scaled font sizes - let font_10 = self.scaled_font(10.0); - let font_11 = self.scaled_font(11.0); // First, gather the data we need (immutable borrow) let config = if is_left { @@ -563,44 +526,32 @@ impl UltraLogApp { // Color scale legend egui::Frame::NONE - .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 220)) + .fill(self.tokens.bg_raised) .corner_radius(4) .inner_margin(6.0) .show(ui, |ui| { ui.horizontal(|ui| { ui.label( egui::RichText::new(t!("scatter.hits_label")) - .size(font_11) - .color(egui::Color32::WHITE), + .small() + .color(self.tokens.ink), ); // Color gradient bar let (rect, _) = - ui.allocate_exact_size(egui::vec2(100.0, 14.0), egui::Sense::hover()); - - let painter = ui.painter(); - let steps = 25; - let step_width = rect.width() / steps as f32; - - for i in 0..steps { - let t = i as f64 / steps as f64; - let color = Self::get_heat_color(t); - let x = rect.left() + i as f32 * step_width; - painter.rect_filled( - egui::Rect::from_min_size( - egui::pos2(x, rect.top()), - egui::vec2(step_width + 1.0, rect.height()), - ), - 0.0, - color, - ); - } + ui.allocate_exact_size(egui::vec2(100.0, 8.0), egui::Sense::hover()); + heatmap::gradient_bar( + ui.painter(), + rect, + &self.tokens, + MapKind::Sequential, + ); ui.add_space(4.0); ui.label( egui::RichText::new(format!("0-{}", max_hits)) - .size(font_10) - .color(egui::Color32::WHITE), + .text_style(fonts::text_style(style::DATA_SM)) + .color(self.tokens.ink), ); }); }); @@ -610,20 +561,17 @@ impl UltraLogApp { // Selected point legend (similar to log viewer channel display) if let Some(ref selected) = selected_point { egui::Frame::NONE - .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 220)) + .fill(self.tokens.bg_raised) .corner_radius(4) .inner_margin(6.0) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(0, 255, 255))) + .stroke(egui::Stroke::new(1.0, self.tokens.cyan)) .show(ui, |ui| { ui.horizontal(|ui| { // Cyan indicator dot let (dot_rect, _) = ui .allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); - ui.painter().circle_filled( - dot_rect.center(), - 5.0, - egui::Color32::from_rgb(0, 255, 255), - ); + ui.painter() + .circle_filled(dot_rect.center(), 5.0, self.tokens.cyan); ui.add_space(4.0); @@ -636,8 +584,8 @@ impl UltraLogApp { selected.y_value, selected.hits )) - .size(font_11) - .color(egui::Color32::WHITE), + .small() + .color(self.tokens.ink), ); ui.add_space(8.0); @@ -664,9 +612,6 @@ impl UltraLogApp { /// Render scatter plot controls in the sidebar - optimized for vertical layout /// Shows controls for both left and right plots pub(crate) fn render_scatter_plot_controls(&mut self, ui: &mut egui::Ui) { - let font_14 = self.scaled_font(14.0); - let font_15 = self.scaled_font(15.0); - let Some(tab_idx) = self.active_tab else { return; }; @@ -690,29 +635,27 @@ impl UltraLogApp { .map(|(idx, name, _)| (*idx, name.clone())) .collect(); - let font_12 = self.scaled_font(12.0); - // ============================================================================ // LEFT PLOT SECTION // ============================================================================ let frame_left = egui::Frame::NONE - .fill(egui::Color32::from_rgb(40, 40, 40)) + .fill(self.tokens.bg_raised) .corner_radius(6) .inner_margin(egui::Margin::same(12)); frame_left.show(ui, |ui| { - ui.label(egui::RichText::new("📊 Left Plot").size(font_15).strong()); + ui.label( + egui::RichText::new(format!("{} Left plot", icon::CHART_SCATTER)) + .heading() + .strong(), + ); ui.add_space(12.0); let left_config = &self.tabs[tab_idx].scatter_plot_state.left; let mut left_new_x = None; let mut left_new_y = None; - ui.label( - egui::RichText::new(t!("scatter.x_axis")) - .size(font_14) - .strong(), - ); + ui.label(egui::RichText::new(t!("scatter.x_axis")).heading().strong()); egui::ComboBox::from_id_salt("left_x_sidebar") .selected_text( left_config @@ -735,11 +678,7 @@ impl UltraLogApp { ui.add_space(8.0); // Y Axis - ui.label( - egui::RichText::new(t!("scatter.y_axis")) - .size(font_14) - .strong(), - ); + ui.label(egui::RichText::new(t!("scatter.y_axis")).heading().strong()); egui::ComboBox::from_id_salt("left_y_sidebar") .selected_text( left_config @@ -765,13 +704,13 @@ impl UltraLogApp { ui.horizontal(|ui| { ui.label( egui::RichText::new(format!("{}: ", t!("scatter.z_axis"))) - .size(font_12) - .color(egui::Color32::from_rgb(150, 150, 150)), + .small() + .color(self.tokens.ink_subtle), ); ui.label( egui::RichText::new(t!("scatter.hits")) - .size(font_12) - .color(egui::Color32::from_rgb(180, 180, 180)) + .small() + .color(self.tokens.ink_muted) .italics(), ); }); @@ -793,23 +732,23 @@ impl UltraLogApp { // RIGHT PLOT SECTION // ============================================================================ let frame_right = egui::Frame::NONE - .fill(egui::Color32::from_rgb(40, 40, 40)) + .fill(self.tokens.bg_raised) .corner_radius(6) .inner_margin(egui::Margin::same(12)); frame_right.show(ui, |ui| { - ui.label(egui::RichText::new("📊 Right Plot").size(font_15).strong()); + ui.label( + egui::RichText::new(format!("{} Right plot", icon::CHART_SCATTER)) + .heading() + .strong(), + ); ui.add_space(12.0); let right_config = &self.tabs[tab_idx].scatter_plot_state.right; let mut right_new_x = None; let mut right_new_y = None; - ui.label( - egui::RichText::new(t!("scatter.x_axis")) - .size(font_14) - .strong(), - ); + ui.label(egui::RichText::new(t!("scatter.x_axis")).heading().strong()); egui::ComboBox::from_id_salt("right_x_sidebar") .selected_text( right_config @@ -832,11 +771,7 @@ impl UltraLogApp { ui.add_space(8.0); // Y Axis - ui.label( - egui::RichText::new(t!("scatter.y_axis")) - .size(font_14) - .strong(), - ); + ui.label(egui::RichText::new(t!("scatter.y_axis")).heading().strong()); egui::ComboBox::from_id_salt("right_y_sidebar") .selected_text( right_config @@ -862,13 +797,13 @@ impl UltraLogApp { ui.horizontal(|ui| { ui.label( egui::RichText::new(format!("{}: ", t!("scatter.z_axis"))) - .size(font_12) - .color(egui::Color32::from_rgb(150, 150, 150)), + .small() + .color(self.tokens.ink_subtle), ); ui.label( egui::RichText::new(t!("scatter.hits")) - .size(font_12) - .color(egui::Color32::from_rgb(180, 180, 180)) + .small() + .color(self.tokens.ink_muted) .italics(), ); }); diff --git a/src/ui/settings_panel.rs b/src/ui/settings_panel.rs index 898d3bd4..ee442a18 100644 --- a/src/ui/settings_panel.rs +++ b/src/ui/settings_panel.rs @@ -9,6 +9,9 @@ use crate::analytics; use crate::app::UltraLogApp; use crate::i18n::Language; use crate::state::FontScale; +use crate::ui::components::{self, icon}; +use crate::ui::theme; +use crate::ui::theme::tokens::space; use crate::units::{ AccelerationUnit, AfrLambdaUnit, DistanceUnit, FlowUnit, FuelEconomyUnit, PressureUnit, SpeedUnit, TemperatureUnit, VolumeUnit, @@ -52,20 +55,17 @@ impl UltraLogApp { /// Render language settings section fn render_language_settings(&mut self, ui: &mut egui::Ui) { - let font_12 = self.scaled_font(12.0); - let font_14 = self.scaled_font(14.0); - egui::CollapsingHeader::new( - egui::RichText::new(format!("\u{1F310} {}", t!("settings.language"))) - .size(font_14) + egui::RichText::new(format!("{} {}", icon::GLOBE, t!("settings.language"))) + .heading() .strong(), ) .default_open(true) .show(ui, |ui| { ui.label( egui::RichText::new(t!("settings.language_desc")) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); ui.add_space(8.0); @@ -96,62 +96,191 @@ impl UltraLogApp { }); } + /// Ultra intensity: one slider, 0-100, with named stops (Stealth, Night Drive, Neon, + /// Ultra) that snap within a small detent. Locked while High contrast is on. + /// Spec: `design/design-system/components/IntensityControl/README.md`. Also shown in the + /// menu bar's theme menu (`render_menu_bar`). + pub(crate) fn render_intensity_control(&mut self, ui: &mut egui::Ui) { + let t = self.tokens; + let locked = self.high_contrast; + let mut value = self.ultra_intensity; + // Keep clear of the panel's floating scroll bar so the readout and "Ultra" stay visible. + let width = ui.available_width() - ui.spacing().scroll.bar_width - space::SPACE_1; + + ui.horizontal(|ui| { + ui.set_max_width(width); + ui.label( + egui::RichText::new(t!("settings.ultra_intensity")) + .text_style(egui::TextStyle::Body), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let readout = format!("{} \u{00B7} {}", theme::intensity_name(value), value); + ui.label( + egui::RichText::new(readout) + .text_style(egui::TextStyle::Body) + .family(egui::FontFamily::Name( + crate::ui::fonts::DISPLAY_BOLD_ITALIC.into(), + )) + .color(if locked { t.ink_disabled } else { t.cyan }), + ); + }); + }); + + let small = egui::TextStyle::Small.resolve(ui.style()); + let slider = ui + .add_enabled_ui(!locked, |ui| { + components::intensity_slider(ui, &mut value, width) + }) + .inner; + // Snap to a named stop only for pointer input: an arrow-key step of 1 is inside the + // detent and would snap straight back, so the keyboard could never leave a stop. + if slider.changed() && (slider.dragged() || slider.clicked()) { + value = theme::snap_intensity(value); + } + + // Stop labels under the track; a click jumps to the nearest stop. + let (rect, stops) = ui.allocate_exact_size( + egui::vec2(width, small.size + 4.0), + if locked { + egui::Sense::hover() + } else { + egui::Sense::click() + }, + ); + if stops.clicked() + && let Some(pos) = stops.interact_pointer_pos() + { + let x = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0) * 100.0; + if let Some((stop, _)) = theme::INTENSITY_STOPS.iter().min_by(|a, b| { + (f32::from(a.0) - x) + .abs() + .total_cmp(&(f32::from(b.0) - x).abs()) + }) { + value = *stop; + } + } + let painter = ui.painter_at(rect); + for (stop, name) in theme::INTENSITY_STOPS { + let x = rect.left() + rect.width() * f32::from(stop) / 100.0; + let anchor = match stop { + 0 => egui::Align2::LEFT_TOP, + 100 => egui::Align2::RIGHT_TOP, + _ => egui::Align2::CENTER_TOP, + }; + let color = if locked { + t.ink_disabled + } else if theme::intensity_name(value) == name { + t.ink + } else { + t.ink_subtle + }; + painter.text( + egui::pos2(x, rect.top()), + anchor, + name, + small.clone(), + color, + ); + } + + let caption = if locked { + t!("settings.ultra_intensity_locked") + } else { + t!("settings.ultra_intensity_desc") + }; + ui.label(egui::RichText::new(caption).small().color(t.ink_subtle)); + + if value != self.ultra_intensity { + self.ultra_intensity = value; + // One event per adjustment: at the end of a drag, or at once for a click/keypress. + if !slider.dragged() && !slider.drag_stopped() { + analytics::track_ultra_intensity_changed(value); + } + } + if slider.drag_stopped() { + analytics::track_ultra_intensity_changed(self.ultra_intensity); + } + } + /// Render display settings section fn render_display_settings(&mut self, ui: &mut egui::Ui) { - let font_12 = self.scaled_font(12.0); - let font_14 = self.scaled_font(14.0); - egui::CollapsingHeader::new( - egui::RichText::new(format!("\u{1F5A5} {}", t!("settings.display"))) - .size(font_14) + egui::RichText::new(format!("{} {}", icon::MONITOR, t!("settings.display"))) + .heading() .strong(), ) .default_open(true) .show(ui, |ui| { + self.render_intensity_control(ui); + + ui.add_space(8.0); + // Colorblind mode let old_color_blind_mode = self.color_blind_mode; - ui.checkbox( + components::checkbox( + ui, &mut self.color_blind_mode, - egui::RichText::new(t!("settings.color_blind_mode")).size(font_14), + t!("settings.color_blind_mode"), ); if self.color_blind_mode != old_color_blind_mode { analytics::track_colorblind_mode_toggled(self.color_blind_mode); } ui.label( egui::RichText::new(t!("settings.color_blind_desc")) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), + ); + + ui.add_space(8.0); + + // High contrast + let old_high_contrast = self.high_contrast; + components::checkbox(ui, &mut self.high_contrast, t!("settings.high_contrast")); + if self.high_contrast != old_high_contrast { + analytics::track_high_contrast_toggled(self.high_contrast); + } + ui.label( + egui::RichText::new(t!("settings.high_contrast_desc")) + .small() + .color(self.tokens.ink_subtle), ); ui.add_space(8.0); // Font size - ui.label(egui::RichText::new(t!("settings.font_size")).size(font_14)); - ui.horizontal(|ui| { - ui.selectable_value(&mut self.font_scale, FontScale::Small, "S"); - ui.selectable_value(&mut self.font_scale, FontScale::Medium, "M"); - ui.selectable_value(&mut self.font_scale, FontScale::Large, "L"); - ui.selectable_value(&mut self.font_scale, FontScale::ExtraLarge, "XL"); - }); + ui.label( + egui::RichText::new(t!("settings.font_size")).text_style(egui::TextStyle::Body), + ); + components::segmented( + ui, + &mut self.font_scale, + &[ + (FontScale::Small, "S"), + (FontScale::Medium, "M"), + (FontScale::Large, "L"), + (FontScale::ExtraLarge, "XL"), + ], + ); ui.add_space(8.0); // Cursor tracking - ui.checkbox( + components::checkbox( + ui, &mut self.cursor_tracking, - egui::RichText::new(t!("settings.cursor_tracking")).size(font_14), + t!("settings.cursor_tracking"), ); ui.label( egui::RichText::new(t!("settings.cursor_tracking_desc")) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); if self.cursor_tracking { ui.add_space(4.0); let mut window_resp = None; ui.horizontal(|ui| { - ui.label(egui::RichText::new(t!("settings.window")).size(font_12)); + ui.label(egui::RichText::new(t!("settings.window")).small()); window_resp = Some( ui.add( egui::Slider::new(&mut self.view_window_seconds, 5.0..=120.0) @@ -170,14 +299,11 @@ impl UltraLogApp { // Scroll to zoom let old_scroll_to_zoom = self.scroll_to_zoom; - ui.checkbox( - &mut self.scroll_to_zoom, - egui::RichText::new(t!("settings.scroll_to_zoom")).size(font_14), - ); + components::checkbox(ui, &mut self.scroll_to_zoom, t!("settings.scroll_to_zoom")); ui.label( egui::RichText::new(t!("settings.scroll_to_zoom_desc")) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); if self.scroll_to_zoom != old_scroll_to_zoom { self.user_settings.scroll_to_zoom = self.scroll_to_zoom; @@ -190,14 +316,11 @@ impl UltraLogApp { // Chart grid let old_show_grid = self.show_grid; - ui.checkbox( - &mut self.show_grid, - egui::RichText::new(t!("settings.show_grid")).size(font_14), - ); + components::checkbox(ui, &mut self.show_grid, t!("settings.show_grid")); ui.label( egui::RichText::new(t!("settings.show_grid_desc")) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); if self.show_grid != old_show_grid { self.user_settings.show_grid = self.show_grid; @@ -210,7 +333,7 @@ impl UltraLogApp { ui.add_space(4.0); let mut slider_response = None; ui.horizontal(|ui| { - ui.label(egui::RichText::new(t!("settings.grid_opacity")).size(font_12)); + ui.label(egui::RichText::new(t!("settings.grid_opacity")).small()); slider_response = Some(ui.add(egui::Slider::new(&mut self.grid_opacity, 0..=255))); }); @@ -231,24 +354,22 @@ impl UltraLogApp { /// Render field normalization settings fn render_normalization_settings(&mut self, ui: &mut egui::Ui) { - let font_12 = self.scaled_font(12.0); - let font_14 = self.scaled_font(14.0); - egui::CollapsingHeader::new( - egui::RichText::new(format!("\u{1F4DD} {}", t!("settings.field_names"))) - .size(font_14) + egui::RichText::new(format!("{} {}", icon::TEXT_AA, t!("settings.field_names"))) + .heading() .strong(), ) .default_open(true) .show(ui, |ui| { - ui.checkbox( + components::checkbox( + ui, &mut self.field_normalization, - egui::RichText::new(t!("settings.field_normalization")).size(font_14), + t!("settings.field_normalization"), ); ui.label( egui::RichText::new(t!("settings.field_normalization_desc")) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); ui.add_space(8.0); @@ -258,21 +379,21 @@ impl UltraLogApp { if custom_count > 0 { ui.label( egui::RichText::new(t!("settings.custom_mappings", count = custom_count)) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); } // Edit mappings button let btn = egui::Frame::NONE - .fill(egui::Color32::from_rgb(60, 60, 60)) + .fill(self.tokens.bg_raised) .corner_radius(4) .inner_margin(egui::vec2(12.0, 6.0)) .show(ui, |ui| { ui.label( egui::RichText::new(t!("settings.edit_custom_mappings")) - .color(egui::Color32::LIGHT_GRAY) - .size(font_14), + .color(self.tokens.ink_muted) + .text_style(egui::TextStyle::Body), ); }); @@ -288,20 +409,17 @@ impl UltraLogApp { /// Render unit preferences fn render_unit_settings(&mut self, ui: &mut egui::Ui) { - let font_12 = self.scaled_font(12.0); - let font_14 = self.scaled_font(14.0); - egui::CollapsingHeader::new( - egui::RichText::new(format!("\u{1F4D0} {}", t!("settings.units"))) - .size(font_14) + egui::RichText::new(format!("{} {}", icon::RULER, t!("settings.units"))) + .heading() .strong(), ) .default_open(true) .show(ui, |ui| { ui.label( egui::RichText::new(t!("settings.units_desc")) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); ui.add_space(8.0); @@ -311,7 +429,7 @@ impl UltraLogApp { .spacing([8.0, 6.0]) .show(ui, |ui| { // Temperature - ui.label(egui::RichText::new(t!("settings.temperature")).size(font_12)); + ui.label(egui::RichText::new(t!("settings.temperature")).small()); egui::ComboBox::from_id_salt("temp_unit") .selected_text(self.unit_preferences.temperature.symbol()) .width(80.0) @@ -335,7 +453,7 @@ impl UltraLogApp { ui.end_row(); // Pressure - ui.label(egui::RichText::new(t!("settings.pressure")).size(font_12)); + ui.label(egui::RichText::new(t!("settings.pressure")).small()); egui::ComboBox::from_id_salt("pressure_unit") .selected_text(self.unit_preferences.pressure.symbol()) .width(80.0) @@ -359,7 +477,7 @@ impl UltraLogApp { ui.end_row(); // Speed - ui.label(egui::RichText::new(t!("settings.speed")).size(font_12)); + ui.label(egui::RichText::new(t!("settings.speed")).small()); egui::ComboBox::from_id_salt("speed_unit") .selected_text(self.unit_preferences.speed.symbol()) .width(80.0) @@ -378,7 +496,7 @@ impl UltraLogApp { ui.end_row(); // Distance - ui.label(egui::RichText::new(t!("settings.distance")).size(font_12)); + ui.label(egui::RichText::new(t!("settings.distance")).small()); egui::ComboBox::from_id_salt("distance_unit") .selected_text(self.unit_preferences.distance.symbol()) .width(80.0) @@ -397,7 +515,7 @@ impl UltraLogApp { ui.end_row(); // Fuel Economy - ui.label(egui::RichText::new(t!("settings.fuel_economy")).size(font_12)); + ui.label(egui::RichText::new(t!("settings.fuel_economy")).small()); egui::ComboBox::from_id_salt("fuel_unit") .selected_text(self.unit_preferences.fuel_economy.symbol()) .width(80.0) @@ -421,7 +539,7 @@ impl UltraLogApp { ui.end_row(); // Volume - ui.label(egui::RichText::new(t!("settings.volume")).size(font_12)); + ui.label(egui::RichText::new(t!("settings.volume")).small()); egui::ComboBox::from_id_salt("volume_unit") .selected_text(self.unit_preferences.volume.symbol()) .width(80.0) @@ -440,7 +558,7 @@ impl UltraLogApp { ui.end_row(); // Flow Rate - ui.label(egui::RichText::new(t!("settings.flow_rate")).size(font_12)); + ui.label(egui::RichText::new(t!("settings.flow_rate")).small()); egui::ComboBox::from_id_salt("flow_unit") .selected_text(self.unit_preferences.flow.symbol()) .width(80.0) @@ -459,7 +577,7 @@ impl UltraLogApp { ui.end_row(); // Acceleration - ui.label(egui::RichText::new(t!("settings.acceleration")).size(font_12)); + ui.label(egui::RichText::new(t!("settings.acceleration")).small()); egui::ComboBox::from_id_salt("accel_unit") .selected_text(self.unit_preferences.acceleration.symbol()) .width(80.0) @@ -478,7 +596,7 @@ impl UltraLogApp { ui.end_row(); // AFR/Lambda - ui.label(egui::RichText::new(t!("settings.afr_lambda")).size(font_12)); + ui.label(egui::RichText::new(t!("settings.afr_lambda")).small()); egui::ComboBox::from_id_salt("afr_lambda_unit") .selected_text(self.unit_preferences.afr_lambda.symbol()) .width(80.0) @@ -501,25 +619,27 @@ impl UltraLogApp { /// Render update settings fn render_update_settings(&mut self, ui: &mut egui::Ui) { - let font_12 = self.scaled_font(12.0); - let font_14 = self.scaled_font(14.0); - egui::CollapsingHeader::new( - egui::RichText::new(format!("\u{1F504} {}", t!("settings.updates"))) - .size(font_14) - .strong(), + egui::RichText::new(format!( + "{} {}", + icon::ARROWS_CLOCKWISE, + t!("settings.updates") + )) + .heading() + .strong(), ) .default_open(true) .show(ui, |ui| { // Auto-check preference - ui.checkbox( + components::checkbox( + ui, &mut self.auto_check_updates, - egui::RichText::new(t!("settings.check_on_startup")).size(font_14), + t!("settings.check_on_startup"), ); ui.label( egui::RichText::new(t!("settings.auto_check_desc")) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); ui.add_space(8.0); @@ -528,7 +648,7 @@ impl UltraLogApp { let is_checking = matches!(self.update_state, UpdateState::Checking); ui.add_enabled_ui(!is_checking, |ui| { let btn = egui::Frame::NONE - .fill(egui::Color32::from_rgb(60, 60, 60)) + .fill(self.tokens.bg_raised) .corner_radius(4) .inner_margin(egui::vec2(12.0, 6.0)) .show(ui, |ui| { @@ -539,8 +659,8 @@ impl UltraLogApp { }; ui.label( egui::RichText::new(text) - .color(egui::Color32::LIGHT_GRAY) - .size(font_14), + .color(self.tokens.ink_muted) + .text_style(egui::TextStyle::Body), ); }); @@ -559,8 +679,8 @@ impl UltraLogApp { let version = env!("CARGO_PKG_VERSION"); ui.label( egui::RichText::new(t!("settings.current_version", version = version)) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); // Show update status @@ -570,8 +690,8 @@ impl UltraLogApp { ui.spinner(); ui.label( egui::RichText::new(t!("settings.checking")) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); }); } @@ -579,11 +699,12 @@ impl UltraLogApp { ui.add_space(4.0); ui.label( egui::RichText::new(format!( - "\u{2713} {}", + "{} {}", + crate::ui::components::Status::Success.icon(), t!("settings.update_available", version = &info.new_version) )) - .size(font_12) - .color(egui::Color32::from_rgb(150, 200, 150)), + .small() + .color(self.tokens.success), ); let view_btn = ui.add( @@ -592,8 +713,8 @@ impl UltraLogApp { "{} \u{2192}", t!("settings.view_details") )) - .size(font_12) - .color(egui::Color32::from_rgb(150, 180, 220)), + .small() + .color(self.tokens.cyan), ) .sense(egui::Sense::click()), ); @@ -608,9 +729,9 @@ impl UltraLogApp { } UpdateState::Error(msg) => { ui.label( - egui::RichText::new(format!("⚠ {}", msg)) - .size(font_12) - .color(egui::Color32::from_rgb(200, 150, 100)), + egui::RichText::new(format!("{} {}", icon::WARNING, msg)) + .small() + .color(self.tokens.warning), ); } _ => {} diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs deleted file mode 100644 index 75907d3e..00000000 --- a/src/ui/sidebar.rs +++ /dev/null @@ -1,311 +0,0 @@ -//! Sidebar UI rendering - files panel and view options. - -use eframe::egui; - -use crate::app::UltraLogApp; -use crate::state::{ActiveTool, LoadingState}; -use crate::ui::icons::draw_upload_icon; - -impl UltraLogApp { - /// Render the left sidebar with file list and view options - pub fn render_sidebar(&mut self, ui: &mut egui::Ui) { - ui.heading("Files"); - ui.separator(); - - // Show loading indicator - if let LoadingState::Loading(filename) = &self.loading_state { - ui.horizontal(|ui| { - ui.spinner(); - ui.label(format!("Loading {}...", filename)); - }); - ui.separator(); - } - - let is_loading = matches!(self.loading_state, LoadingState::Loading(_)); - - // File list (if any files loaded) - if !self.files.is_empty() { - let mut file_to_remove: Option = None; - let mut file_to_switch: Option = None; - - // Collect file info upfront to avoid borrow issues - let file_info: Vec<(String, bool, String, usize, usize)> = self - .files - .iter() - .enumerate() - .map(|(i, file)| { - ( - file.name.clone(), - self.selected_file == Some(i), - file.ecu_type.name().to_string(), - file.log.channels.len(), - file.log.data.len(), - ) - }) - .collect(); - - for (i, (file_name, is_selected, ecu_name, channel_count, data_count)) in - file_info.iter().enumerate() - { - ui.horizontal(|ui| { - let response = ui.selectable_label(*is_selected, file_name); - if response.clicked() { - file_to_switch = Some(i); - } - - // Delete button - if ui.small_button("x").clicked() { - file_to_remove = Some(i); - } - }); - - // Show ECU type and data info - ui.indent(format!("file_indent_{}", i), |ui| { - ui.label( - egui::RichText::new(format!( - "{} | {} channels | {} points", - ecu_name, channel_count, data_count - )) - .size(self.scaled_font(12.0)) - .color(egui::Color32::GRAY), - ); - }); - } - - // Handle deferred file switching - if let Some(index) = file_to_switch { - self.switch_to_file_tab(index); - } - - if let Some(index) = file_to_remove { - self.remove_file(index); - } - - ui.add_space(10.0); - ui.separator(); - ui.add_space(5.0); - - // Add more files button (styled to match drop zone button) - ui.add_enabled_ui(!is_loading, |ui| { - let primary_color = egui::Color32::from_rgb(113, 120, 78); // Olive green - - ui.vertical_centered(|ui| { - let button_response = egui::Frame::NONE - .fill(primary_color) - .corner_radius(6) - .inner_margin(egui::vec2(16.0, 8.0)) - .show(ui, |ui| { - ui.label( - egui::RichText::new("+ Add File") - .color(egui::Color32::WHITE) - .size(self.scaled_font(14.0)), - ); - }); - - if button_response - .response - .interact(egui::Sense::click()) - .clicked() - && let Some(path) = rfd::FileDialog::new() - .add_filter("Log Files", crate::state::SUPPORTED_EXTENSIONS) - .pick_file() - { - self.start_loading_file(path); - } - - if button_response.response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - }); - }); - } else if !is_loading { - // Nice drop zone when no files loaded - self.render_drop_zone(ui); - } - - // View Options section at bottom - self.render_view_options(ui); - } - - /// Render the drop zone for when no files are loaded - fn render_drop_zone(&mut self, ui: &mut egui::Ui) { - let primary_color = egui::Color32::from_rgb(113, 120, 78); // Olive green - let card_bg = egui::Color32::from_rgb(45, 45, 45); // Dark card for dark theme - let text_gray = egui::Color32::from_rgb(150, 150, 150); - - ui.add_space(20.0); - - // Drop zone card - egui::Frame::NONE - .fill(card_bg) - .corner_radius(12) - .inner_margin(20.0) - .show(ui, |ui| { - ui.vertical_centered(|ui| { - // Upload icon - let icon_size = 32.0; - let (icon_rect, _) = ui.allocate_exact_size( - egui::vec2(icon_size, icon_size), - egui::Sense::hover(), - ); - draw_upload_icon(ui, icon_rect.center(), icon_size, primary_color); - - ui.add_space(12.0); - - // Select file button - let button_response = egui::Frame::NONE - .fill(primary_color) - .corner_radius(6) - .inner_margin(egui::vec2(16.0, 8.0)) - .show(ui, |ui| { - ui.label( - egui::RichText::new("Select a file") - .color(egui::Color32::WHITE) - .size(self.scaled_font(14.0)), - ); - }); - - if button_response - .response - .interact(egui::Sense::click()) - .clicked() - && let Some(path) = rfd::FileDialog::new() - .add_filter("Log Files", crate::state::SUPPORTED_EXTENSIONS) - .pick_file() - { - self.start_loading_file(path); - } - - if button_response.response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - - ui.add_space(12.0); - - ui.label( - egui::RichText::new("or") - .color(text_gray) - .size(self.scaled_font(12.0)), - ); - - ui.add_space(8.0); - - ui.label( - egui::RichText::new("Drop file here") - .color(egui::Color32::LIGHT_GRAY) - .size(self.scaled_font(13.0)), - ); - - ui.add_space(12.0); - - ui.label( - egui::RichText::new("CSV • LOG • TXT • MLG • LLG • XRK • DRK") - .color(text_gray) - .size(self.scaled_font(11.0)), - ); - }); - }); - } - - /// Render view options at the bottom of the sidebar - fn render_view_options(&mut self, ui: &mut egui::Ui) { - // Pre-compute scaled font sizes - let font_12 = self.scaled_font(12.0); - let font_14 = self.scaled_font(14.0); - let font_18 = self.scaled_font(18.0); - - ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| { - // Reverse order since we're bottom-up - ui.add_space(10.0); - - // Show histogram stats when in Histogram mode - if !self.files.is_empty() && self.active_tool == ActiveTool::Histogram { - self.render_histogram_stats(ui); - - ui.add_space(5.0); - ui.separator(); - } - - // Only show options when we have data to view and are in Log Viewer mode - if !self.files.is_empty() - && !self.get_selected_channels().is_empty() - && self.active_tool == ActiveTool::LogViewer - { - egui::Frame::NONE - .fill(egui::Color32::from_rgb(35, 35, 35)) - .corner_radius(8) - .inner_margin(10.0) - .show(ui, |ui| { - // Cursor tracking checkbox - ui.checkbox( - &mut self.cursor_tracking, - egui::RichText::new("🎯 Cursor Tracking").size(font_14), - ); - ui.label( - egui::RichText::new("Keep cursor centered while scrubbing") - .color(egui::Color32::GRAY) - .size(font_12), - ); - - // Window size slider (only show when cursor tracking is enabled) - if self.cursor_tracking { - ui.add_space(8.0); - ui.label(egui::RichText::new("View Window:").size(font_14)); - let resp = ui.add( - egui::Slider::new(&mut self.view_window_seconds, 5.0..=120.0) - .suffix("s") - .logarithmic(true), - ); - if resp.changed() { - self.current_view_window = self.view_window_seconds; - } - } - - ui.add_space(8.0); - ui.separator(); - ui.add_space(4.0); - - // Color blind mode checkbox - ui.checkbox( - &mut self.color_blind_mode, - egui::RichText::new("👁 Color Blind Mode").size(font_14), - ); - ui.label( - egui::RichText::new("Use accessible color palette") - .color(egui::Color32::GRAY) - .size(font_12), - ); - - ui.add_space(8.0); - ui.separator(); - ui.add_space(4.0); - - // Field normalization checkbox with right-aligned Edit button - ui.horizontal(|ui| { - ui.checkbox( - &mut self.field_normalization, - egui::RichText::new("📝 Field Normalization").size(font_14), - ); - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - if ui.small_button("Edit").clicked() { - self.show_normalization_editor = true; - } - }, - ); - }); - ui.label( - egui::RichText::new("Standardize channel names across ECU types") - .color(egui::Color32::GRAY) - .size(font_12), - ); - }); - - ui.add_space(5.0); - ui.separator(); - ui.label(egui::RichText::new("View Options").heading().size(font_18)); - } - }); - } -} diff --git a/src/ui/tab_bar.rs b/src/ui/tab_bar.rs index 95f5bcf8..58e5728f 100644 --- a/src/ui/tab_bar.rs +++ b/src/ui/tab_bar.rs @@ -1,8 +1,25 @@ -//! Tab bar UI for managing multiple log file views. +//! FileTabs: one tab per loaded log (`design/design-system/components/FileTabs/README.md`). +//! +//! Each tab shows the ECU source as a mono `ecu` badge, the file name truncated in the middle +//! so the extension stays visible, and a close button. Active: `bg_raised`, `ink`, +//! `line_control` border and a 2px `cyan` inset on the top edge. Inactive: `bg_panel`, +//! `ink_muted`. The full file name is in the tooltip. -use eframe::egui; +use eframe::egui::{ + self, Align2, CornerRadius, FontId, Rect, Sense, Stroke, StrokeKind, pos2, vec2, +}; use crate::app::UltraLogApp; +use crate::ui::components::icon; +use crate::ui::fonts::{self, style}; +use crate::ui::theme::tokens::{radius, space, stroke}; + +/// Tab height (px) +const TAB_HEIGHT: f32 = 30.0; +/// Widest a file name may draw before it is truncated in the middle (px) +const MAX_NAME_WIDTH: f32 = 200.0; +/// Close button hit area (px) +const CLOSE_SIZE: f32 = 18.0; impl UltraLogApp { /// Render the tab bar for switching between log files @@ -10,100 +27,159 @@ impl UltraLogApp { if self.tabs.is_empty() { return; } + let t = self.tokens; let mut tab_to_activate: Option = None; let mut tab_to_close: Option = None; // Collect tab info to avoid borrow issues - let tab_info: Vec<(String, bool)> = self + let tab_info: Vec<(String, &'static str, bool)> = self .tabs .iter() .enumerate() - .map(|(i, tab)| (tab.name.clone(), self.active_tab == Some(i))) + .map(|(i, tab)| { + let code = self + .files + .get(tab.file_index) + .map(|f| f.ecu_type.short_code()) + .unwrap_or("LOG"); + (tab.name.clone(), code, self.active_tab == Some(i)) + }) .collect(); - ui.horizontal(|ui| { - for (i, (name, is_active)) in tab_info.iter().enumerate() { - let tab_color = if *is_active { - egui::Color32::from_rgb(60, 60, 60) - } else { - egui::Color32::from_rgb(40, 40, 40) - }; - - let text_color = if *is_active { - egui::Color32::WHITE - } else { - egui::Color32::from_rgb(180, 180, 180) - }; - - let border_color = if *is_active { - egui::Color32::from_rgb(113, 120, 78) // Primary olive green - } else { - egui::Color32::from_rgb(60, 60, 60) - }; - - egui::Frame::NONE - .fill(tab_color) - .corner_radius(egui::CornerRadius { - nw: 6, - ne: 6, - sw: 0, - se: 0, - }) - .stroke(egui::Stroke::new( - if *is_active { 2.0 } else { 1.0 }, - border_color, - )) - .inner_margin(egui::Margin { - left: 12, - right: 8, - top: 6, - bottom: 6, - }) - .show(ui, |ui| { - ui.horizontal(|ui| { - // Tab name (clickable) - let font_13 = self.scaled_font(13.0); - let label_response = ui.add( - egui::Label::new( - egui::RichText::new(name).color(text_color).size(font_13), - ) - .sense(egui::Sense::click()), + let name_font = egui::TextStyle::Button.resolve(ui.style()); + let badge_font = fonts::text_style(style::DATA_SM).resolve(ui.style()); + + // A horizontal scroll area caps the row at the panel width. A bare + // `horizontal` grew with the tabs and pushed the chart and data panel + // past the window's right edge once five or six logs were open. + egui::ScrollArea::horizontal() + .id_salt("tab_bar_scroll") + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = space::SPACE_1; + for (i, (name, code, is_active)) in tab_info.iter().enumerate() { + let shown = truncate_middle(ui, name, &name_font, MAX_NAME_WIDTH); + let name_galley = ui.painter().layout_no_wrap( + shown, + name_font.clone(), + if *is_active { t.ink } else { t.ink_muted }, + ); + let badge_galley = ui.painter().layout_no_wrap( + (*code).to_owned(), + badge_font.clone(), + t.ink_subtle, + ); + let badge_size = badge_galley.size() + vec2(8.0, 4.0); + let width = space::SPACE_3 + + badge_size.x + + space::SPACE_2 + + name_galley.size().x + + space::SPACE_2 + + CLOSE_SIZE + + space::SPACE_1; + + let (rect, response) = + ui.allocate_exact_size(vec2(width, TAB_HEIGHT), Sense::click()); + let painter = ui.painter(); + let corners = CornerRadius { + nw: radius::RADIUS_SM, + ne: radius::RADIUS_SM, + sw: 0, + se: 0, + }; + let (fill, border) = if *is_active { + (t.bg_raised, t.line_control) + } else if response.hovered() { + (t.bg_hover, t.line) + } else { + (t.bg_panel, t.line) + }; + painter.rect( + rect, + corners, + fill, + Stroke::new(stroke::STROKE_HAIRLINE, border), + StrokeKind::Inside, + ); + if *is_active { + let inset = Rect::from_min_size( + rect.left_top() + vec2(1.0, 1.0), + vec2(rect.width() - 2.0, stroke::STROKE_INDICATOR), ); - - if label_response.clicked() { - tab_to_activate = Some(i); - } - if label_response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - - ui.add_space(4.0); - - // Close button - let font_14 = self.scaled_font(14.0); - let close_btn = ui.add( - egui::Label::new( - egui::RichText::new("x") - .color(egui::Color32::from_rgb(150, 150, 150)) - .size(font_14), - ) - .sense(egui::Sense::click()), + painter.rect_filled(inset, corners, t.cyan); + } + + // ECU badge: mono, outlined + let badge = Rect::from_min_size( + pos2( + rect.left() + space::SPACE_3, + rect.center().y - badge_size.y / 2.0, + ), + badge_size, + ); + painter.rect_stroke( + badge, + CornerRadius::same(radius::RADIUS_XS), + Stroke::new(stroke::STROKE_HAIRLINE, t.line_control), + StrokeKind::Inside, + ); + painter.galley( + badge.center() - badge_galley.size() / 2.0, + badge_galley, + t.ink_subtle, + ); + + let name_pos = pos2( + badge.right() + space::SPACE_2, + rect.center().y - name_galley.size().y / 2.0, + ); + painter.galley(name_pos, name_galley, t.ink); + + // Close button + let close = Rect::from_center_size( + pos2( + rect.right() - space::SPACE_1 - CLOSE_SIZE / 2.0, + rect.center().y, + ), + vec2(CLOSE_SIZE, CLOSE_SIZE), + ); + let close_response = ui + .interact(close, ui.id().with(("tab_close", i)), Sense::click()) + .on_hover_text("Close"); + let painter = ui.painter(); + if close_response.hovered() { + painter.rect_filled( + close, + CornerRadius::same(radius::RADIUS_XS), + t.bg_hover, ); - - if close_btn.clicked() { - tab_to_close = Some(i); - } - - if close_btn.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - }); - }); - - ui.add_space(2.0); - } - }); + } + painter.text( + close.center(), + Align2::CENTER_CENTER, + icon::X, + FontId::proportional(12.0), + if close_response.hovered() { + t.ink + } else { + t.ink_subtle + }, + ); + + if close_response.clicked() { + tab_to_close = Some(i); + } else if response.clicked() { + tab_to_activate = Some(i); + } + if response.hovered() || close_response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + response.on_hover_text(name.as_str()); + } + }); + }); // Handle deferred tab activation if let Some(index) = tab_to_activate { @@ -116,15 +192,45 @@ impl UltraLogApp { self.close_tab(index); } - // Separator line under tabs - ui.add_space(2.0); + // Baseline under the tabs let rect = ui.available_rect_before_wrap(); ui.painter().line_segment( [ egui::pos2(rect.left(), rect.top()), egui::pos2(rect.right(), rect.top()), ], - egui::Stroke::new(1.0, egui::Color32::from_rgb(60, 60, 60)), + Stroke::new(stroke::STROKE_HAIRLINE, t.line), ); } } + +/// `name` shortened in the middle with "…" until it fits `max_width`, so the start and the +/// extension both stay visible ("2025-03-06_09…Logs.csv"). +fn truncate_middle(ui: &egui::Ui, name: &str, font: &FontId, max_width: f32) -> String { + let width = |s: &str| { + ui.painter() + .layout_no_wrap(s.to_owned(), font.clone(), ui.visuals().text_color()) + .size() + .x + }; + if width(name) <= max_width { + return name.to_owned(); + } + let chars: Vec = name.chars().collect(); + // Keep the extension (and a few characters before it) whole. + let tail_len = name + .rfind('.') + .map(|dot| name[dot..].chars().count() + 4) + .unwrap_or(6) + .min(chars.len() / 2); + let tail: String = chars[chars.len() - tail_len..].iter().collect(); + let mut head_len = chars.len() - tail_len; + while head_len > 1 { + head_len -= 1; + let candidate: String = chars[..head_len].iter().collect::() + "…" + &tail; + if width(&candidate) <= max_width { + return candidate; + } + } + format!("…{tail}") +} diff --git a/src/ui/table_export.rs b/src/ui/table_export.rs index f72f277c..5c30e08d 100644 --- a/src/ui/table_export.rs +++ b/src/ui/table_export.rs @@ -1,49 +1,17 @@ -//! PNG and PDF export for generated tuning tables (lambda delay, accel -//! enrichment). +//! ExportSheet models for generated tuning tables (lambda delay, accel enrichment). //! -//! The renderers are free functions over a [`TableAccumulator`] so they are -//! unit-testable without an `UltraLogApp`. Cell values, blanking of -//! low-confidence cells, and delay-unit conversion all go through -//! `analysis::tables::export::cell_value`, the same path CSV export uses, so -//! the four exports never disagree. -//! -//! PDF draws values and axis labels with Helvetica. PNG follows the histogram -//! precedent: this crate has no font rasterizer, so PNG is cells and grid -//! lines only. CSV and clipboard remain the numeric exports. - -use std::path::Path; - -use ::image::{Rgba, RgbaImage}; -use printpdf::*; -use rust_i18n::t; +//! [`table_sheet`] is a free function over a [`TableAccumulator`] so it is unit-testable without +//! an `UltraLogApp`. Cell values, blanking of low-confidence cells, and delay-unit conversion all +//! go through `analysis::tables::export::cell_value`, the same path CSV export uses, so PNG, PDF, +//! CSV and clipboard never disagree. The sheet itself (layout, export theme, fonts) is drawn by +//! `ui::export_sheet`. use crate::analysis::tables::binning::Confidence; use crate::analysis::tables::export::{ExportOptions, cell_value, fmt_edge}; use crate::analysis::tables::{GeneratorKind, TableAccumulator}; -use crate::analytics; -use crate::app::UltraLogApp; -use crate::colormap::{Colormap, sample}; -use crate::ui::export::{draw_line, push_closed_line, push_filled_rect, push_text}; - -/// Colour for a cell's normalized value. -fn cell_rgb(t: f64) -> [u8; 3] { - let c = sample(Colormap::Viridis, t as f32); - [c.r(), c.g(), c.b()] -} - -/// Black or white text for a Viridis background (WCAG relative luminance). -fn text_on(rgb: [u8; 3]) -> bool { - let lin = |c: u8| { - let v = c as f64 / 255.0; - if v <= 0.03928 { - v / 12.92 - } else { - ((v + 0.055) / 1.055).powf(2.4) - } - }; - let lum = 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]); - lum > 0.4 -} +use crate::ui::export::{ExportResult, group_thousands}; +use crate::ui::export_sheet::{self, Body, GridModel, Sheet}; +use crate::ui::heatmap::MapKind; /// `(value, decimals)` per cell after export options; `None` renders blank. type ResolvedCells = Vec>>; @@ -54,7 +22,7 @@ fn resolve_cells( acc: &TableAccumulator, measure_idx: usize, opts: &ExportOptions, -) -> Result<(ResolvedCells, f64, f64), Box> { +) -> ExportResult<(ResolvedCells, f64, f64)> { let measure = acc .measures .get(measure_idx) @@ -95,369 +63,131 @@ fn normalize(v: f64, lo: f64, hi: f64) -> f64 { } } -/// Write the table as a 1920x1080 PNG: Viridis cells, grid lines, no text. -pub fn render_table_png( - acc: &TableAccumulator, - measure_idx: usize, - opts: &ExportOptions, - path: &Path, -) -> Result<(), Box> { - let (cells, lo, hi) = resolve_cells(acc, measure_idx, opts)?; - let grid = acc.grid(measure_idx); - let (rows, cols) = (grid.rows(), grid.cols()); - - let width = 1920u32; - let height = 1080u32; - let margin = 80u32; - let left = margin; - let right = width - margin; - let top = margin; - let bottom = height - margin; - let cell_w = (right - left) as f64 / cols as f64; - let cell_h = (bottom - top) as f64 / rows as f64; - - let mut img = RgbaImage::new(width, height); - for px in img.pixels_mut() { - *px = Rgba([30, 30, 30, 255]); - } - - for (r, row) in cells.iter().enumerate() { - // Highest Y bin at the top, the way ECU tables are laid out. - let draw_row = rows - 1 - r; - let y0 = top as f64 + draw_row as f64 * cell_h; - for (c, cell) in row.iter().enumerate() { - let x0 = left as f64 + c as f64 * cell_w; - let rgb = match cell { - Some((v, _)) => cell_rgb(normalize(*v, lo, hi)), - None => [36, 36, 36], - }; - let px = Rgba([rgb[0], rgb[1], rgb[2], 255]); - let x_end = (x0 + cell_w).min(right as f64) as u32; - let y_end = (y0 + cell_h).min(bottom as f64) as u32; - for y in y0 as u32..y_end { - for x in x0 as u32..x_end { - img.put_pixel(x, y, px); - } - } - } - } - - let line = Rgba([60, 60, 60, 255]); - for c in 0..=cols { - let x = (left as f64 + c as f64 * cell_w).min(right as f64) as u32; - draw_line(&mut img, x, top, x, bottom, line); - } - for r in 0..=rows { - let y = (top as f64 + r as f64 * cell_h).min(bottom as f64) as u32; - draw_line(&mut img, left, y, right, y, line); - } - let border = Rgba([120, 120, 120, 255]); - draw_line(&mut img, left, top, right, top, border); - draw_line(&mut img, left, bottom, right, bottom, border); - draw_line(&mut img, left, top, left, bottom, border); - draw_line(&mut img, right, top, right, bottom, border); - - img.save(path)?; - Ok(()) +/// Edge labels at their positions along the axis (edges sit on the cell boundaries). +fn edge_ticks(edges: &[f64]) -> Vec<(f32, String)> { + let n = edges.len().saturating_sub(1).max(1) as f32; + edges + .iter() + .enumerate() + .map(|(i, &e)| (i as f32 / n, fmt_edge(e))) + .collect() } -/// Write the table as an A4-landscape PDF with values and axis labels. -pub fn render_table_pdf( +/// The ExportSheet for a table: values, count badges by confidence, edge labels and a +/// ColorScale for the measure. +pub fn table_sheet( acc: &TableAccumulator, measure_idx: usize, opts: &ExportOptions, title: &str, - generated: &str, - path: &Path, -) -> Result<(), Box> { +) -> ExportResult { let (cells, lo, hi) = resolve_cells(acc, measure_idx, opts)?; let grid = acc.grid(measure_idx); let (rows, cols) = (grid.rows(), grid.cols()); let measure = &acc.measures[measure_idx]; - - let font_bold = PdfFontHandle::Builtin(BuiltinFont::HelveticaBold); - let font_regular = PdfFontHandle::Builtin(BuiltinFont::Helvetica); - let mut ops: Vec = Vec::new(); - - // A4 landscape, mm. - let margin: f64 = 15.0; - let label_w: f64 = 22.0; - let label_h: f64 = 8.0; - let chart_left = margin + label_w; - let chart_right: f64 = 297.0 - margin; - let chart_top: f64 = 210.0 - margin - 26.0; - let chart_bottom = margin + label_h + 6.0; - let cell_w = (chart_right - chart_left) / cols as f64; - let cell_h = (chart_top - chart_bottom) / rows as f64; - - let black = Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)); - let white = Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)); - - ops.push(Op::SetFillColor { col: black.clone() }); - push_text( - &mut ops, - title, - 16.0, - Mm(margin as f32), - Mm(200.0), - &font_bold, - ); let unit = if acc.generator == GeneratorKind::LambdaDelay && measure.unit == "ms" { opts.delay_unit.label().to_string() } else { measure.unit.to_string() }; - let logs: Vec<&str> = acc.logs.iter().map(|l| l.name.as_str()).collect(); - let subtitle = format!( - "{} ({}) | {} | Logs: {}", - measure.label, - unit, - generated, - logs.join(", ") - ); - push_text( - &mut ops, - &subtitle, - 9.0, - Mm(margin as f32), - Mm(193.0), - &font_regular, - ); - let (filled, high) = grid.coverage(); - let coverage = format!( - "Rows: {} | Columns: {} | {}/{} cells filled, {} high confidence{}", - grid.y_axis.header(), - grid.x_axis.header(), - filled, - rows * cols, - high, - if opts.exclude_low { - " | low-confidence cells blank" - } else { - "" - } - ); - push_text( - &mut ops, - &coverage, - 8.0, - Mm(margin as f32), - Mm(188.0), - &font_regular, - ); - - // Cells. - let value_size = (cell_h as f32 * 1.6).clamp(5.0, 11.0); - let count_size = (value_size * 0.6).max(4.0); - for (r, row) in cells.iter().enumerate() { - let y = chart_bottom + r as f64 * cell_h; - for (c, cell) in row.iter().enumerate() { - let x = chart_left + c as f64 * cell_w; - let Some((v, decimals)) = cell else { - ops.push(Op::SetFillColor { - col: Color::Rgb(Rgb::new(0.93, 0.93, 0.93, None)), - }); - push_filled_rect(&mut ops, x as f32, y as f32, cell_w as f32, cell_h as f32); - continue; - }; - let rgb = cell_rgb(normalize(*v, lo, hi)); - ops.push(Op::SetFillColor { - col: Color::Rgb(Rgb::new( - rgb[0] as f32 / 255.0, - rgb[1] as f32 / 255.0, - rgb[2] as f32 / 255.0, - None, - )), - }); - push_filled_rect(&mut ops, x as f32, y as f32, cell_w as f32, cell_h as f32); - let fg = if text_on(rgb) { - black.clone() - } else { - white.clone() - }; - ops.push(Op::SetFillColor { col: fg }); - let text = format!("{v:.decimals$}"); - // Helvetica averages ~0.5em per glyph; centre approximately. - let text_w_mm = text.len() as f64 * value_size as f64 * 0.5 * 0.3528; - push_text( - &mut ops, - &text, - value_size, - Mm((x + (cell_w - text_w_mm) / 2.0).max(x + 0.5) as f32), - Mm((y + cell_h / 2.0 - value_size as f64 * 0.12) as f32), - &font_regular, - ); - if let Some(stats) = grid.cell(r, c) { - // Built-in Helvetica maps `~` to an arrow glyph, so the - // markers stay in plain ASCII: `*` medium, `?` low. - let mark = match stats.confidence { - Confidence::High | Confidence::Empty => "", - Confidence::Medium => "*", - Confidence::Low => "?", - }; - push_text( - &mut ops, - &format!("{}{}", stats.count, mark), - count_size, - Mm((x + 0.6) as f32), - Mm((y + cell_h - count_size as f64 * 0.42) as f32), - &font_regular, - ); - } - } - } - - // Grid lines. - ops.push(Op::SetOutlineColor { - col: Color::Rgb(Rgb::new(0.5, 0.5, 0.5, None)), - }); - ops.push(Op::SetOutlineThickness { pt: Pt(0.25) }); - for c in 0..=cols { - let x = chart_left + c as f64 * cell_w; - push_closed_line( - &mut ops, - &[ - (x as f32, chart_bottom as f32), - (x as f32, chart_top as f32), - ], - ); - } - for r in 0..=rows { - let y = chart_bottom + r as f64 * cell_h; - push_closed_line( - &mut ops, - &[ - (chart_left as f32, y as f32), - (chart_right as f32, y as f32), - ], - ); - } + // Same map choice as the on-screen table (`render_table_tool_view`): values on both sides of + // zero use the diverging map centred on zero, magnitudes the sequential map. + let kind = if lo < 0.0 && hi > 0.0 { + MapKind::Diverging + } else { + MapKind::Sequential + }; + let span = lo.abs().max(hi.abs()).max(f64::EPSILON); + let norm = cells + .iter() + .map(|row| { + row.iter() + .map(|v| { + v.map(|(v, _)| match kind { + MapKind::Diverging => (0.5 + v / (2.0 * span)).clamp(0.0, 1.0) as f32, + MapKind::Sequential => normalize(v, lo, hi) as f32, + }) + }) + .collect() + }) + .collect(); + let labels = cells + .iter() + .map(|row| { + row.iter() + .map(|v| v.map(|(v, d)| format!("{v:.d$}"))) + .collect() + }) + .collect(); + let counts = (0..rows) + .map(|r| { + (0..cols) + .map(|c| { + let stats = grid.cell(r, c)?; + let conf = match stats.confidence { + Confidence::High => export_sheet::Confidence::High, + Confidence::Medium => export_sheet::Confidence::Medium, + Confidence::Low => export_sheet::Confidence::Low, + Confidence::Empty => return None, + }; + Some((stats.count, conf)) + }) + .collect() + }) + .collect(); - // Axis labels: lower edges, X along the bottom, Y down the left. - ops.push(Op::SetFillColor { col: black }); - let axis_size = (cell_w as f32 * 0.9).clamp(5.0, 8.0); - for c in 0..cols { - let x = chart_left + c as f64 * cell_w; - push_text( - &mut ops, - &fmt_edge(grid.x_axis.lower_edge(c)), - axis_size, - Mm((x + 0.6) as f32), - Mm((chart_bottom - 4.0) as f32), - &font_regular, - ); - } - for r in 0..rows { - let y = chart_bottom + r as f64 * cell_h; - push_text( - &mut ops, - &fmt_edge(grid.y_axis.lower_edge(r)), - axis_size, - Mm(margin as f32), - Mm((y + cell_h / 2.0 - 1.0) as f32), - &font_regular, - ); - } - push_text( - &mut ops, - &grid.x_axis.header(), - 8.0, - Mm(chart_left as f32), - Mm((chart_bottom - 10.0) as f32), - &font_bold, - ); - push_text( - &mut ops, - &grid.y_axis.header(), - 8.0, - Mm(margin as f32), - Mm((chart_top + 2.0) as f32), - &font_bold, + let (filled, high) = grid.coverage(); + let logs: Vec<&str> = acc.logs.iter().map(|l| l.name.as_str()).collect(); + let mut coverage = format!( + "{}/{} cells \u{00B7} {} high confidence", + group_thousands(filled), + group_thousands(rows * cols), + high ); - - let page = PdfPage::new(Mm(297.0), Mm(210.0), ops); - let mut doc = PdfDocument::new(title); - doc.with_pages(vec![page]); - let mut warnings = Vec::new(); - let bytes = doc.save(&PdfSaveOptions::default(), &mut warnings); - std::fs::write(path, bytes)?; - Ok(()) -} - -impl UltraLogApp { - /// Export the active table tool's table as PNG. - pub fn export_table_png(&mut self) { - let Some(kind) = self.active_tool.generator_kind() else { - return; - }; - if self.table_generator.table(kind).is_none() { - self.show_toast_error(&t!("table_gen.no_events")); - return; - } - let Some(path) = rfd::FileDialog::new() - .add_filter("PNG Image", &["png"]) - .set_file_name(format!("ultralog_{}.png", kind.id())) - .save_file() - else { - return; - }; - let result = { - let Some(acc) = self.table_generator.table(kind) else { - return; - }; - let measure_idx = self.table_generator.measure_index(kind); - render_table_png(acc, measure_idx, &self.table_generator.export, &path) - }; - match result { - Ok(()) => { - analytics::track_export(&format!("{}_png", kind.id())); - self.show_toast_success(&t!("toast.table_exported_png")); - } - Err(e) => self.show_toast_error(&t!("toast.export_failed", error = e.to_string())), - } - } - - /// Export the active table tool's table as PDF. - pub fn export_table_pdf(&mut self) { - let Some(kind) = self.active_tool.generator_kind() else { - return; - }; - if self.table_generator.table(kind).is_none() { - self.show_toast_error(&t!("table_gen.no_events")); - return; - } - let Some(path) = rfd::FileDialog::new() - .add_filter("PDF Document", &["pdf"]) - .set_file_name(format!("ultralog_{}.pdf", kind.id())) - .save_file() - else { - return; - }; - let result = { - let Some(acc) = self.table_generator.table(kind) else { - return; - }; - let measure_idx = self.table_generator.measure_index(kind); - let title = format!("UltraLog {}", self.active_tool.name()); - let generated = chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(); - render_table_pdf( - acc, - measure_idx, - &self.table_generator.export, - &title, - &generated, - &path, - ) - }; - match result { - Ok(()) => { - analytics::track_export(&format!("{}_pdf", kind.id())); - self.show_toast_success(&t!("toast.table_exported_pdf")); - } - Err(e) => self.show_toast_error(&t!("toast.export_failed", error = e.to_string())), - } + if opts.exclude_low { + coverage.push_str(" \u{00B7} low blank"); } + // The scale uses the cells' decimals, which follow the delay unit (cycles have 2). + let decimals = cells + .iter() + .flatten() + .flatten() + .map(|&(_, d)| d) + .next() + .unwrap_or(measure.decimals); + let (scale_min, scale_max) = match kind { + MapKind::Diverging => ( + format!("\u{2212}{span:.decimals$}"), + format!("+{span:.decimals$}"), + ), + MapKind::Sequential => (format!("{lo:.decimals$}"), format!("{hi:.decimals$}")), + }; + let scale_title = if unit.is_empty() { + measure.label.to_string() + } else { + format!("{} ({unit})", measure.label) + }; + Ok(Sheet { + title: title.to_string(), + meta: vec![logs.join(", "), coverage], + body: Body::Grids(vec![GridModel { + caption: None, + x_title: grid.x_axis.header(), + y_title: grid.y_axis.header(), + x_ticks: edge_ticks(&grid.x_axis.edges), + y_ticks: edge_ticks(&grid.y_axis.edges), + norm, + labels, + counts: Some(counts), + kind, + scale_title, + scale_min, + scale_max, + rich_lean: measure.lean_is_positive(), + dense: false, + }]), + }) } #[cfg(test)] @@ -465,6 +195,8 @@ mod tests { use super::*; use crate::analysis::tables::export::DelayUnit; use crate::analysis::tables::{AxisSpec, MeasureSpec, RunReport, TableEvent}; + use crate::ui::heatmap; + use crate::ui::theme::{ExportTheme, export_tokens}; fn synthetic_accumulator() -> TableAccumulator { let x = AxisSpec::new("RPM", "rpm", vec![1000.0, 2000.0, 3000.0, 4000.0]); @@ -499,45 +231,74 @@ mod tests { acc } + /// `EXPORT_SHEET_DUMP= cargo test --lib dump_table_sheet -- --ignored` writes a sample + /// table sheet for a visual check. #[test] - fn png_writes_a_valid_image() { - let acc = synthetic_accumulator(); - let dir = std::env::temp_dir().join(format!("ultralog_table_{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("table.png"); - render_table_png(&acc, 0, &ExportOptions::default(), &path).unwrap(); - let img = ::image::open(&path).unwrap(); - assert_eq!(img.width(), 1920); - assert_eq!(img.height(), 1080); - std::fs::remove_dir_all(&dir).ok(); + #[ignore] + fn dump_table_sheet() { + let dir = std::env::var("EXPORT_SHEET_DUMP").expect("EXPORT_SHEET_DUMP"); + let rpm: Vec = (0..=10).map(|i| 1000.0 + i as f64 * 500.0).collect(); + let map: Vec = (0..=6).map(|i| 20.0 + i as f64 * 15.0).collect(); + let x = AxisSpec::new("RPM", "rpm", rpm); + let y = AxisSpec::new("MAP", "kPa", map); + let measures = vec![MeasureSpec { + key: "dead_time", + label: "Dead time", + unit: "ms", + decimals: 0, + }]; + let mut acc = TableAccumulator::new(GeneratorKind::LambdaDelay, (x, y), measures); + let mut events = Vec::new(); + for c in 0..10 { + for r in 0..6 { + // Fewer events at high load and high rpm; a corner stays empty. + let n = (12usize).saturating_sub(c + 2 * r); + for k in 0..n { + events.push(TableEvent { + log_id: 1, + log_name: "pull.csv".into(), + time: (c * 100 + r * 10 + k) as f64, + rpm: 1250.0 + c as f64 * 500.0, + axis_value: 27.0 + r as f64 * 15.0, + values: vec![80.0 + c as f64 * 12.0 - r as f64 * 6.0 + k as f64], + quality: 1.0, + reject: None, + note: String::new(), + }); + } + } + } + acc.add_log(1, "pull.csv", events, RunReport::default()); + let sheet = table_sheet(&acc, 0, &ExportOptions::default(), "Lambda Delay").unwrap(); + for theme in [ExportTheme::Paper, ExportTheme::Graphite] { + let name = format!("{theme:?}").to_lowercase(); + export_sheet::render_png(&sheet, theme) + .save(format!("{dir}/table-{name}.png")) + .unwrap(); + std::fs::write( + format!("{dir}/table-{name}.pdf"), + export_sheet::render_pdf(&sheet, theme), + ) + .unwrap(); + } } #[test] - fn pdf_writes_a_document_with_values() { + fn sheet_renders_as_png_and_pdf() { let acc = synthetic_accumulator(); - let dir = std::env::temp_dir().join(format!("ultralog_table_pdf_{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("table.pdf"); - render_table_pdf( - &acc, - 0, - &ExportOptions::default(), - "UltraLog Lambda Delay", - "2026-09-18 10:00", - &path, - ) - .unwrap(); - let bytes = std::fs::read(&path).unwrap(); - assert!(bytes.starts_with(b"%PDF")); - assert!(bytes.len() > 1000); - std::fs::remove_dir_all(&dir).ok(); + let sheet = table_sheet(&acc, 0, &ExportOptions::default(), "Lambda Delay").unwrap(); + let img = export_sheet::render_png(&sheet, ExportTheme::Paper); + assert_eq!((img.width(), img.height()), (1920, 1080)); + let pdf = export_sheet::render_pdf(&sheet, ExportTheme::Graphite); + assert!(pdf.starts_with(b"%PDF")); + assert!(pdf.len() > 1000); } #[test] fn png_draws_the_highest_row_at_the_top() { // Row 0 (MAP 30-50) holds every event's low value; row 1 is empty. // On screen the highest Y bin is at the top, so the PNG must put - // row 0's Viridis fill in the bottom band and dark grey in the top. + // row 0's map fill in the bottom band and dark grey in the top. let x = AxisSpec::new("RPM", "rpm", vec![1000.0, 2000.0]); let y = AxisSpec::new("MAP", "kPa", vec![30.0, 50.0, 70.0]); let measures = vec![MeasureSpec { @@ -561,20 +322,24 @@ mod tests { }) .collect(); acc.add_log(1, "s", events, RunReport::default()); - let dir = std::env::temp_dir().join(format!("ultralog_row_order_{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("rows.png"); - render_table_png(&acc, 0, &ExportOptions::default(), &path).unwrap(); - let img = ::image::open(&path).unwrap().to_rgba8(); - let top = img.get_pixel(960, 80 + 200); - let bottom = img.get_pixel(960, 1080 - 80 - 200); - assert_eq!(top.0, [36, 36, 36, 255], "empty row 1 must be at the top"); + let sheet = table_sheet(&acc, 0, &ExportOptions::default(), "t").unwrap(); + let img = export_sheet::render_png_body(&sheet, ExportTheme::Paper); + // Body layout: plot (48, 24)-(948, 344) pt at 2 px/pt, two rows of 160 pt. Sample the + // bottom-left corner of each cell, clear of the value label and count badge. + let fill = heatmap::fill(&export_tokens(ExportTheme::Paper), MapKind::Sequential, 0.5); + let px = |x: f32, y: f32| img.get_pixel((x * 2.0) as u32, (y * 2.0) as u32).0; + let bottom = px(52.0, 340.0); + let top = px(52.0, 180.0); + assert_eq!( + &bottom[..3], + &[fill.r(), fill.g(), fill.b()], + "filled row 0 is at the bottom" + ); assert_ne!( - bottom.0, - [36, 36, 36, 255], - "filled row 0 must be at the bottom" + &top[..3], + &[fill.r(), fill.g(), fill.b()], + "empty row 1 is at the top" ); - std::fs::remove_dir_all(&dir).ok(); } #[test] @@ -616,13 +381,19 @@ mod tests { let x = AxisSpec::new("RPM", "rpm", vec![1000.0, 2000.0]); let y = AxisSpec::new("MAP", "kPa", vec![30.0, 50.0]); let acc = TableAccumulator::new(GeneratorKind::LambdaDelay, (x, y), vec![]); - let path = std::env::temp_dir().join("ultralog_never_written.png"); - assert!(render_table_png(&acc, 0, &ExportOptions::default(), &path).is_err()); + assert!(table_sheet(&acc, 0, &ExportOptions::default(), "t").is_err()); } #[test] - fn text_colour_flips_on_bright_viridis() { - assert!(!text_on(cell_rgb(0.0))); - assert!(text_on(cell_rgb(1.0))); + fn badges_carry_count_and_confidence() { + let acc = synthetic_accumulator(); + let sheet = table_sheet(&acc, 0, &ExportOptions::default(), "t").unwrap(); + let Body::Grids(grids) = sheet.body else { + panic!("a table exports as a grid"); + }; + let counts = grids[0].counts.as_ref().unwrap(); + // Every fixture cell holds 2 events, which is Low confidence. + assert_eq!(counts[0][0], Some((2, export_sheet::Confidence::Low))); + assert_eq!(grids[0].x_ticks.len(), 4, "one label per edge"); } } diff --git a/src/ui/table_generator.rs b/src/ui/table_generator.rs index 0895e5e6..8851b2d8 100644 --- a/src/ui/table_generator.rs +++ b/src/ui/table_generator.rs @@ -5,7 +5,7 @@ //! `ActiveTool::AccelEnrich`), split the way Histogram is: //! //! - **Tool Properties panel** (`render_table_tool_properties`) - channel roles -//! (auto-suggested, ⚠ on ambiguity), load axis, axes, parameters, and +//! (auto-suggested, a warning icon on ambiguity), load axis, axes, parameters, and //! *Run / Add current file*. //! - **Central panel** (`render_table_tool_view`) - Viridis heatmap with value //! text and a count badge coloured by confidence; hover for the cell tooltip; @@ -30,8 +30,10 @@ use crate::analysis::tables::{ }; use crate::analytics; use crate::app::UltraLogApp; -use crate::colormap::{Colormap, sample}; -use crate::ui::histogram::get_aaa_text_color; +use crate::ui::components::{self, Kind, icon}; +use crate::ui::fonts::{self, style}; +use crate::ui::heatmap::{self, BadgeStyle, MapKind}; +use crate::ui::theme::tokens::space; /// Per-generator setup that is re-derived per log. #[derive(Clone)] @@ -125,18 +127,13 @@ impl TableGeneratorState { } } -const CONFIDENCE_COLORS: [(Confidence, egui::Color32); 3] = [ - (Confidence::Low, egui::Color32::from_rgb(220, 80, 80)), - (Confidence::Medium, egui::Color32::from_rgb(230, 170, 50)), - (Confidence::High, egui::Color32::from_rgb(100, 200, 100)), -]; - -fn confidence_color(c: Confidence) -> egui::Color32 { - CONFIDENCE_COLORS - .iter() - .find(|(k, _)| *k == c) - .map(|(_, col)| *col) - .unwrap_or(egui::Color32::GRAY) +/// Confidence as a badge fill style (filled high, outlined medium, dashed low), never a hue. +fn badge_style(c: Confidence) -> BadgeStyle { + match c { + Confidence::High => BadgeStyle::High, + Confidence::Medium => BadgeStyle::Medium, + _ => BadgeStyle::Low, + } } fn fmt_value(v: f64, decimals: usize) -> String { @@ -162,48 +159,53 @@ impl UltraLogApp { if file_index.is_none() { ui.label( egui::RichText::new(t!("analysis.no_file_loaded")) - .color(egui::Color32::GRAY) - .size(16.0), + .color(self.tokens.ink_subtle) + .heading(), ); ui.label( egui::RichText::new(t!("analysis.load_file_help")) - .color(egui::Color32::GRAY) + .color(self.tokens.ink_subtle) .small(), ); } else { ui.label( egui::RichText::new(self.table_generator.generator(kind).description()) - .color(egui::Color32::GRAY) - .size(14.0), + .color(self.tokens.ink_subtle) + .text_style(egui::TextStyle::Body), ); ui.add_space(8.0); ui.label( egui::RichText::new(t!("table_gen.configure_hint")) - .color(egui::Color32::GRAY) + .color(self.tokens.ink_subtle) .small(), ); if let Some(report) = self.table_generator.last_report.get(&kind) { ui.add_space(12.0); ui.label( egui::RichText::new(format!( - "{}: {}", + "{} {}: {}", + crate::ui::components::Status::Warning.icon(), report.log_name, report.summary() )) - .color(egui::Color32::from_rgb(230, 170, 50)), + .color(self.tokens.warning), ); for w in &report.warnings { ui.label( egui::RichText::new(format!("• {w}")) .small() - .color(egui::Color32::GRAY), + .color(self.tokens.ink_subtle), ); } } if let Some(err) = self.table_generator.last_error.get(&kind) { ui.add_space(8.0); ui.label( - egui::RichText::new(err).color(egui::Color32::from_rgb(220, 80, 80)), + egui::RichText::new(format!( + "{} {err}", + crate::ui::components::Status::Danger.icon() + )) + .color(self.tokens.danger), ); } } @@ -224,18 +226,16 @@ impl UltraLogApp { let Some(kind) = self.active_tool.generator_kind() else { return; }; - let font_12 = self.scaled_font(12.0); - let font_14 = self.scaled_font(14.0); ui.label( egui::RichText::new(self.table_generator.generator(kind).name()) - .size(font_14) + .heading() .strong(), ); ui.label( egui::RichText::new(self.table_generator.generator(kind).description()) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); ui.add_space(8.0); @@ -243,8 +243,8 @@ impl UltraLogApp { let Some(fi) = file_index else { ui.label( egui::RichText::new(t!("table_gen.load_file_hint")) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); return; }; @@ -255,8 +255,8 @@ impl UltraLogApp { "table_gen.setup_header", file = self.files[fi].name.as_str() )) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); ui.add_space(4.0); egui::ScrollArea::vertical() @@ -396,13 +396,11 @@ impl UltraLogApp { state.ambiguous.retain(|r| *r != spec.role); } if state.ambiguous.contains(&spec.role) { - ui.label( - egui::RichText::new("⚠").color(egui::Color32::from_rgb(230, 170, 50)), - ) - .on_hover_text(t!("table_gen.ambiguous_hint")); + ui.label(egui::RichText::new(icon::WARNING).color(self.tokens.warning)) + .on_hover_text(t!("table_gen.ambiguous_hint")); } else if spec.required && !state.mapping.is_mapped(spec.role) { ui.label( - egui::RichText::new("!").color(egui::Color32::from_rgb(220, 80, 80)), + egui::RichText::new(icon::WARNING_OCTAGON).color(self.tokens.danger), ) .on_hover_text(t!("table_gen.required_hint")); } else { @@ -448,11 +446,15 @@ impl UltraLogApp { ui.label(if valid { egui::RichText::new(t!("table_gen.bins", n = axis.bins())) .small() - .color(egui::Color32::GRAY) + .color(self.tokens.ink_subtle) } else { - egui::RichText::new(t!("table_gen.invalid_axis")) - .small() - .color(egui::Color32::from_rgb(220, 80, 80)) + egui::RichText::new(format!( + "{} {}", + crate::ui::components::Status::Danger.icon(), + t!("table_gen.invalid_axis") + )) + .small() + .color(self.tokens.danger) }); }); let resp = ui.add( @@ -578,26 +580,35 @@ impl UltraLogApp { t!("table_gen.run") }; ui.horizontal(|ui| { - let button = - egui::Button::new(egui::RichText::new(label.as_ref()).color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(113, 120, 78)); - if ui - .add_enabled(missing.is_empty() && axes_ok, button) - .clicked() - { + let run = ui + .add_enabled_ui(missing.is_empty() && axes_ok, |ui| { + components::button(ui, Kind::Primary, None, label.as_ref()) + }) + .inner; + if run.clicked() { self.run_table_generator(kind, file_index); } if !missing.is_empty() { let names: Vec<&str> = missing.iter().map(|r| r.label()).collect(); ui.label( - egui::RichText::new(t!("table_gen.missing_roles", roles = names.join(", "))) - .small() - .color(egui::Color32::from_rgb(220, 80, 80)), + egui::RichText::new(format!( + "{} {}", + crate::ui::components::Status::Danger.icon(), + t!("table_gen.missing_roles", roles = names.join(", ")) + )) + .small() + .color(self.tokens.danger), ); } }); if let Some(err) = self.table_generator.last_error.get(&kind) { - ui.label(egui::RichText::new(err).color(egui::Color32::from_rgb(220, 80, 80))); + ui.label( + egui::RichText::new(format!( + "{} {err}", + crate::ui::components::Status::Danger.icon() + )) + .color(self.tokens.danger), + ); } // Deferred actions that need `&mut self` outside the borrows above. @@ -778,7 +789,7 @@ impl UltraLogApp { high = high )) .small() - .color(egui::Color32::GRAY), + .color(self.tokens.ink_subtle), ); for r in &reports { ui.label(egui::RichText::new(format!("{}: {}", r.log_name, r.summary())).small()); @@ -796,7 +807,7 @@ impl UltraLogApp { ui.label( egui::RichText::new(format!("• {w}")) .small() - .color(egui::Color32::GRAY), + .color(self.tokens.ink_subtle), ); } }); @@ -807,11 +818,17 @@ impl UltraLogApp { let decimals = measures[measure_idx].decimals; if accepted == 0 { ui.label( - egui::RichText::new(t!("table_gen.no_events")) - .color(egui::Color32::from_rgb(230, 170, 50)), + egui::RichText::new(format!( + "{} {}", + crate::ui::components::Status::Warning.icon(), + t!("table_gen.no_events") + )) + .color(self.tokens.warning), ); } else { - self.render_table_heatmap(ui, &grid, decimals); + let title = measures[measure_idx].label.to_string(); + let rich_lean = measures[measure_idx].lean_is_positive(); + self.render_table_heatmap(ui, &grid, decimals, &title, rich_lean); } // --- Inspector ------------------------------------------------------ @@ -882,7 +899,7 @@ impl UltraLogApp { ui.label( egui::RichText::new(&e.note) .small() - .color(egui::Color32::GRAY), + .color(self.tokens.ink_subtle), ); let loaded = self.files.iter().any(|f| f.load_id == e.log_id); if ui @@ -935,6 +952,8 @@ impl UltraLogApp { ui: &mut egui::Ui, grid: &crate::analysis::tables::TableGrid, decimals: usize, + title: &str, + rich_lean: bool, ) { let rows = grid.rows(); let cols = grid.cols(); @@ -951,16 +970,32 @@ impl UltraLogApp { label_h + cell_h * rows as f32 + 18.0, ); let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + let t = self.tokens; let painter = ui.painter_at(rect); - let font = egui::FontId::proportional((cell_h * 0.42).clamp(9.0, 13.0)); - let small = egui::FontId::proportional(9.0); - let text_color = ui.visuals().text_color(); + let font = egui::FontId::monospace((cell_h * 0.42).clamp(9.0, 13.0)); + let small = egui::FontId::monospace(9.0); + let badge_font = egui::FontId::monospace(8.0); + let text_color = t.plot_axis; let (lo, hi) = grid.value_range().unwrap_or((0.0, 1.0)); + // Signed values around zero (corrections, deviations) use the diverging map centred on + // zero; magnitudes (dead time, t63) use the sequential map. + let kind = if lo < 0.0 && hi > 0.0 { + MapKind::Diverging + } else { + MapKind::Sequential + }; + let span = lo.abs().max(hi.abs()).max(f64::EPSILON); let range = if (hi - lo).abs() < f64::EPSILON { 1.0 } else { hi - lo }; + let normalize = |v: f64| -> f32 { + match kind { + MapKind::Diverging => (0.5 + v / (2.0 * span)) as f32, + MapKind::Sequential => ((v - lo) / range) as f32, + } + }; let origin = rect.min + egui::vec2(label_w, label_h); let hover = response.hover_pos(); let mut hovered: Option<(usize, usize)> = None; @@ -979,16 +1014,16 @@ impl UltraLogApp { painter.text( egui::pos2(origin.x + cell_w * cols as f32 * 0.5, rect.max.y - 8.0), egui::Align2::CENTER_CENTER, - grid.x_axis.header(), - small.clone(), - text_color, + grid.x_axis.header().to_uppercase(), + fonts::text_style(style::OVERLINE).resolve(ui.style()), + t.ink_muted, ); painter.text( egui::pos2(rect.min.x + 2.0, rect.min.y + label_h * 0.5), egui::Align2::LEFT_CENTER, - grid.y_axis.header(), - small.clone(), - text_color, + grid.y_axis.header().to_uppercase(), + fonts::text_style(style::OVERLINE).resolve(ui.style()), + t.ink_muted, ); // Rows are drawn top-down with the highest Y bin at the top, the way @@ -1016,55 +1051,79 @@ impl UltraLogApp { { hovered = Some((r, c)); } + let inner = cell_rect.shrink(1.0); if cell.is_empty() { - painter.rect_filled( - cell_rect.shrink(0.5), - 2.0, - egui::Color32::from_rgb(36, 36, 36), - ); + heatmap::empty_cell(&painter, inner, &t); continue; } - let t = ((cell.median - lo) / range) as f32; - let fill = sample(Colormap::Viridis, t); - painter.rect_filled(cell_rect.shrink(0.5), 2.0, fill); - let fg = get_aaa_text_color(fill); - let value_text = fmt_value(cell.median, decimals); - let text = if cell.confidence == Confidence::Low { - egui::RichText::new(value_text).italics() - } else { - egui::RichText::new(value_text) - }; - painter.text( - cell_rect.center(), - egui::Align2::CENTER_CENTER, - text.text(), - font.clone(), - fg, - ); - // Count badge, coloured by confidence. - let badge = egui::Rect::from_min_size( - cell_rect.right_top() + egui::vec2(-14.0, 1.0), - egui::vec2(13.0, 9.0), + heatmap::cell( + &painter, + inner, + &t, + kind, + normalize(cell.median), + Some(&fmt_value(cell.median, decimals)), + &font, ); - painter.rect_filled(badge, 2.0, confidence_color(cell.confidence)); - painter.text( - badge.center(), - egui::Align2::CENTER_CENTER, - cell.count.to_string(), - egui::FontId::proportional(7.5), - egui::Color32::BLACK, + heatmap::count_badge( + &painter, + inner.right_top() + egui::vec2(-1.0, 1.0), + cell.count, + badge_style(cell.confidence), + &t, + &badge_font, ); if self.table_generator.selected_cell == Some((r, c)) { - painter.rect_stroke( - cell_rect.shrink(1.0), - 2.0, - egui::Stroke::new(2.0, egui::Color32::WHITE), - egui::StrokeKind::Inside, - ); + heatmap::selected_ring(&painter, inner, &t); } } } + // ColorScale with the real range, and what the badge styles mean + ui.add_space(space::SPACE_2); + let (min_label, max_label) = match kind { + MapKind::Diverging => ( + format!("\u{2212}{}", fmt_value(span, decimals)), + format!("+{}", fmt_value(span, decimals)), + ), + MapKind::Sequential => (fmt_value(lo, decimals), fmt_value(hi, decimals)), + }; + ui.horizontal(|ui| { + ui.vertical(|ui| { + heatmap::color_scale(ui, kind, title, &min_label, &max_label, rich_lean); + }); + ui.add_space(space::SPACE_6); + let (legend, _) = ui.allocate_exact_size(egui::vec2(330.0, 18.0), egui::Sense::hover()); + let p = ui.painter(); + let mut x = legend.left(); + let label_font = egui::TextStyle::Small.resolve(ui.style()); + for (count, style_, word) in [ + (12, Some(BadgeStyle::High), t!("table_gen.legend_high")), + (3, Some(BadgeStyle::Medium), t!("table_gen.legend_medium")), + (1, Some(BadgeStyle::Low), t!("table_gen.legend_low")), + (0, None, t!("table_gen.legend_empty")), + ] { + let swatch = egui::Rect::from_min_size( + egui::pos2(x, legend.top() + 2.0), + egui::vec2(16.0, 13.0), + ); + match style_ { + Some(st) => { + heatmap::count_badge(p, swatch.right_top(), count, st, &t, &badge_font) + } + None => heatmap::empty_cell(p, swatch, &t), + } + let g = p.layout_no_wrap(word.to_string(), label_font.clone(), t.ink_muted); + let w = g.size().x; + p.galley( + egui::pos2(swatch.right() + 5.0, legend.top() + 2.0), + g, + t.ink_muted, + ); + x = swatch.right() + 5.0 + w + space::SPACE_4; + } + }); + if let Some((r, c)) = hovered { if response.clicked() { self.table_generator.selected_cell = diff --git a/src/ui/theme/codegen.rs b/src/ui/theme/codegen.rs new file mode 100644 index 00000000..8dcf7ea7 --- /dev/null +++ b/src/ui/theme/codegen.rs @@ -0,0 +1,315 @@ +//! Generator for `tokens.rs` from `design/tokens/tokens.json`. +//! +//! Run `cargo run --example gen_theme` after editing the JSON. The test at the bottom fails when +//! the committed `tokens.rs` no longer matches the JSON. +//! +//! Resolution rules (they match `design/tokens/tokens.ts`): +//! - A colour token whose value is a string applies to every theme. +//! - A theme with no entry of its own takes the `night` value. +//! - `{name}` references resolve inside the same theme. +//! - Effects come from the shadow tokens: glow from `glow-cyan` (first blurred layer), bloom from +//! `bloom-series`, the redline is flat when `redline-0` points at `line-control`, the plot floor +//! is on when `plot-floor` is not fully transparent. High contrast turns every effect off. + +use serde_json::Value; +use std::fmt::Write as _; + +const FALLBACK_THEME: &str = "night"; + +/// Generate the Rust source of `tokens.rs` from the JSON text of `tokens.json`. +pub fn generate(json: &str) -> Result { + let doc: Value = serde_json::from_str(json).map_err(|e| e.to_string())?; + let themes: Vec = doc["color"]["themes"] + .as_array() + .ok_or("color.themes missing")? + .iter() + .filter_map(|t| t["id"].as_str().map(str::to_owned)) + .collect(); + let tokens = doc["color"]["tokens"] + .as_array() + .ok_or("color.tokens missing")?; + let names: Vec<&str> = tokens.iter().filter_map(|t| t["name"].as_str()).collect(); + + let mut out = String::new(); + out.push_str(HEADER); + + out.push_str("#[derive(Clone, Copy, Debug, PartialEq)]\npub struct Tokens {\n"); + for name in &names { + let _ = writeln!(out, " pub {}: Color32,", ident(name)); + } + out.push_str(" pub fx: Effects,\n}\n"); + + for theme in &themes { + let _ = writeln!( + out, + "\npub const {}: Tokens = Tokens {{", + theme.to_uppercase() + ); + for name in &names { + let hex = resolve(tokens, name, theme, 0)?; + let _ = writeln!(out, " {}: {},", ident(name), color_expr(&hex)?); + } + let fx = effects(&doc, tokens, theme)?; + let _ = writeln!( + out, + " fx: Effects {{ glow_blur: {:?}, glow_alpha: {:?}, bloom: {:?}, redline_gradient: {}, plot_floor: {} }},", + fx.glow_blur, fx.glow_alpha, fx.bloom, fx.redline_gradient, fx.plot_floor + ); + out.push_str("};\n"); + } + + out.push('\n'); + px_module(&mut out, &doc["spacing"]["tokens"], "space", "f32")?; + px_module(&mut out, &doc["radius"]["tokens"], "radius", "u8")?; + px_module(&mut out, &doc["stroke"]["tokens"], "stroke", "f32")?; + text_module(&mut out, &doc["type"]["groups"])?; + Ok(out) +} + +const HEADER: &str = "\ +//! UltraLog design tokens — generated from `design/tokens/tokens.json` by +//! `cargo run --example gen_theme`. Do not edit by hand. +//! Seven anchor themes. The live theme is built by `super::tokens_at`. +use eframe::egui::Color32; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Effects { + /// Blur radius of glow-cyan / glow-magenta (px). 0 = no glow. + pub glow_blur: f32, + /// Alpha of the glow colour. + pub glow_alpha: f32, + /// Width of the translucent bloom drawn under series lines (px). 0 = off. + pub bloom: f32, + /// Draw the sunset redline as a gradient (false = flat line-control hairline). + pub redline_gradient: bool, + /// Draw the perspective floor inside plots. + pub plot_floor: bool, +} + +"; + +fn ident(name: &str) -> String { + name.replace('-', "_") +} + +fn find<'a>(tokens: &'a [Value], name: &str) -> Option<&'a Value> { + tokens.iter().find(|t| t["name"].as_str() == Some(name)) +} + +/// The raw value of a token for a theme, before reference resolution. +fn raw<'a>(token: &'a Value, theme: &str) -> Option<&'a str> { + match &token["value"] { + Value::String(s) => Some(s), + Value::Object(m) => m + .get(theme) + .or_else(|| m.get(FALLBACK_THEME)) + .and_then(Value::as_str), + _ => None, + } +} + +fn resolve(tokens: &[Value], name: &str, theme: &str, depth: u8) -> Result { + if depth > 8 { + return Err(format!("reference loop at {name}")); + } + let token = find(tokens, name).ok_or_else(|| format!("unknown token {name}"))?; + let v = raw(token, theme).ok_or_else(|| format!("{name} has no value for {theme}"))?; + match v.strip_prefix('{').and_then(|r| r.strip_suffix('}')) { + Some(target) => resolve(tokens, target, theme, depth + 1), + None => Ok(v.to_ascii_lowercase()), + } +} + +fn parse_hex(hex: &str) -> Result<[u8; 4], String> { + let h = hex + .strip_prefix('#') + .ok_or_else(|| format!("not a hex colour: {hex}"))?; + let byte = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).map_err(|e| format!("{hex}: {e}")); + match h.len() { + 6 => Ok([byte(0)?, byte(2)?, byte(4)?, 255]), + 8 => Ok([byte(0)?, byte(2)?, byte(4)?, byte(6)?]), + _ => Err(format!("not a hex colour: {hex}")), + } +} + +/// Opaque colours use `from_rgb`; translucent ones are premultiplied in sRGB space. +fn color_expr(hex: &str) -> Result { + let [r, g, b, a] = parse_hex(hex)?; + if a == 255 { + return Ok(format!("Color32::from_rgb({r}, {g}, {b})")); + } + let pm = |c: u8| ((c as u32 * a as u32 + 127) / 255) as u8; + Ok(format!( + "Color32::from_rgba_premultiplied({}, {}, {}, {a})", + pm(r), + pm(g), + pm(b) + )) +} + +struct Fx { + glow_blur: f32, + glow_alpha: f32, + bloom: f32, + redline_gradient: bool, + plot_floor: bool, +} + +fn effects(doc: &Value, tokens: &[Value], theme: &str) -> Result { + if theme == "hc" { + return Ok(Fx { + glow_blur: 0.0, + glow_alpha: 0.0, + bloom: 0.0, + redline_gradient: false, + plot_floor: false, + }); + } + let shadows = doc["shadow"]["tokens"] + .as_array() + .ok_or("shadow.tokens missing")?; + let shadow = |name: &str| { + find(shadows, name) + .and_then(|t| raw(t, theme)) + .unwrap_or("none") + }; + + let (glow_blur, glow_alpha) = first_blurred_layer(shadow("glow-cyan")); + let bloom = shadow("bloom-series") + .split_whitespace() + .filter_map(px) + .nth(2) + .unwrap_or(0.0); + let redline_raw = find(tokens, "redline-0").and_then(|t| raw(t, theme)); + let redline_gradient = redline_raw != Some("{line-control}"); + let plot_floor = parse_hex(&resolve(tokens, "plot-floor", theme, 0)?)?[3] > 0; + Ok(Fx { + glow_blur, + glow_alpha, + bloom, + redline_gradient, + plot_floor, + }) +} + +/// Blur and alpha of the first CSS box-shadow layer with a non-zero blur, e.g. +/// `0 0 0 1px #2de2ff, 0 0 16px rgba(45,226,255,0.35)` -> (16, 0.35). +fn first_blurred_layer(css: &str) -> (f32, f32) { + for layer in shadow_layers(css) { + let lengths: Vec = layer.split_whitespace().filter_map(px).collect(); + let blur = lengths.get(2).copied().unwrap_or(0.0); + if blur > 0.0 { + let alpha = layer + .rsplit(',') + .next() + .and_then(|a| a.trim().trim_end_matches(')').parse::().ok()) + .unwrap_or(1.0); + return (blur, alpha); + } + } + (0.0, 0.0) +} + +/// Split a CSS shadow list on the commas between layers, not those inside `rgba(..)`. +fn shadow_layers(css: &str) -> Vec<&str> { + let mut layers = Vec::new(); + let (mut depth, mut start) = (0i32, 0); + for (i, ch) in css.char_indices() { + match ch { + '(' => depth += 1, + ')' => depth -= 1, + ',' if depth == 0 => { + layers.push(css[start..i].trim()); + start = i + 1; + } + _ => {} + } + } + layers.push(css[start..].trim()); + layers +} + +fn px(s: &str) -> Option { + if s == "0" { + return Some(0.0); + } + s.strip_suffix("px")?.parse().ok() +} + +fn px_module(out: &mut String, tokens: &Value, module: &str, ty: &str) -> Result<(), String> { + let tokens = tokens + .as_array() + .ok_or_else(|| format!("{module} tokens missing"))?; + let _ = writeln!(out, "pub mod {module} {{"); + for t in tokens { + let name = t["name"].as_str().ok_or("token without a name")?; + let value = t["value"] + .as_str() + .and_then(px) + .ok_or_else(|| format!("{name}: expected a px value"))?; + let literal = if ty == "u8" { + format!("{}", value.min(255.0) as u8) + } else { + format!("{value:?}") + }; + let _ = writeln!( + out, + " pub const {}: {ty} = {literal};", + ident(name).to_uppercase() + ); + } + out.push_str("}\n"); + Ok(()) +} + +fn text_module(out: &mut String, groups: &Value) -> Result<(), String> { + let groups = groups.as_array().ok_or("type.groups missing")?; + out.push_str( + "\n/// Type scale: (family, size px, line height px). Families: \"display\" = Saira, \ + \"ui\" = Outfit, \"mono\" = JetBrains Mono.\npub mod text {\n", + ); + for g in groups { + let family = g["family"].as_str().ok_or("type group without a family")?; + for s in g["styles"].as_array().ok_or("type group without styles")? { + let name = s["name"].as_str().ok_or("type style without a name")?; + let size = s["fontSize"].as_str().and_then(px); + let line = s["lineHeight"].as_str().and_then(px); + let (Some(size), Some(line)) = (size, line) else { + return Err(format!("{name}: fontSize/lineHeight must be px")); + }; + let _ = writeln!( + out, + " pub const {}: (&str, f32, f32) = ({family:?}, {size:?}, {line:?});", + ident(name).to_uppercase() + ); + } + } + out.push_str("}\n"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const JSON: &str = include_str!("../../../design/tokens/tokens.json"); + const COMMITTED: &str = include_str!("tokens.rs"); + + #[test] + fn committed_tokens_match_json() { + let generated = generate(JSON).expect("tokens.json must generate"); + assert!( + generated == COMMITTED, + "src/ui/theme/tokens.rs is stale: run `cargo run --example gen_theme`" + ); + } + + #[test] + fn glow_layer_parsing() { + assert_eq!( + first_blurred_layer("0 0 0 1px #2de2ff, 0 0 16px rgba(45,226,255,0.35)"), + (16.0, 0.35) + ); + assert_eq!(first_blurred_layer("none"), (0.0, 0.0)); + } +} diff --git a/src/ui/theme/mod.rs b/src/ui/theme/mod.rs new file mode 100644 index 00000000..86550fea --- /dev/null +++ b/src/ui/theme/mod.rs @@ -0,0 +1,744 @@ +//! Night Drive theme runtime. +//! +//! Builds the live [`Tokens`] from the user's settings and maps them onto egui. The seven anchor +//! tables in [`tokens`] are generated from `design/tokens/tokens.json` (see [`codegen`]); every +//! colour, size and radius in the UI comes from here. Design source: `design/design-system/`. +//! +//! Composition: chrome tokens (`bg_*`, `line_*`, `ink_*`, glow, redline, floor) follow Ultra +//! intensity; data tokens (series, maps, status, cursor) follow Color blind mode; High contrast +//! overrides both. Data colours never depend on intensity. + +pub mod codegen; +pub mod paint; +#[rustfmt::skip] +pub mod tokens; + +pub use tokens::{Effects, Tokens}; + +use eframe::egui::{self, Color32, CornerRadius, Shadow, Stroke, Visuals}; +use tokens::{self as ut}; + +/// User-facing settings that decide the theme. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ThemeSettings { + /// Ultra intensity, 0..=100. 0 Stealth (default), 35 Night Drive, 70 Neon, 100 Ultra. + pub intensity: f32, + pub color_blind: bool, + pub high_contrast: bool, +} + +impl Default for ThemeSettings { + fn default() -> Self { + Self { + intensity: DEFAULT_INTENSITY, + color_blind: false, + high_contrast: false, + } + } +} + +/// The intensity a new install opens at: Stealth. +pub const DEFAULT_INTENSITY: f32 = 0.0; + +/// The Night Drive anchor on the Ultra intensity slider. `tokens_at` blends Stealth -> Night +/// Drive below it and Night Drive -> Ultra above it. +pub const NIGHT_DRIVE_INTENSITY: f32 = 35.0; + +/// Named stops on the Ultra intensity slider. The names are the brand's level names and are not +/// translated. +pub const INTENSITY_STOPS: [(u8, &str); 4] = [ + (0, "Stealth"), + (35, "Night Drive"), + (70, "Neon"), + (100, "Ultra"), +]; + +/// How close (in slider units) a value must come to a named stop to snap onto it. +pub const INTENSITY_DETENT: u8 = 2; + +/// The name of the stop nearest to `value` (ties go to the lower stop). +pub fn intensity_name(value: u8) -> &'static str { + INTENSITY_STOPS + .iter() + .min_by_key(|(stop, _)| stop.abs_diff(value)) + .map(|(_, name)| *name) + .unwrap_or("Night Drive") +} + +/// `value` snapped onto a named stop when it is within [`INTENSITY_DETENT`] of one. +pub fn snap_intensity(value: u8) -> u8 { + INTENSITY_STOPS + .iter() + .map(|(stop, _)| *stop) + .find(|stop| stop.abs_diff(value) <= INTENSITY_DETENT) + .unwrap_or(value) +} + +/// Export themes ignore every app setting. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ExportTheme { + #[default] + Paper, + Graphite, +} + +pub fn export_tokens(t: ExportTheme) -> Tokens { + match t { + ExportTheme::Paper => ut::PAPER, + ExportTheme::Graphite => ut::GRAPHITE, + } +} + +/// The live token table for the app. +pub fn tokens_at(s: ThemeSettings) -> Tokens { + if s.high_contrast { + return ut::HC; + } + let i = s.intensity.clamp(0.0, 100.0); + let mut t = if i <= NIGHT_DRIVE_INTENSITY { + lerp_tokens(&ut::STEALTH, &ut::NIGHT, i / NIGHT_DRIVE_INTENSITY) + } else { + lerp_tokens( + &ut::NIGHT, + &ut::ULTRA, + (i - NIGHT_DRIVE_INTENSITY) / (100.0 - NIGHT_DRIVE_INTENSITY), + ) + }; + if s.color_blind { + let c = ut::CVD; + // Data + status come from the color-blind table; text is raised to its AAA values. + copy_data(&mut t, &c); + t.ink_muted = c.ink_muted; + t.ink_subtle = c.ink_subtle; + t.line_control = c.line_control; + t.magenta = c.magenta; + t.fx.bloom = 0.0; + t.fx.plot_floor = false; + t.plot_floor = Color32::TRANSPARENT; + } + t +} + +fn copy_data(t: &mut Tokens, c: &Tokens) { + t.success = c.success; + t.success_soft = c.success_soft; + t.warning = c.warning; + t.warning_soft = c.warning_soft; + t.danger = c.danger; + t.danger_soft = c.danger_soft; + t.info = c.info; + t.series_1 = c.series_1; + t.series_2 = c.series_2; + t.series_3 = c.series_3; + t.series_4 = c.series_4; + t.series_5 = c.series_5; + t.series_6 = c.series_6; + t.series_7 = c.series_7; + t.series_8 = c.series_8; + t.series_9 = c.series_9; + t.series_10 = c.series_10; + t.seq_0 = c.seq_0; + t.seq_1 = c.seq_1; + t.seq_2 = c.seq_2; + t.seq_3 = c.seq_3; + t.seq_4 = c.seq_4; + t.seq_5 = c.seq_5; + t.seq_6 = c.seq_6; + t.on_seq_0 = c.on_seq_0; + t.on_seq_1 = c.on_seq_1; + t.on_seq_2 = c.on_seq_2; + t.on_seq_3 = c.on_seq_3; + t.on_seq_4 = c.on_seq_4; + t.on_seq_5 = c.on_seq_5; + t.on_seq_6 = c.on_seq_6; + t.div_0 = c.div_0; + t.div_1 = c.div_1; + t.div_2 = c.div_2; + t.div_3 = c.div_3; + t.div_4 = c.div_4; + t.on_div_0 = c.on_div_0; + t.on_div_1 = c.on_div_1; + t.on_div_2 = c.on_div_2; + t.on_div_3 = c.on_div_3; + t.on_div_4 = c.on_div_4; + t.cell_plate = c.cell_plate; + t.plot_cursor = c.plot_cursor; + t.plot_selection = c.plot_selection; +} + +// ---------- data colours ---------- + +/// Line style of a series. The palette has 10 colours (8 distinct ones when color-blind safe), +/// so later channels repeat the colours with a different stroke. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SeriesStroke { + Solid, + Dashed, + Dotted, +} + +/// Series colour and stroke by selection index (0-based). +/// +/// `color_blind_like` is true for Color blind mode, High contrast and exports: their palette is +/// eight Okabe-Ito colours, and series 9-10 repeat 1-2 dashed. Otherwise the ten Night Drive +/// colours repeat dashed for channels 11-20 and dotted from 21 on. +pub fn series(t: &Tokens, idx: usize, color_blind_like: bool) -> (Color32, SeriesStroke) { + let s = [ + t.series_1, + t.series_2, + t.series_3, + t.series_4, + t.series_5, + t.series_6, + t.series_7, + t.series_8, + t.series_9, + t.series_10, + ]; + let distinct = if color_blind_like { 8 } else { 10 }; + let stroke = match idx / distinct { + 0 => SeriesStroke::Solid, + 1 => SeriesStroke::Dashed, + _ => SeriesStroke::Dotted, + }; + (s[idx % distinct], stroke) +} + +/// Series colour and stroke while a tab compares two logs (issue #98): the colour of slot `idx`, +/// solid for log A and dashed for log B, so a pair reads as one colour. The stroke shows the log, +/// so slots past the palette repeat colours without a stroke change (the one exception to +/// [`series`]); the A/B tag in the legend and labels still tells them apart. +pub fn compare_series( + t: &Tokens, + idx: usize, + color_blind_like: bool, + log_b: bool, +) -> (Color32, SeriesStroke) { + let (color, _) = series(t, idx, color_blind_like); + let stroke = if log_b { + SeriesStroke::Dashed + } else { + SeriesStroke::Solid + }; + (color, stroke) +} + +/// Sequential colour map sample, `v` in 0..=1, interpolated in OKLab between the 7 stops. +pub fn seq(t: &Tokens, v: f32) -> Color32 { + let s = [ + t.seq_0, t.seq_1, t.seq_2, t.seq_3, t.seq_4, t.seq_5, t.seq_6, + ]; + sample_stops(&s, v) +} + +/// Diverging colour map sample, `v` in 0..=1 with 0.5 on target, interpolated in OKLab between +/// the 5 stops. Rich / negative is `div_0`, lean / positive is `div_4`. +pub fn div(t: &Tokens, v: f32) -> Color32 { + let s = [t.div_0, t.div_1, t.div_2, t.div_3, t.div_4]; + sample_stops(&s, v) +} + +/// Text colour for a value drawn on [`seq`] at `v` (the nearest stop's `on_seq_N`). +pub fn on_seq(t: &Tokens, v: f32) -> Color32 { + let s = [ + t.on_seq_0, t.on_seq_1, t.on_seq_2, t.on_seq_3, t.on_seq_4, t.on_seq_5, t.on_seq_6, + ]; + s[nearest_stop(s.len(), v)] +} + +/// Text colour for a value drawn on [`div`] at `v` (the nearest stop's `on_div_N`). +pub fn on_div(t: &Tokens, v: f32) -> Color32 { + let s = [t.on_div_0, t.on_div_1, t.on_div_2, t.on_div_3, t.on_div_4]; + s[nearest_stop(s.len(), v)] +} + +fn sample_stops(stops: &[Color32], v: f32) -> Color32 { + let last = stops.len() - 1; + let v = if v.is_finite() { + v.clamp(0.0, 1.0) + } else { + 0.0 + }; + let x = v * last as f32; + let i = (x.floor() as usize).min(last - 1); + lerp_oklab(stops[i], stops[i + 1], x - i as f32) +} + +fn nearest_stop(count: usize, v: f32) -> usize { + let v = if v.is_finite() { + v.clamp(0.0, 1.0) + } else { + 0.0 + }; + (v * (count - 1) as f32).round() as usize +} + +// ---------- egui mapping ---------- + +pub fn visuals(t: &Tokens) -> Visuals { + let mut v = Visuals::dark(); + v.dark_mode = true; + v.panel_fill = t.bg_panel; + v.window_fill = t.bg_raised; + v.extreme_bg_color = t.bg_field; + v.faint_bg_color = t.bg_base; + v.code_bg_color = t.bg_field; + v.hyperlink_color = t.cyan; + v.warn_fg_color = t.warning; + v.error_fg_color = t.danger; + v.selection.bg_fill = t.cyan_soft; + v.selection.stroke = Stroke::new(1.0, t.cyan); + v.window_stroke = Stroke::new(1.0, t.line); + v.window_corner_radius = CornerRadius::same(ut::radius::RADIUS_LG); + v.menu_corner_radius = CornerRadius::same(ut::radius::RADIUS_MD); + v.window_shadow = Shadow { + offset: [0, 18], + blur: 48, + spread: 0, + color: Color32::from_black_alpha(150), + }; + v.popup_shadow = Shadow { + offset: [0, 8], + blur: 24, + spread: 0, + color: Color32::from_black_alpha(120), + }; + + let w = &mut v.widgets; + w.noninteractive.bg_fill = t.bg_panel; + w.noninteractive.weak_bg_fill = t.bg_panel; + w.noninteractive.bg_stroke = Stroke::new(1.0, t.line); + w.noninteractive.fg_stroke = Stroke::new(1.0, t.ink_muted); + w.inactive.bg_fill = t.bg_field; + w.inactive.weak_bg_fill = t.bg_raised; + w.inactive.bg_stroke = Stroke::new(1.0, t.line_control); + w.inactive.fg_stroke = Stroke::new(1.0, t.ink); + w.hovered.bg_fill = t.bg_hover; + w.hovered.weak_bg_fill = t.bg_hover; + w.hovered.bg_stroke = Stroke::new(1.0, t.line_control); + w.hovered.fg_stroke = Stroke::new(1.0, t.ink); + w.active.bg_fill = t.bg_active; + w.active.weak_bg_fill = t.bg_active; + w.active.bg_stroke = Stroke::new(1.0, t.cyan); + w.active.fg_stroke = Stroke::new(1.0, t.ink); + w.open.bg_fill = t.bg_active; + w.open.weak_bg_fill = t.bg_active; + w.open.bg_stroke = Stroke::new(1.0, t.line_control); + w.open.fg_stroke = Stroke::new(1.0, t.ink); + for s in [ + &mut w.noninteractive, + &mut w.inactive, + &mut w.hovered, + &mut w.active, + &mut w.open, + ] { + s.corner_radius = CornerRadius::same(ut::radius::RADIUS_MD); + } + v +} + +/// Glow behind the primary button / active tab. `None` when intensity turns glows off. +pub fn glow(t: &Tokens, color: Color32) -> Option { + if t.fx.glow_blur <= 0.0 { + return None; + } + let [r, g, b, _] = color.to_array(); + Some(Shadow { + offset: [0, 0], + blur: t.fx.glow_blur as u8, + spread: 1, + color: Color32::from_rgba_unmultiplied(r, g, b, (t.fx.glow_alpha * 255.0) as u8), + }) +} + +/// Pin the app to the dark theme slot and install `t` as its visuals, with the type scale at +/// `font_scale`. Without the pin, egui follows the OS appearance and would swap in its own light +/// style. +pub fn install(ctx: &egui::Context, t: &Tokens, font_scale: f32) { + ctx.set_theme(egui::ThemePreference::Dark); + ctx.style_mut_of(egui::Theme::Dark, |style| { + style.visuals = visuals(t); + style.text_styles = crate::ui::fonts::text_styles(font_scale); + // Window body padding is space-4 (Window spec). + // Motion: quick and mechanical, 120 ms for hover and toggles. + style.animation_time = 0.12; + style.spacing.window_margin = egui::Margin::same(ut::space::SPACE_4 as i8); + }); + ctx.data_mut(|d| d.insert_temp(current_id(), *t)); +} + +/// The tokens last installed with [`install`]; Night Drive before the first install. +pub fn current(ctx: &egui::Context) -> Tokens { + ctx.data(|d| d.get_temp(current_id())) + .unwrap_or_else(|| tokens_at(ThemeSettings::default())) +} + +fn current_id() -> egui::Id { + egui::Id::new("ultralog_theme_tokens") +} + +// ---------- interpolation ---------- + +fn lerp_tokens(a: &Tokens, b: &Tokens, k: f32) -> Tokens { + let k = k.clamp(0.0, 1.0); + macro_rules! mix { + ($($f:ident),* $(,)?) => { + Tokens { $($f: lerp_oklab(a.$f, b.$f, k),)* fx: lerp_fx(a.fx, b.fx, k) } + }; + } + mix!( + bg_void, + bg_base, + bg_panel, + bg_raised, + bg_field, + bg_hover, + bg_active, + line_subtle, + line, + line_control, + ink, + ink_muted, + ink_subtle, + ink_disabled, + cyan, + cyan_soft, + on_cyan, + magenta, + magenta_soft, + on_magenta, + violet, + orange, + gold, + redline_0, + redline_1, + redline_2, + redline_3, + redline_4, + grid_floor, + success, + success_soft, + warning, + warning_soft, + danger, + danger_soft, + info, + series_1, + series_2, + series_3, + series_4, + series_5, + series_6, + series_7, + series_8, + series_9, + series_10, + plot_bg, + plot_grid, + plot_grid_major, + plot_axis, + plot_cursor, + plot_selection, + plot_legend_bg, + plot_floor, + seq_0, + seq_1, + seq_2, + seq_3, + seq_4, + seq_5, + seq_6, + on_seq_0, + on_seq_1, + on_seq_2, + on_seq_3, + on_seq_4, + on_seq_5, + on_seq_6, + div_0, + div_1, + div_2, + div_3, + div_4, + on_div_0, + on_div_1, + on_div_2, + on_div_3, + on_div_4, + cell_empty, + cell_plate, + ) +} + +fn lerp_fx(a: Effects, b: Effects, k: f32) -> Effects { + let l = |x: f32, y: f32| x + (y - x) * k; + Effects { + glow_blur: l(a.glow_blur, b.glow_blur), + glow_alpha: l(a.glow_alpha, b.glow_alpha), + bloom: l(a.bloom, b.bloom), + redline_gradient: if k < 0.5 { + a.redline_gradient + } else { + b.redline_gradient + }, + plot_floor: if k < 0.5 { a.plot_floor } else { b.plot_floor }, + } +} + +/// The tint that draws an image as it is, at `opacity` (0..=1). Egui multiplies an image by its +/// tint, so white is "no tint"; this keeps that literal inside the theme module. +pub fn image_tint(opacity: f32) -> Color32 { + Color32::from_white_alpha((opacity.clamp(0.0, 1.0) * 255.0).round() as u8) +} + +/// `color` composited over an opaque `background` (exports flatten translucent tokens: PDF has +/// no cheap alpha). +pub fn flatten(color: Color32, background: Color32) -> Color32 { + let a = color.a() as f32 / 255.0; + if a >= 1.0 { + return color; + } + let [r, g, b, _] = color.to_srgba_unmultiplied(); + let mix = |c: u8, bg: u8| (c as f32 * a + bg as f32 * (1.0 - a)).round() as u8; + Color32::from_rgb( + mix(r, background.r()), + mix(g, background.g()), + mix(b, background.b()), + ) +} + +/// Interpolate two colours in OKLab (unmultiplied), alpha linearly. +pub fn lerp_oklab(a: Color32, b: Color32, k: f32) -> Color32 { + if a == b { + return a; + } + let (la, aa) = (to_oklab(a), a.a() as f32); + let (lb, ab) = (to_oklab(b), b.a() as f32); + let m = [ + la[0] + (lb[0] - la[0]) * k, + la[1] + (lb[1] - la[1]) * k, + la[2] + (lb[2] - la[2]) * k, + ]; + from_oklab(m, (aa + (ab - aa) * k).round() as u8) +} + +fn lin(c: u8) -> f32 { + let c = c as f32 / 255.0; + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } +} + +fn srgb(c: f32) -> u8 { + let c = c.clamp(0.0, 1.0); + let v = if c <= 0.003_130_8 { + c * 12.92 + } else { + 1.055 * c.powf(1.0 / 2.4) - 0.055 + }; + (v * 255.0).round() as u8 +} + +// Björn Ottosson's OKLab matrices, kept at their published precision. +#[allow(clippy::excessive_precision)] +fn to_oklab(c: Color32) -> [f32; 3] { + let [r, g, b, _] = c.to_srgba_unmultiplied(); + let (r, g, b) = (lin(r), lin(g), lin(b)); + let l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b).cbrt(); + let m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b).cbrt(); + let s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b).cbrt(); + [ + 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, + ] +} + +#[allow(clippy::excessive_precision)] +fn from_oklab(c: [f32; 3], a: u8) -> Color32 { + let l = (c[0] + 0.3963377774 * c[1] + 0.2158037573 * c[2]).powi(3); + let m = (c[0] - 0.1055613458 * c[1] - 0.0638541728 * c[2]).powi(3); + let s = (c[0] - 0.0894841775 * c[1] - 1.2914855480 * c[2]).powi(3); + let r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + let g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + let b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; + Color32::from_rgba_unmultiplied(srgb(r), srgb(g), srgb(b), a) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lum(c: Color32) -> f32 { + let [r, g, b, _] = c.to_srgba_unmultiplied(); + 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) + } + + fn ratio(a: Color32, b: Color32) -> f32 { + let (x, y) = (lum(a), lum(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) + } + + fn settings(intensity: f32, color_blind: bool, high_contrast: bool) -> ThemeSettings { + ThemeSettings { + intensity, + color_blind, + high_contrast, + } + } + + #[test] + fn anchors_are_exact() { + assert_eq!(tokens_at(ThemeSettings::default()), ut::STEALTH); + assert_eq!( + tokens_at(settings(NIGHT_DRIVE_INTENSITY, false, false)), + ut::NIGHT + ); + assert_eq!( + tokens_at(settings(0.0, false, false)).bg_panel, + ut::STEALTH.bg_panel + ); + assert_eq!( + tokens_at(settings(100.0, false, false)).bg_panel, + ut::ULTRA.bg_panel + ); + assert_eq!(tokens_at(settings(70.0, true, true)), ut::HC); + } + + /// Every intensity value keeps text at AA (AAA with Color blind mode). + #[test] + fn text_contrast_holds_across_the_slider() { + for cb in [false, true] { + let min = if cb { 7.0 } else { 4.5 }; + for i in 0..=100 { + let t = tokens_at(settings(i as f32, cb, false)); + for ink in [t.ink, t.ink_muted, t.ink_subtle] { + for bg in [ + t.bg_void, + t.bg_base, + t.bg_panel, + t.bg_raised, + t.bg_field, + t.bg_hover, + ] { + let r = ratio(ink, bg); + assert!(r >= min, "intensity {i} cb {cb}: {r:.2}"); + } + } + } + } + } + + #[test] + fn compare_series_dashes_log_b_in_the_same_colour() { + let t = tokens_at(ThemeSettings::default()); + for cvd in [false, true] { + for idx in [0, 3, 9, 12] { + let (a, sa) = compare_series(&t, idx, cvd, false); + let (b, sb) = compare_series(&t, idx, cvd, true); + assert_eq!(a, b); + assert_eq!(a, series(&t, idx, cvd).0); + assert_eq!((sa, sb), (SeriesStroke::Solid, SeriesStroke::Dashed)); + } + } + } + + #[test] + fn data_colors_ignore_intensity() { + for cb in [false, true] { + let base = tokens_at(settings(NIGHT_DRIVE_INTENSITY, cb, false)); + for i in [0.0, 20.0, 50.0, 70.0, 100.0] { + let t = tokens_at(settings(i, cb, false)); + for idx in 0..30 { + assert_eq!(series(&t, idx, cb), series(&base, idx, cb)); + } + for v in [0.0, 0.3, 0.5, 1.0] { + assert_eq!(seq(&t, v), seq(&base, v)); + } + // The diverging ends are data colours. The on-target midpoint `div_2` is a + // neutral that tokens.json tints with the ground (outside Color blind mode). + for idx in [0, 1, 3, 4] { + assert_eq!(div(&t, idx as f32 / 4.0), div(&base, idx as f32 / 4.0)); + } + assert_eq!(t.danger, base.danger); + assert_eq!(t.plot_cursor == t.cyan, base.plot_cursor == base.cyan); + } + } + } + + #[test] + fn no_series_uses_the_cursor_colour() { + let themes = [ + (tokens_at(settings(0.0, false, false)), false), + (tokens_at(settings(35.0, false, false)), false), + (tokens_at(settings(100.0, false, false)), false), + (tokens_at(settings(35.0, true, false)), true), + (ut::HC, true), + (ut::PAPER, true), + (ut::GRAPHITE, true), + ]; + for (t, cvd_like) in themes { + for idx in 0..10 { + assert_ne!(series(&t, idx, cvd_like).0, t.plot_cursor, "series {idx}"); + } + } + } + + #[test] + fn series_laps_change_the_stroke_not_the_colour() { + let t = ut::NIGHT; + assert_eq!(series(&t, 0, false), (t.series_1, SeriesStroke::Solid)); + assert_eq!(series(&t, 9, false), (t.series_10, SeriesStroke::Solid)); + assert_eq!(series(&t, 10, false), (t.series_1, SeriesStroke::Dashed)); + assert_eq!(series(&t, 25, false), (t.series_6, SeriesStroke::Dotted)); + let c = ut::CVD; + assert_eq!(series(&c, 7, true), (c.series_8, SeriesStroke::Solid)); + assert_eq!(series(&c, 8, true), (c.series_1, SeriesStroke::Dashed)); + assert_eq!(series(&c, 16, true), (c.series_1, SeriesStroke::Dotted)); + } + + /// Sequential maps read in greyscale: lightness rises (or, on Paper, falls) monotonically. + #[test] + fn sequential_maps_are_monotonic_in_lightness() { + for t in [ut::NIGHT, ut::CVD, ut::HC, ut::PAPER, ut::GRAPHITE] { + let l: Vec = (0..=50) + .map(|i| to_oklab(seq(&t, i as f32 / 50.0))[0]) + .collect(); + let rising = l.windows(2).all(|w| w[1] >= w[0] - 1e-3); + let falling = l.windows(2).all(|w| w[1] <= w[0] + 1e-3); + assert!(rising || falling, "{l:?}"); + } + } + + #[test] + fn maps_clamp_and_tolerate_nan() { + let t = ut::NIGHT; + assert_eq!(seq(&t, -1.0), t.seq_0); + assert_eq!(seq(&t, 2.0), t.seq_6); + assert_eq!(seq(&t, f32::NAN), t.seq_0); + assert_eq!(div(&t, 0.5), t.div_2); + assert_eq!(on_seq(&t, 1.0), t.on_seq_6); + assert_eq!(on_div(&t, 0.0), t.on_div_0); + } + + #[test] + fn intensity_names_and_detents() { + assert_eq!(intensity_name(0), "Stealth"); + assert_eq!(intensity_name(35), "Night Drive"); + assert_eq!(intensity_name(52), "Night Drive"); + assert_eq!(intensity_name(53), "Neon"); + assert_eq!(intensity_name(100), "Ultra"); + assert_eq!(snap_intensity(33), 35); + assert_eq!(snap_intensity(72), 70); + assert_eq!(snap_intensity(98), 100); + assert_eq!(snap_intensity(50), 50); + } + + #[test] + fn export_tokens_ignore_settings() { + assert_eq!(export_tokens(ExportTheme::Paper), ut::PAPER); + assert_eq!(export_tokens(ExportTheme::Graphite), ut::GRAPHITE); + } +} diff --git a/src/ui/theme/paint.rs b/src/ui/theme/paint.rs new file mode 100644 index 00000000..2a6cf027 --- /dev/null +++ b/src/ui/theme/paint.rs @@ -0,0 +1,215 @@ +//! The custom painters the design system allows. +//! +//! The brief (`design/design-system/6-implementation.md`) keeps custom painting to four small +//! helpers — redline, glow, series bloom, perspective floor — and the plan adds three +//! (`docs/plans/2026-10-23-night-drive-design-system.md`, Decision 2): the focus ring, the plot +//! legend overlay and the major grid. Everything else comes from egui `Visuals`. + +use eframe::egui::{ + self, Color32, CornerRadius, Mesh, Painter, Pos2, Rect, Shape, Stroke, StrokeKind, +}; + +use super::Tokens; +use super::tokens::{radius, stroke}; + +/// Sunset stop positions along the redline (cyan → violet → magenta → orange → gold). +const REDLINE_STOPS: [f32; 5] = [0.0, 0.30, 0.55, 0.80, 1.0]; + +/// The 2px sunset redline along the top edge of `rect` (windows, welcome screen, export +/// headers). Flat `line_control` hairline when the theme turns the gradient off (Stealth, High +/// contrast). `inset` keeps it inside rounded corners. +pub fn redline(painter: &Painter, rect: Rect, t: &Tokens, inset: f32) { + let left = rect.left() + inset; + let right = rect.right() - inset; + if right <= left { + return; + } + let height = stroke::STROKE_INDICATOR; + let top = rect.top(); + if !t.fx.redline_gradient { + painter.rect_filled( + Rect::from_min_max(Pos2::new(left, top), Pos2::new(right, top + height)), + CornerRadius::ZERO, + t.line_control, + ); + return; + } + let colors = [ + t.redline_0, + t.redline_1, + t.redline_2, + t.redline_3, + t.redline_4, + ]; + let mut mesh = Mesh::default(); + for (stop, color) in REDLINE_STOPS.iter().zip(colors) { + let x = left + (right - left) * stop; + mesh.colored_vertex(Pos2::new(x, top), color); + mesh.colored_vertex(Pos2::new(x, top + height), color); + } + for i in 0..(REDLINE_STOPS.len() as u32 - 1) { + let a = i * 2; + mesh.add_triangle(a, a + 1, a + 2); + mesh.add_triangle(a + 1, a + 3, a + 2); + } + painter.add(Shape::mesh(mesh)); +} + +/// A glow behind `rect` in `color` (primary button hover, active mode-nav bar). `None` at +/// intensities that turn glows off. +pub fn glow(t: &Tokens, rect: Rect, corner_radius: u8, color: Color32) -> Option { + super::glow(t, color).map(|shadow| Shape::Rect(shadow.as_shape(rect, corner_radius))) +} + +/// Keyboard focus: a ground-coloured gap, then a solid `cyan` ring (5px in High contrast). +pub fn focus_ring(painter: &Painter, rect: Rect, corner_radius: f32, t: &Tokens, wide: bool) { + let gap = 2.0; + let ring = if wide { 3.0 } else { 2.0 }; + let r = |extra: f32| CornerRadius::same((corner_radius + extra).round().min(255.0) as u8); + painter.rect_stroke( + rect.expand(gap / 2.0), + r(gap / 2.0), + Stroke::new(gap, t.bg_void), + StrokeKind::Middle, + ); + painter.rect_stroke( + rect.expand(gap + ring / 2.0), + r(gap + ring / 2.0), + Stroke::new(ring, t.cyan), + StrokeKind::Middle, + ); +} + +const KEYBOARD_FOCUS: &str = "ultralog_keyboard_focus"; + +/// Paint the focus ring around whichever widget has keyboard focus, on a foreground layer so +/// every control gets the same ring without its own code. Call once per frame, after the UI. +/// +/// The ring shows only after the keyboard moved focus (Tab, arrows) or on a text field, and +/// hides again on the next pointer press, so a click never leaves a ring behind. +pub fn keyboard_focus_ring(ctx: &egui::Context, t: &Tokens, high_contrast: bool) { + let id = egui::Id::new(KEYBOARD_FOCUS); + let (tabbed, pressed) = ctx.input(|i| { + ( + i.key_pressed(egui::Key::Tab) + || i.key_pressed(egui::Key::ArrowDown) + || i.key_pressed(egui::Key::ArrowUp), + i.pointer.any_pressed(), + ) + }); + let mut keyboard = ctx.data(|d| d.get_temp::(id)).unwrap_or(false); + if tabbed { + keyboard = true; + } else if pressed { + keyboard = false; + } + ctx.data_mut(|d| d.insert_temp(id, keyboard)); + + let Some(focused) = ctx.memory(|m| m.focused()) else { + return; + }; + let Some(response) = ctx.read_response(focused) else { + return; + }; + let is_text = egui::TextEdit::load_state(ctx, focused).is_some(); + if !(keyboard || is_text) || !response.rect.is_positive() { + return; + } + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("ultralog_focus_ring"), + )); + let painter = painter.with_clip_rect(response.interact_rect.expand(8.0)); + focus_ring( + &painter, + response.rect, + f32::from(radius::RADIUS_SM), + t, + high_contrast, + ); +} + +/// The synthwave perspective floor: a horizon on the top edge of `rect`, lines fanning from a +/// vanishing point above it to the bottom edge, and horizontals that close up toward the +/// horizon. Returned as shapes so the caller can place them under its content (drop zone; plots +/// at Ultra). Everything is clipped to `rect`. +pub fn floor(rect: Rect, color: Color32) -> Vec { + if color.a() == 0 || !rect.is_positive() { + return Vec::new(); + } + let s = Stroke::new(stroke::STROKE_HAIRLINE, color); + let horizon = rect.top(); + let vanish = Pos2::new(rect.center().x, horizon - rect.height() * 0.9); + let mut shapes = vec![Shape::line_segment( + [ + Pos2::new(rect.left(), horizon), + Pos2::new(rect.right(), horizon), + ], + s, + )]; + let fan = 12; + for i in 0..=fan { + let x = rect.left() - rect.width() * 0.75 + rect.width() * 2.5 * i as f32 / fan as f32; + let end = Pos2::new(x, rect.bottom()); + // Start where the ray from the vanishing point crosses the horizon. + let k = (horizon - vanish.y) / (end.y - vanish.y); + let start = vanish + (end - vanish) * k; + if let Some(segment) = clip_x([start, end], rect.left(), rect.right()) { + shapes.push(Shape::line_segment(segment, s)); + } + } + let rows = 4; + for i in 1..=rows { + // Quadratic spacing: rows spread out toward the viewer like a receding floor. + let k = (i as f32 / rows as f32).powi(2); + let y = horizon + (rect.bottom() - horizon) * k; + shapes.push(Shape::line_segment( + [Pos2::new(rect.left(), y), Pos2::new(rect.right(), y)], + s, + )); + } + shapes +} + +/// Clip a segment to the vertical band `left..=right`. +fn clip_x([a, b]: [Pos2; 2], left: f32, right: f32) -> Option<[Pos2; 2]> { + let (mut t0, mut t1) = (0.0f32, 1.0f32); + let dx = b.x - a.x; + for (p, q) in [(-dx, a.x - left), (dx, right - a.x)] { + if p == 0.0 { + if q < 0.0 { + return None; + } + } else { + let r = q / p; + if p < 0.0 { + t0 = t0.max(r); + } else { + t1 = t1.min(r); + } + } + } + (t0 <= t1).then(|| [a + (b - a) * t0, a + (b - a) * t1]) +} + +/// A dashed outline around `rect` with rounded corners (drop zone, low-confidence badges). +pub fn dashed_rect(painter: &Painter, rect: Rect, corner_radius: f32, stroke: Stroke, dash: f32) { + let r = corner_radius + .min(rect.width() / 2.0) + .min(rect.height() / 2.0); + let corners = [ + (Pos2::new(rect.right() - r, rect.top() + r), -90.0f32), + (Pos2::new(rect.right() - r, rect.bottom() - r), 0.0), + (Pos2::new(rect.left() + r, rect.bottom() - r), 90.0), + (Pos2::new(rect.left() + r, rect.top() + r), 180.0), + ]; + let mut path = vec![Pos2::new(rect.left() + r, rect.top())]; + for (center, start) in corners { + for step in 0..=6 { + let a = (start + 15.0 * step as f32).to_radians(); + path.push(center + egui::vec2(a.cos(), a.sin()) * r); + } + } + path.push(Pos2::new(rect.left() + r, rect.top())); + painter.extend(Shape::dashed_line(&path, stroke, dash, dash * 0.75)); +} diff --git a/src/ui/theme/tokens.rs b/src/ui/theme/tokens.rs new file mode 100644 index 00000000..a47bc780 --- /dev/null +++ b/src/ui/theme/tokens.rs @@ -0,0 +1,729 @@ +//! UltraLog design tokens — generated from `design/tokens/tokens.json` by +//! `cargo run --example gen_theme`. Do not edit by hand. +//! Seven anchor themes. The live theme is built by `super::tokens_at`. +use eframe::egui::Color32; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Effects { + /// Blur radius of glow-cyan / glow-magenta (px). 0 = no glow. + pub glow_blur: f32, + /// Alpha of the glow colour. + pub glow_alpha: f32, + /// Width of the translucent bloom drawn under series lines (px). 0 = off. + pub bloom: f32, + /// Draw the sunset redline as a gradient (false = flat line-control hairline). + pub redline_gradient: bool, + /// Draw the perspective floor inside plots. + pub plot_floor: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Tokens { + pub bg_void: Color32, + pub bg_base: Color32, + pub bg_panel: Color32, + pub bg_raised: Color32, + pub bg_field: Color32, + pub bg_hover: Color32, + pub bg_active: Color32, + pub line_subtle: Color32, + pub line: Color32, + pub line_control: Color32, + pub ink: Color32, + pub ink_muted: Color32, + pub ink_subtle: Color32, + pub ink_disabled: Color32, + pub cyan: Color32, + pub cyan_soft: Color32, + pub on_cyan: Color32, + pub magenta: Color32, + pub magenta_soft: Color32, + pub on_magenta: Color32, + pub violet: Color32, + pub orange: Color32, + pub gold: Color32, + pub redline_0: Color32, + pub redline_1: Color32, + pub redline_2: Color32, + pub redline_3: Color32, + pub redline_4: Color32, + pub grid_floor: Color32, + pub success: Color32, + pub success_soft: Color32, + pub warning: Color32, + pub warning_soft: Color32, + pub danger: Color32, + pub danger_soft: Color32, + pub info: Color32, + pub series_1: Color32, + pub series_2: Color32, + pub series_3: Color32, + pub series_4: Color32, + pub series_5: Color32, + pub series_6: Color32, + pub series_7: Color32, + pub series_8: Color32, + pub series_9: Color32, + pub series_10: Color32, + pub plot_bg: Color32, + pub plot_grid: Color32, + pub plot_grid_major: Color32, + pub plot_axis: Color32, + pub plot_cursor: Color32, + pub plot_selection: Color32, + pub plot_legend_bg: Color32, + pub plot_floor: Color32, + pub seq_0: Color32, + pub seq_1: Color32, + pub seq_2: Color32, + pub seq_3: Color32, + pub seq_4: Color32, + pub seq_5: Color32, + pub seq_6: Color32, + pub on_seq_0: Color32, + pub on_seq_1: Color32, + pub on_seq_2: Color32, + pub on_seq_3: Color32, + pub on_seq_4: Color32, + pub on_seq_5: Color32, + pub on_seq_6: Color32, + pub div_0: Color32, + pub div_1: Color32, + pub div_2: Color32, + pub div_3: Color32, + pub div_4: Color32, + pub on_div_0: Color32, + pub on_div_1: Color32, + pub on_div_2: Color32, + pub on_div_3: Color32, + pub on_div_4: Color32, + pub cell_empty: Color32, + pub cell_plate: Color32, + pub fx: Effects, +} + +pub const NIGHT: Tokens = Tokens { + bg_void: Color32::from_rgb(8, 7, 15), + bg_base: Color32::from_rgb(15, 13, 26), + bg_panel: Color32::from_rgb(22, 19, 39), + bg_raised: Color32::from_rgb(30, 26, 51), + bg_field: Color32::from_rgb(12, 11, 22), + bg_hover: Color32::from_rgb(39, 34, 65), + bg_active: Color32::from_rgb(50, 43, 85), + line_subtle: Color32::from_rgb(35, 31, 58), + line: Color32::from_rgb(47, 42, 75), + line_control: Color32::from_rgb(111, 103, 153), + ink: Color32::from_rgb(243, 240, 255), + ink_muted: Color32::from_rgb(179, 171, 214), + ink_subtle: Color32::from_rgb(143, 136, 179), + ink_disabled: Color32::from_rgb(90, 84, 120), + cyan: Color32::from_rgb(45, 226, 255), + cyan_soft: Color32::from_rgb(13, 47, 59), + on_cyan: Color32::from_rgb(4, 19, 26), + magenta: Color32::from_rgb(255, 92, 173), + magenta_soft: Color32::from_rgb(58, 16, 48), + on_magenta: Color32::from_rgb(26, 4, 16), + violet: Color32::from_rgb(166, 138, 255), + orange: Color32::from_rgb(255, 140, 66), + gold: Color32::from_rgb(255, 210, 63), + redline_0: Color32::from_rgb(45, 226, 255), + redline_1: Color32::from_rgb(166, 138, 255), + redline_2: Color32::from_rgb(255, 92, 173), + redline_3: Color32::from_rgb(255, 140, 66), + redline_4: Color32::from_rgb(255, 210, 63), + grid_floor: Color32::from_rgb(47, 42, 75), + success: Color32::from_rgb(62, 224, 143), + success_soft: Color32::from_rgb(15, 51, 36), + warning: Color32::from_rgb(255, 197, 61), + warning_soft: Color32::from_rgb(58, 44, 8), + danger: Color32::from_rgb(255, 92, 108), + danger_soft: Color32::from_rgb(61, 15, 23), + info: Color32::from_rgb(45, 226, 255), + series_1: Color32::from_rgb(255, 92, 173), + series_2: Color32::from_rgb(255, 210, 63), + series_3: Color32::from_rgb(91, 168, 255), + series_4: Color32::from_rgb(163, 230, 90), + series_5: Color32::from_rgb(255, 140, 66), + series_6: Color32::from_rgb(166, 138, 255), + series_7: Color32::from_rgb(62, 224, 192), + series_8: Color32::from_rgb(233, 216, 176), + series_9: Color32::from_rgb(255, 123, 123), + series_10: Color32::from_rgb(201, 196, 220), + plot_bg: Color32::from_rgb(8, 7, 15), + plot_grid: Color32::from_rgb(26, 23, 48), + plot_grid_major: Color32::from_rgb(44, 39, 71), + plot_axis: Color32::from_rgb(143, 136, 179), + plot_cursor: Color32::from_rgb(45, 226, 255), + plot_selection: Color32::from_rgba_premultiplied(6, 32, 36, 36), + plot_legend_bg: Color32::from_rgba_premultiplied(20, 17, 35, 230), + plot_floor: Color32::from_rgba_premultiplied(0, 0, 0, 0), + seq_0: Color32::from_rgb(42, 29, 92), + seq_1: Color32::from_rgb(79, 33, 131), + seq_2: Color32::from_rgb(139, 42, 155), + seq_3: Color32::from_rgb(224, 71, 154), + seq_4: Color32::from_rgb(255, 99, 99), + seq_5: Color32::from_rgb(255, 157, 66), + seq_6: Color32::from_rgb(255, 226, 90), + on_seq_0: Color32::from_rgb(243, 240, 255), + on_seq_1: Color32::from_rgb(243, 240, 255), + on_seq_2: Color32::from_rgb(243, 240, 255), + on_seq_3: Color32::from_rgb(12, 11, 22), + on_seq_4: Color32::from_rgb(12, 11, 22), + on_seq_5: Color32::from_rgb(12, 11, 22), + on_seq_6: Color32::from_rgb(12, 11, 22), + div_0: Color32::from_rgb(54, 200, 255), + div_1: Color32::from_rgb(30, 106, 143), + div_2: Color32::from_rgb(43, 38, 64), + div_3: Color32::from_rgb(154, 74, 44), + div_4: Color32::from_rgb(255, 122, 61), + on_div_0: Color32::from_rgb(12, 11, 22), + on_div_1: Color32::from_rgb(243, 240, 255), + on_div_2: Color32::from_rgb(243, 240, 255), + on_div_3: Color32::from_rgb(243, 240, 255), + on_div_4: Color32::from_rgb(12, 11, 22), + cell_empty: Color32::from_rgb(22, 19, 39), + cell_plate: Color32::from_rgba_premultiplied(0, 0, 0, 0), + fx: Effects { glow_blur: 16.0, glow_alpha: 0.35, bloom: 0.0, redline_gradient: true, plot_floor: false }, +}; + +pub const STEALTH: Tokens = Tokens { + bg_void: Color32::from_rgb(10, 10, 12), + bg_base: Color32::from_rgb(17, 17, 19), + bg_panel: Color32::from_rgb(23, 23, 26), + bg_raised: Color32::from_rgb(30, 30, 34), + bg_field: Color32::from_rgb(13, 13, 15), + bg_hover: Color32::from_rgb(38, 38, 43), + bg_active: Color32::from_rgb(48, 48, 54), + line_subtle: Color32::from_rgb(34, 34, 38), + line: Color32::from_rgb(44, 44, 49), + line_control: Color32::from_rgb(108, 108, 118), + ink: Color32::from_rgb(242, 242, 245), + ink_muted: Color32::from_rgb(180, 180, 189), + ink_subtle: Color32::from_rgb(143, 143, 153), + ink_disabled: Color32::from_rgb(86, 86, 94), + cyan: Color32::from_rgb(92, 200, 220), + cyan_soft: Color32::from_rgb(19, 42, 48), + on_cyan: Color32::from_rgb(6, 20, 23), + magenta: Color32::from_rgb(255, 92, 173), + magenta_soft: Color32::from_rgb(58, 16, 48), + on_magenta: Color32::from_rgb(26, 4, 16), + violet: Color32::from_rgb(166, 138, 255), + orange: Color32::from_rgb(255, 140, 66), + gold: Color32::from_rgb(255, 210, 63), + redline_0: Color32::from_rgb(108, 108, 118), + redline_1: Color32::from_rgb(108, 108, 118), + redline_2: Color32::from_rgb(108, 108, 118), + redline_3: Color32::from_rgb(108, 108, 118), + redline_4: Color32::from_rgb(108, 108, 118), + grid_floor: Color32::from_rgb(34, 34, 38), + success: Color32::from_rgb(62, 224, 143), + success_soft: Color32::from_rgb(15, 51, 36), + warning: Color32::from_rgb(255, 197, 61), + warning_soft: Color32::from_rgb(58, 44, 8), + danger: Color32::from_rgb(255, 92, 108), + danger_soft: Color32::from_rgb(61, 15, 23), + info: Color32::from_rgb(92, 200, 220), + series_1: Color32::from_rgb(255, 92, 173), + series_2: Color32::from_rgb(255, 210, 63), + series_3: Color32::from_rgb(91, 168, 255), + series_4: Color32::from_rgb(163, 230, 90), + series_5: Color32::from_rgb(255, 140, 66), + series_6: Color32::from_rgb(166, 138, 255), + series_7: Color32::from_rgb(62, 224, 192), + series_8: Color32::from_rgb(233, 216, 176), + series_9: Color32::from_rgb(255, 123, 123), + series_10: Color32::from_rgb(201, 196, 220), + plot_bg: Color32::from_rgb(10, 10, 12), + plot_grid: Color32::from_rgb(24, 24, 28), + plot_grid_major: Color32::from_rgb(42, 42, 48), + plot_axis: Color32::from_rgb(143, 143, 153), + plot_cursor: Color32::from_rgb(92, 200, 220), + plot_selection: Color32::from_rgba_premultiplied(12, 27, 29, 34), + plot_legend_bg: Color32::from_rgba_premultiplied(21, 21, 23, 230), + plot_floor: Color32::from_rgba_premultiplied(0, 0, 0, 0), + seq_0: Color32::from_rgb(42, 29, 92), + seq_1: Color32::from_rgb(79, 33, 131), + seq_2: Color32::from_rgb(139, 42, 155), + seq_3: Color32::from_rgb(224, 71, 154), + seq_4: Color32::from_rgb(255, 99, 99), + seq_5: Color32::from_rgb(255, 157, 66), + seq_6: Color32::from_rgb(255, 226, 90), + on_seq_0: Color32::from_rgb(242, 242, 245), + on_seq_1: Color32::from_rgb(242, 242, 245), + on_seq_2: Color32::from_rgb(242, 242, 245), + on_seq_3: Color32::from_rgb(13, 13, 15), + on_seq_4: Color32::from_rgb(13, 13, 15), + on_seq_5: Color32::from_rgb(13, 13, 15), + on_seq_6: Color32::from_rgb(13, 13, 15), + div_0: Color32::from_rgb(54, 200, 255), + div_1: Color32::from_rgb(30, 106, 143), + div_2: Color32::from_rgb(41, 41, 47), + div_3: Color32::from_rgb(154, 74, 44), + div_4: Color32::from_rgb(255, 122, 61), + on_div_0: Color32::from_rgb(13, 13, 15), + on_div_1: Color32::from_rgb(242, 242, 245), + on_div_2: Color32::from_rgb(242, 242, 245), + on_div_3: Color32::from_rgb(242, 242, 245), + on_div_4: Color32::from_rgb(13, 13, 15), + cell_empty: Color32::from_rgb(23, 23, 26), + cell_plate: Color32::from_rgba_premultiplied(0, 0, 0, 0), + fx: Effects { glow_blur: 0.0, glow_alpha: 0.0, bloom: 0.0, redline_gradient: false, plot_floor: false }, +}; + +pub const ULTRA: Tokens = Tokens { + bg_void: Color32::from_rgb(6, 2, 14), + bg_base: Color32::from_rgb(14, 6, 32), + bg_panel: Color32::from_rgb(22, 10, 46), + bg_raised: Color32::from_rgb(32, 16, 61), + bg_field: Color32::from_rgb(10, 4, 24), + bg_hover: Color32::from_rgb(43, 22, 80), + bg_active: Color32::from_rgb(55, 29, 99), + line_subtle: Color32::from_rgb(42, 21, 72), + line: Color32::from_rgb(62, 32, 104), + line_control: Color32::from_rgb(142, 104, 200), + ink: Color32::from_rgb(251, 244, 255), + ink_muted: Color32::from_rgb(205, 182, 239), + ink_subtle: Color32::from_rgb(169, 144, 214), + ink_disabled: Color32::from_rgb(94, 70, 136), + cyan: Color32::from_rgb(61, 240, 255), + cyan_soft: Color32::from_rgb(11, 50, 67), + on_cyan: Color32::from_rgb(4, 19, 26), + magenta: Color32::from_rgb(255, 61, 165), + magenta_soft: Color32::from_rgb(58, 16, 48), + on_magenta: Color32::from_rgb(26, 4, 16), + violet: Color32::from_rgb(180, 140, 255), + orange: Color32::from_rgb(255, 140, 66), + gold: Color32::from_rgb(255, 210, 63), + redline_0: Color32::from_rgb(61, 240, 255), + redline_1: Color32::from_rgb(180, 140, 255), + redline_2: Color32::from_rgb(255, 61, 165), + redline_3: Color32::from_rgb(255, 140, 66), + redline_4: Color32::from_rgb(255, 210, 63), + grid_floor: Color32::from_rgba_premultiplied(115, 41, 78, 115), + success: Color32::from_rgb(62, 224, 143), + success_soft: Color32::from_rgb(15, 51, 36), + warning: Color32::from_rgb(255, 197, 61), + warning_soft: Color32::from_rgb(58, 44, 8), + danger: Color32::from_rgb(255, 92, 108), + danger_soft: Color32::from_rgb(61, 15, 23), + info: Color32::from_rgb(61, 240, 255), + series_1: Color32::from_rgb(255, 92, 173), + series_2: Color32::from_rgb(255, 210, 63), + series_3: Color32::from_rgb(91, 168, 255), + series_4: Color32::from_rgb(163, 230, 90), + series_5: Color32::from_rgb(255, 140, 66), + series_6: Color32::from_rgb(166, 138, 255), + series_7: Color32::from_rgb(62, 224, 192), + series_8: Color32::from_rgb(233, 216, 176), + series_9: Color32::from_rgb(255, 123, 123), + series_10: Color32::from_rgb(201, 196, 220), + plot_bg: Color32::from_rgb(6, 2, 14), + plot_grid: Color32::from_rgb(29, 13, 54), + plot_grid_major: Color32::from_rgb(59, 26, 98), + plot_axis: Color32::from_rgb(169, 144, 214), + plot_cursor: Color32::from_rgb(61, 240, 255), + plot_selection: Color32::from_rgba_premultiplied(11, 43, 46, 46), + plot_legend_bg: Color32::from_rgba_premultiplied(20, 9, 41, 230), + plot_floor: Color32::from_rgba_premultiplied(56, 13, 36, 56), + seq_0: Color32::from_rgb(42, 29, 92), + seq_1: Color32::from_rgb(79, 33, 131), + seq_2: Color32::from_rgb(139, 42, 155), + seq_3: Color32::from_rgb(224, 71, 154), + seq_4: Color32::from_rgb(255, 99, 99), + seq_5: Color32::from_rgb(255, 157, 66), + seq_6: Color32::from_rgb(255, 226, 90), + on_seq_0: Color32::from_rgb(251, 244, 255), + on_seq_1: Color32::from_rgb(251, 244, 255), + on_seq_2: Color32::from_rgb(251, 244, 255), + on_seq_3: Color32::from_rgb(10, 4, 24), + on_seq_4: Color32::from_rgb(10, 4, 24), + on_seq_5: Color32::from_rgb(10, 4, 24), + on_seq_6: Color32::from_rgb(10, 4, 24), + div_0: Color32::from_rgb(54, 200, 255), + div_1: Color32::from_rgb(30, 106, 143), + div_2: Color32::from_rgb(42, 24, 70), + div_3: Color32::from_rgb(154, 74, 44), + div_4: Color32::from_rgb(255, 122, 61), + on_div_0: Color32::from_rgb(10, 4, 24), + on_div_1: Color32::from_rgb(251, 244, 255), + on_div_2: Color32::from_rgb(251, 244, 255), + on_div_3: Color32::from_rgb(251, 244, 255), + on_div_4: Color32::from_rgb(10, 4, 24), + cell_empty: Color32::from_rgb(22, 10, 46), + cell_plate: Color32::from_rgba_premultiplied(0, 0, 0, 0), + fx: Effects { glow_blur: 22.0, glow_alpha: 0.6, bloom: 4.0, redline_gradient: true, plot_floor: true }, +}; + +pub const CVD: Tokens = Tokens { + bg_void: Color32::from_rgb(8, 7, 15), + bg_base: Color32::from_rgb(15, 13, 26), + bg_panel: Color32::from_rgb(22, 19, 39), + bg_raised: Color32::from_rgb(30, 26, 51), + bg_field: Color32::from_rgb(12, 11, 22), + bg_hover: Color32::from_rgb(39, 34, 65), + bg_active: Color32::from_rgb(50, 43, 85), + line_subtle: Color32::from_rgb(35, 31, 58), + line: Color32::from_rgb(47, 42, 75), + line_control: Color32::from_rgb(141, 134, 184), + ink: Color32::from_rgb(243, 240, 255), + ink_muted: Color32::from_rgb(205, 199, 234), + ink_subtle: Color32::from_rgb(188, 181, 222), + ink_disabled: Color32::from_rgb(90, 84, 120), + cyan: Color32::from_rgb(45, 226, 255), + cyan_soft: Color32::from_rgb(13, 47, 59), + on_cyan: Color32::from_rgb(4, 19, 26), + magenta: Color32::from_rgb(255, 148, 201), + magenta_soft: Color32::from_rgb(58, 16, 48), + on_magenta: Color32::from_rgb(26, 4, 16), + violet: Color32::from_rgb(166, 138, 255), + orange: Color32::from_rgb(255, 140, 66), + gold: Color32::from_rgb(255, 210, 63), + redline_0: Color32::from_rgb(45, 226, 255), + redline_1: Color32::from_rgb(166, 138, 255), + redline_2: Color32::from_rgb(255, 148, 201), + redline_3: Color32::from_rgb(255, 140, 66), + redline_4: Color32::from_rgb(255, 210, 63), + grid_floor: Color32::from_rgb(47, 42, 75), + success: Color32::from_rgb(124, 200, 255), + success_soft: Color32::from_rgb(11, 40, 64), + warning: Color32::from_rgb(245, 232, 90), + warning_soft: Color32::from_rgb(47, 43, 6), + danger: Color32::from_rgb(255, 162, 94), + danger_soft: Color32::from_rgb(58, 29, 5), + info: Color32::from_rgb(212, 201, 255), + series_1: Color32::from_rgb(86, 180, 233), + series_2: Color32::from_rgb(230, 159, 0), + series_3: Color32::from_rgb(31, 191, 143), + series_4: Color32::from_rgb(240, 228, 66), + series_5: Color32::from_rgb(47, 134, 208), + series_6: Color32::from_rgb(232, 105, 42), + series_7: Color32::from_rgb(217, 133, 184), + series_8: Color32::from_rgb(143, 138, 158), + series_9: Color32::from_rgb(86, 180, 233), + series_10: Color32::from_rgb(230, 159, 0), + plot_bg: Color32::from_rgb(8, 7, 15), + plot_grid: Color32::from_rgb(26, 23, 48), + plot_grid_major: Color32::from_rgb(44, 39, 71), + plot_axis: Color32::from_rgb(188, 181, 222), + plot_cursor: Color32::from_rgb(243, 240, 255), + plot_selection: Color32::from_rgba_premultiplied(30, 29, 31, 31), + plot_legend_bg: Color32::from_rgba_premultiplied(20, 17, 35, 230), + plot_floor: Color32::from_rgba_premultiplied(0, 0, 0, 0), + seq_0: Color32::from_rgb(18, 58, 115), + seq_1: Color32::from_rgb(52, 80, 127), + seq_2: Color32::from_rgb(94, 102, 118), + seq_3: Color32::from_rgb(131, 127, 119), + seq_4: Color32::from_rgb(169, 158, 114), + seq_5: Color32::from_rgb(209, 189, 94), + seq_6: Color32::from_rgb(253, 231, 74), + on_seq_0: Color32::from_rgb(243, 240, 255), + on_seq_1: Color32::from_rgb(243, 240, 255), + on_seq_2: Color32::from_rgb(243, 240, 255), + on_seq_3: Color32::from_rgb(243, 240, 255), + on_seq_4: Color32::from_rgb(243, 240, 255), + on_seq_5: Color32::from_rgb(243, 240, 255), + on_seq_6: Color32::from_rgb(243, 240, 255), + div_0: Color32::from_rgb(74, 163, 239), + div_1: Color32::from_rgb(38, 90, 140), + div_2: Color32::from_rgb(51, 49, 63), + div_3: Color32::from_rgb(138, 92, 20), + div_4: Color32::from_rgb(230, 159, 0), + on_div_0: Color32::from_rgb(243, 240, 255), + on_div_1: Color32::from_rgb(243, 240, 255), + on_div_2: Color32::from_rgb(243, 240, 255), + on_div_3: Color32::from_rgb(243, 240, 255), + on_div_4: Color32::from_rgb(243, 240, 255), + cell_empty: Color32::from_rgb(22, 19, 39), + cell_plate: Color32::from_rgba_premultiplied(11, 10, 19, 224), + fx: Effects { glow_blur: 16.0, glow_alpha: 0.35, bloom: 0.0, redline_gradient: true, plot_floor: false }, +}; + +pub const HC: Tokens = Tokens { + bg_void: Color32::from_rgb(0, 0, 0), + bg_base: Color32::from_rgb(0, 0, 0), + bg_panel: Color32::from_rgb(11, 11, 15), + bg_raised: Color32::from_rgb(18, 18, 24), + bg_field: Color32::from_rgb(0, 0, 0), + bg_hover: Color32::from_rgb(36, 36, 44), + bg_active: Color32::from_rgb(51, 51, 61), + line_subtle: Color32::from_rgb(92, 92, 102), + line: Color32::from_rgb(154, 154, 166), + line_control: Color32::from_rgb(208, 208, 218), + ink: Color32::from_rgb(255, 255, 255), + ink_muted: Color32::from_rgb(230, 227, 242), + ink_subtle: Color32::from_rgb(210, 206, 226), + ink_disabled: Color32::from_rgb(138, 134, 150), + cyan: Color32::from_rgb(61, 232, 255), + cyan_soft: Color32::from_rgb(0, 50, 61), + on_cyan: Color32::from_rgb(0, 0, 0), + magenta: Color32::from_rgb(255, 133, 198), + magenta_soft: Color32::from_rgb(61, 10, 40), + on_magenta: Color32::from_rgb(0, 0, 0), + violet: Color32::from_rgb(196, 176, 255), + orange: Color32::from_rgb(255, 172, 112), + gold: Color32::from_rgb(255, 224, 102), + redline_0: Color32::from_rgb(61, 232, 255), + redline_1: Color32::from_rgb(196, 176, 255), + redline_2: Color32::from_rgb(255, 133, 198), + redline_3: Color32::from_rgb(255, 172, 112), + redline_4: Color32::from_rgb(255, 224, 102), + grid_floor: Color32::from_rgb(92, 92, 102), + success: Color32::from_rgb(124, 200, 255), + success_soft: Color32::from_rgb(0, 42, 69), + warning: Color32::from_rgb(255, 241, 118), + warning_soft: Color32::from_rgb(46, 41, 0), + danger: Color32::from_rgb(255, 162, 94), + danger_soft: Color32::from_rgb(58, 26, 0), + info: Color32::from_rgb(224, 214, 255), + series_1: Color32::from_rgb(124, 200, 255), + series_2: Color32::from_rgb(255, 179, 26), + series_3: Color32::from_rgb(61, 219, 168), + series_4: Color32::from_rgb(255, 241, 118), + series_5: Color32::from_rgb(90, 166, 255), + series_6: Color32::from_rgb(255, 138, 74), + series_7: Color32::from_rgb(240, 160, 208), + series_8: Color32::from_rgb(196, 196, 208), + series_9: Color32::from_rgb(124, 200, 255), + series_10: Color32::from_rgb(255, 179, 26), + plot_bg: Color32::from_rgb(0, 0, 0), + plot_grid: Color32::from_rgb(26, 26, 32), + plot_grid_major: Color32::from_rgb(74, 74, 85), + plot_axis: Color32::from_rgb(210, 206, 226), + plot_cursor: Color32::from_rgb(255, 255, 255), + plot_selection: Color32::from_rgba_premultiplied(38, 38, 38, 38), + plot_legend_bg: Color32::from_rgba_premultiplied(0, 0, 0, 242), + plot_floor: Color32::from_rgba_premultiplied(0, 0, 0, 0), + seq_0: Color32::from_rgb(18, 58, 115), + seq_1: Color32::from_rgb(52, 80, 127), + seq_2: Color32::from_rgb(94, 102, 118), + seq_3: Color32::from_rgb(131, 127, 119), + seq_4: Color32::from_rgb(169, 158, 114), + seq_5: Color32::from_rgb(209, 189, 94), + seq_6: Color32::from_rgb(253, 231, 74), + on_seq_0: Color32::from_rgb(255, 255, 255), + on_seq_1: Color32::from_rgb(255, 255, 255), + on_seq_2: Color32::from_rgb(255, 255, 255), + on_seq_3: Color32::from_rgb(255, 255, 255), + on_seq_4: Color32::from_rgb(255, 255, 255), + on_seq_5: Color32::from_rgb(255, 255, 255), + on_seq_6: Color32::from_rgb(255, 255, 255), + div_0: Color32::from_rgb(74, 163, 239), + div_1: Color32::from_rgb(38, 90, 140), + div_2: Color32::from_rgb(44, 44, 52), + div_3: Color32::from_rgb(138, 92, 20), + div_4: Color32::from_rgb(230, 159, 0), + on_div_0: Color32::from_rgb(255, 255, 255), + on_div_1: Color32::from_rgb(255, 255, 255), + on_div_2: Color32::from_rgb(255, 255, 255), + on_div_3: Color32::from_rgb(255, 255, 255), + on_div_4: Color32::from_rgb(255, 255, 255), + cell_empty: Color32::from_rgb(11, 11, 15), + cell_plate: Color32::from_rgba_premultiplied(0, 0, 0, 235), + fx: Effects { glow_blur: 0.0, glow_alpha: 0.0, bloom: 0.0, redline_gradient: false, plot_floor: false }, +}; + +pub const PAPER: Tokens = Tokens { + bg_void: Color32::from_rgb(255, 255, 255), + bg_base: Color32::from_rgb(255, 255, 255), + bg_panel: Color32::from_rgb(245, 244, 249), + bg_raised: Color32::from_rgb(255, 255, 255), + bg_field: Color32::from_rgb(255, 255, 255), + bg_hover: Color32::from_rgb(236, 235, 243), + bg_active: Color32::from_rgb(226, 223, 238), + line_subtle: Color32::from_rgb(236, 235, 242), + line: Color32::from_rgb(214, 211, 226), + line_control: Color32::from_rgb(134, 128, 153), + ink: Color32::from_rgb(21, 18, 31), + ink_muted: Color32::from_rgb(71, 66, 89), + ink_subtle: Color32::from_rgb(95, 89, 117), + ink_disabled: Color32::from_rgb(172, 167, 189), + cyan: Color32::from_rgb(0, 107, 128), + cyan_soft: Color32::from_rgb(225, 246, 250), + on_cyan: Color32::from_rgb(255, 255, 255), + magenta: Color32::from_rgb(184, 19, 106), + magenta_soft: Color32::from_rgb(251, 230, 241), + on_magenta: Color32::from_rgb(255, 255, 255), + violet: Color32::from_rgb(95, 69, 201), + orange: Color32::from_rgb(181, 83, 15), + gold: Color32::from_rgb(140, 100, 0), + redline_0: Color32::from_rgb(0, 107, 128), + redline_1: Color32::from_rgb(95, 69, 201), + redline_2: Color32::from_rgb(184, 19, 106), + redline_3: Color32::from_rgb(181, 83, 15), + redline_4: Color32::from_rgb(140, 100, 0), + grid_floor: Color32::from_rgb(214, 211, 226), + success: Color32::from_rgb(11, 106, 168), + success_soft: Color32::from_rgb(227, 241, 251), + warning: Color32::from_rgb(115, 92, 0), + warning_soft: Color32::from_rgb(251, 244, 214), + danger: Color32::from_rgb(176, 63, 0), + danger_soft: Color32::from_rgb(253, 235, 224), + info: Color32::from_rgb(95, 69, 201), + series_1: Color32::from_rgb(42, 131, 189), + series_2: Color32::from_rgb(181, 111, 0), + series_3: Color32::from_rgb(0, 132, 95), + series_4: Color32::from_rgb(133, 120, 0), + series_5: Color32::from_rgb(0, 96, 158), + series_6: Color32::from_rgb(192, 78, 11), + series_7: Color32::from_rgb(168, 72, 127), + series_8: Color32::from_rgb(95, 95, 110), + series_9: Color32::from_rgb(42, 131, 189), + series_10: Color32::from_rgb(181, 111, 0), + plot_bg: Color32::from_rgb(255, 255, 255), + plot_grid: Color32::from_rgb(240, 239, 245), + plot_grid_major: Color32::from_rgb(217, 214, 227), + plot_axis: Color32::from_rgb(95, 89, 117), + plot_cursor: Color32::from_rgb(21, 18, 31), + plot_selection: Color32::from_rgba_premultiplied(2, 1, 2, 20), + plot_legend_bg: Color32::from_rgba_premultiplied(240, 240, 240, 240), + plot_floor: Color32::from_rgba_premultiplied(0, 0, 0, 0), + seq_0: Color32::from_rgb(251, 239, 176), + seq_1: Color32::from_rgb(227, 207, 110), + seq_2: Color32::from_rgb(185, 168, 95), + seq_3: Color32::from_rgb(115, 110, 93), + seq_4: Color32::from_rgb(94, 102, 118), + seq_5: Color32::from_rgb(52, 80, 127), + seq_6: Color32::from_rgb(18, 58, 115), + on_seq_0: Color32::from_rgb(21, 18, 31), + on_seq_1: Color32::from_rgb(21, 18, 31), + on_seq_2: Color32::from_rgb(21, 18, 31), + on_seq_3: Color32::from_rgb(255, 255, 255), + on_seq_4: Color32::from_rgb(255, 255, 255), + on_seq_5: Color32::from_rgb(255, 255, 255), + on_seq_6: Color32::from_rgb(255, 255, 255), + div_0: Color32::from_rgb(31, 99, 168), + div_1: Color32::from_rgb(156, 195, 234), + div_2: Color32::from_rgb(241, 240, 245), + div_3: Color32::from_rgb(240, 194, 122), + div_4: Color32::from_rgb(168, 90, 0), + on_div_0: Color32::from_rgb(255, 255, 255), + on_div_1: Color32::from_rgb(21, 18, 31), + on_div_2: Color32::from_rgb(21, 18, 31), + on_div_3: Color32::from_rgb(21, 18, 31), + on_div_4: Color32::from_rgb(255, 255, 255), + cell_empty: Color32::from_rgb(233, 231, 240), + cell_plate: Color32::from_rgba_premultiplied(0, 0, 0, 0), + fx: Effects { glow_blur: 0.0, glow_alpha: 0.0, bloom: 0.0, redline_gradient: true, plot_floor: false }, +}; + +pub const GRAPHITE: Tokens = Tokens { + bg_void: Color32::from_rgb(36, 34, 44), + bg_base: Color32::from_rgb(42, 40, 51), + bg_panel: Color32::from_rgb(48, 46, 58), + bg_raised: Color32::from_rgb(54, 52, 65), + bg_field: Color32::from_rgb(36, 34, 44), + bg_hover: Color32::from_rgb(61, 58, 73), + bg_active: Color32::from_rgb(69, 66, 90), + line_subtle: Color32::from_rgb(58, 56, 70), + line: Color32::from_rgb(74, 71, 88), + line_control: Color32::from_rgb(143, 139, 166), + ink: Color32::from_rgb(246, 244, 255), + ink_muted: Color32::from_rgb(207, 202, 226), + ink_subtle: Color32::from_rgb(178, 173, 200), + ink_disabled: Color32::from_rgb(108, 104, 128), + cyan: Color32::from_rgb(45, 226, 255), + cyan_soft: Color32::from_rgb(13, 47, 59), + on_cyan: Color32::from_rgb(4, 19, 26), + magenta: Color32::from_rgb(255, 148, 201), + magenta_soft: Color32::from_rgb(58, 16, 48), + on_magenta: Color32::from_rgb(26, 4, 16), + violet: Color32::from_rgb(166, 138, 255), + orange: Color32::from_rgb(255, 140, 66), + gold: Color32::from_rgb(255, 210, 63), + redline_0: Color32::from_rgb(45, 226, 255), + redline_1: Color32::from_rgb(166, 138, 255), + redline_2: Color32::from_rgb(255, 148, 201), + redline_3: Color32::from_rgb(255, 140, 66), + redline_4: Color32::from_rgb(255, 210, 63), + grid_floor: Color32::from_rgb(74, 71, 88), + success: Color32::from_rgb(124, 200, 255), + success_soft: Color32::from_rgb(29, 52, 70), + warning: Color32::from_rgb(245, 232, 90), + warning_soft: Color32::from_rgb(59, 56, 32), + danger: Color32::from_rgb(255, 162, 94), + danger_soft: Color32::from_rgb(69, 48, 31), + info: Color32::from_rgb(212, 201, 255), + series_1: Color32::from_rgb(86, 180, 233), + series_2: Color32::from_rgb(230, 159, 0), + series_3: Color32::from_rgb(31, 191, 143), + series_4: Color32::from_rgb(240, 228, 66), + series_5: Color32::from_rgb(47, 134, 208), + series_6: Color32::from_rgb(232, 105, 42), + series_7: Color32::from_rgb(217, 133, 184), + series_8: Color32::from_rgb(143, 138, 158), + series_9: Color32::from_rgb(86, 180, 233), + series_10: Color32::from_rgb(230, 159, 0), + plot_bg: Color32::from_rgb(36, 34, 44), + plot_grid: Color32::from_rgb(47, 45, 56), + plot_grid_major: Color32::from_rgb(69, 66, 90), + plot_axis: Color32::from_rgb(178, 173, 200), + plot_cursor: Color32::from_rgb(246, 244, 255), + plot_selection: Color32::from_rgba_premultiplied(30, 29, 31, 31), + plot_legend_bg: Color32::from_rgba_premultiplied(45, 43, 55, 240), + plot_floor: Color32::from_rgba_premultiplied(0, 0, 0, 0), + seq_0: Color32::from_rgb(18, 58, 115), + seq_1: Color32::from_rgb(52, 80, 127), + seq_2: Color32::from_rgb(94, 102, 118), + seq_3: Color32::from_rgb(131, 127, 119), + seq_4: Color32::from_rgb(169, 158, 114), + seq_5: Color32::from_rgb(209, 189, 94), + seq_6: Color32::from_rgb(253, 231, 74), + on_seq_0: Color32::from_rgb(246, 244, 255), + on_seq_1: Color32::from_rgb(246, 244, 255), + on_seq_2: Color32::from_rgb(246, 244, 255), + on_seq_3: Color32::from_rgb(246, 244, 255), + on_seq_4: Color32::from_rgb(246, 244, 255), + on_seq_5: Color32::from_rgb(246, 244, 255), + on_seq_6: Color32::from_rgb(246, 244, 255), + div_0: Color32::from_rgb(74, 163, 239), + div_1: Color32::from_rgb(38, 90, 140), + div_2: Color32::from_rgb(58, 56, 70), + div_3: Color32::from_rgb(138, 92, 20), + div_4: Color32::from_rgb(230, 159, 0), + on_div_0: Color32::from_rgb(246, 244, 255), + on_div_1: Color32::from_rgb(246, 244, 255), + on_div_2: Color32::from_rgb(246, 244, 255), + on_div_3: Color32::from_rgb(246, 244, 255), + on_div_4: Color32::from_rgb(246, 244, 255), + cell_empty: Color32::from_rgb(48, 46, 58), + cell_plate: Color32::from_rgba_premultiplied(32, 30, 39, 224), + fx: Effects { glow_blur: 0.0, glow_alpha: 0.0, bloom: 0.0, redline_gradient: true, plot_floor: false }, +}; + +pub mod space { + pub const SPACE_1: f32 = 4.0; + pub const SPACE_2: f32 = 8.0; + pub const SPACE_3: f32 = 12.0; + pub const SPACE_4: f32 = 16.0; + pub const SPACE_6: f32 = 24.0; + pub const SPACE_8: f32 = 32.0; + pub const SPACE_12: f32 = 48.0; +} +pub mod radius { + pub const RADIUS_XS: u8 = 2; + pub const RADIUS_SM: u8 = 4; + pub const RADIUS_MD: u8 = 6; + pub const RADIUS_LG: u8 = 10; + pub const RADIUS_PILL: u8 = 255; +} +pub mod stroke { + pub const STROKE_HAIRLINE: f32 = 1.0; + pub const STROKE_INDICATOR: f32 = 2.0; + pub const STROKE_SERIES: f32 = 1.5; + pub const STROKE_CURSOR: f32 = 1.5; +} + +/// Type scale: (family, size px, line height px). Families: "display" = Saira, "ui" = Outfit, "mono" = JetBrains Mono. +pub mod text { + pub const DISPLAY: (&str, f32, f32) = ("display", 48.0, 48.0); + pub const HEADLINE: (&str, f32, f32) = ("display", 28.0, 32.0); + pub const TITLE: (&str, f32, f32) = ("display", 18.0, 24.0); + pub const OVERLINE: (&str, f32, f32) = ("display", 11.0, 14.0); + pub const HEADING: (&str, f32, f32) = ("ui", 15.0, 20.0); + pub const BODY: (&str, f32, f32) = ("ui", 14.0, 20.0); + pub const LABEL: (&str, f32, f32) = ("ui", 13.0, 18.0); + pub const CAPTION: (&str, f32, f32) = ("ui", 12.0, 16.0); + pub const DATA_LG: (&str, f32, f32) = ("mono", 18.0, 22.0); + pub const DATA: (&str, f32, f32) = ("mono", 13.0, 18.0); + pub const DATA_SM: (&str, f32, f32) = ("mono", 11.0, 14.0); +} diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs index f6977a4f..a35cd404 100644 --- a/src/ui/timeline.rs +++ b/src/ui/timeline.rs @@ -5,13 +5,27 @@ use rust_i18n::t; use crate::analytics; use crate::app::UltraLogApp; +use crate::ui::components::icon; +use crate::ui::fonts::{self, style}; +use crate::ui::theme::tokens::{radius, space}; + +/// `n` with thin-space thousands separators ("223 559"), the brand's counter format. +pub fn group_thousands(n: usize) -> String { + let digits = n.to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, ch) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i).is_multiple_of(3) { + out.push('\u{2009}'); + } + out.push(ch); + } + out +} impl UltraLogApp { - /// Render the timeline scrubber bar + /// Render the timeline scrubber: start and end times at the track ends in mono, a 4px + /// track filled in `cyan` up to the cursor, and an 18px `ink` thumb bar. pub fn render_timeline_scrubber(&mut self, ui: &mut egui::Ui) { - // Pre-compute scaled font size - let font_12 = self.scaled_font(12.0); - let Some((min_time, max_time)) = self.get_time_range() else { return; }; @@ -20,48 +34,74 @@ impl UltraLogApp { if total_duration <= 0.0 { return; } - - // Time labels row - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(Self::format_time(min_time)) - .color(egui::Color32::LIGHT_GRAY) - .size(font_12), - ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label( - egui::RichText::new(Self::format_time(max_time)) - .color(egui::Color32::LIGHT_GRAY) - .size(font_12), - ); - }); - }); - - // Full-width slider - set slider_width to use available space + let t = self.tokens; + let small = fonts::text_style(style::DATA_SM).resolve(ui.style()); let current_time = self.get_cursor_time().unwrap_or(min_time); - let mut slider_value = current_time; - let available_width = ui.available_width(); - - // Temporarily set slider width to fill available space - let old_slider_width = ui.spacing().slider_width; - ui.spacing_mut().slider_width = available_width - 10.0; // Small margin for aesthetics - let slider = egui::Slider::new(&mut slider_value, min_time..=max_time) - .show_value(false) - .clamping(egui::SliderClamping::Always); - - let slider_response = ui.add(slider); - - // Restore original slider width - ui.spacing_mut().slider_width = old_slider_width; + let (rect, response) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 20.0), + egui::Sense::click_and_drag(), + ); + let painter = ui.painter(); + let start = + painter.layout_no_wrap(Self::format_time(min_time), small.clone(), t.ink_subtle); + let end = painter.layout_no_wrap(Self::format_time(max_time), small, t.ink_subtle); + let track = egui::Rect::from_min_max( + egui::pos2( + rect.left() + start.size().x + space::SPACE_3, + rect.center().y - 2.0, + ), + egui::pos2( + rect.right() - end.size().x - space::SPACE_3, + rect.center().y + 2.0, + ), + ); + painter.galley( + egui::pos2(rect.left(), rect.center().y - start.size().y / 2.0), + start, + t.ink_subtle, + ); + painter.galley( + egui::pos2( + track.right() + space::SPACE_3, + rect.center().y - end.size().y / 2.0, + ), + end, + t.ink_subtle, + ); + + let k = ((current_time - min_time) / total_duration).clamp(0.0, 1.0) as f32; + let thumb_x = track.left() + track.width() * k; + let pill = egui::CornerRadius::same(radius::RADIUS_PILL); + painter.rect_filled(track, pill, t.line); + painter.rect_filled( + egui::Rect::from_min_max(track.left_top(), egui::pos2(thumb_x, track.bottom())), + pill, + t.cyan, + ); + painter.rect_filled( + egui::Rect::from_center_size( + egui::pos2(thumb_x, rect.center().y), + egui::vec2(4.0, 18.0), + ), + egui::CornerRadius::same(radius::RADIUS_XS), + t.ink, + ); + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } - if slider_response.changed() { + if let Some(pos) = response.interact_pointer_pos() + && (response.dragged() || response.clicked()) + { + let k = ((pos.x - track.left()) / track.width()).clamp(0.0, 1.0) as f64; + let time = min_time + k * total_duration; // Stop playback when user manually scrubs self.is_playing = false; self.last_frame_time = None; - self.set_cursor_time(Some(slider_value)); - let record = self.find_record_at_time(slider_value); + self.set_cursor_time(Some(time)); + let record = self.find_record_at_time(time); self.set_cursor_record(record); // Force repaint to update legend values ui.ctx().request_repaint(); @@ -70,31 +110,34 @@ impl UltraLogApp { /// Render the record/time indicator bar with playback controls pub fn render_record_indicator(&mut self, ui: &mut egui::Ui) { - // Pre-compute scaled font size - let font_14 = self.scaled_font(14.0); - + let t = self.tokens; ui.horizontal(|ui| { - // Playback controls let button_size = egui::vec2(28.0, 28.0); + let corner = egui::CornerRadius::same(radius::RADIUS_MD); + let fill_family = egui::FontFamily::Name(fonts::PHOSPHOR_FILL.into()); - // Play/Pause button - let play_text = if self.is_playing { - "\u{23F8}" + // Play / pause: the one cyan-filled transport button + let play_icon = if self.is_playing { + icon::PAUSE } else { - "\u{25B6}" + icon::PLAY }; let play_button = egui::Button::new( - egui::RichText::new(play_text) - .size(self.scaled_font(16.0)) - .color(if self.is_playing { - egui::Color32::from_rgb(253, 193, 73) // Amber when playing - } else { - egui::Color32::from_rgb(144, 238, 144) // Light green when paused - }), + egui::RichText::new(play_icon) + .family(fill_family.clone()) + .size(14.0) + .color(t.on_cyan), ) + .fill(t.cyan) + .corner_radius(corner) .min_size(button_size); - if ui.add(play_button).clicked() { + let play_tip = if self.is_playing { + t!("timeline.pause") + } else { + t!("timeline.play") + }; + if ui.add(play_button).on_hover_text(play_tip).clicked() { self.is_playing = !self.is_playing; if self.is_playing { // Track playback start for analytics @@ -114,13 +157,19 @@ impl UltraLogApp { // Stop button (resets to beginning) let stop_button = egui::Button::new( - egui::RichText::new("\u{23F9}") - .size(self.scaled_font(16.0)) - .color(egui::Color32::from_rgb(191, 78, 48)), // Rust orange + egui::RichText::new(icon::STOP) + .family(fill_family) + .size(12.0) + .color(t.ink), ) + .corner_radius(corner) .min_size(button_size); - if ui.add(stop_button).clicked() { + if ui + .add(stop_button) + .on_hover_text(t!("timeline.stop")) + .clicked() + { self.is_playing = false; self.last_frame_time = None; // Reset cursor to beginning @@ -135,36 +184,39 @@ impl UltraLogApp { // Playback speed selector ui.label( - egui::RichText::new(t!("timeline.speed")) - .color(egui::Color32::GRAY) - .size(font_14), + egui::RichText::new(t!("timeline.speed").trim_end_matches(':').to_string()) + .text_style(fonts::text_style(style::LABEL)) + .color(t.ink_muted), ); let speed_options = [0.25, 0.5, 1.0, 2.0, 4.0, 8.0]; egui::ComboBox::from_id_salt("playback_speed") - .selected_text(format!("{}x", self.playback_speed)) + .selected_text( + egui::RichText::new(format!("{}\u{00D7}", self.playback_speed)).monospace(), + ) .width(60.0) .show_ui(ui, |ui| { for speed in speed_options { - ui.selectable_value(&mut self.playback_speed, speed, format!("{}x", speed)); + ui.selectable_value( + &mut self.playback_speed, + speed, + egui::RichText::new(format!("{speed}\u{00D7}")).monospace(), + ); } }); ui.separator(); - // Current time display + // Current time: data-lg in cyan, the same colour as the plot cursor if let Some(time) = self.get_cursor_time() { ui.label( - egui::RichText::new(t!("timeline.time", time = Self::format_time(time))) - .strong() - .color(egui::Color32::from_rgb(0, 255, 255)) // Cyan to match cursor - .size(font_14), + egui::RichText::new(Self::format_time(time)) + .text_style(fonts::text_style(style::DATA_LG)) + .color(t.plot_cursor), ); } - ui.separator(); - - // Record indicator - use active tab's file for record count + // Record counter with thin-space thousands if let Some(record) = self.get_cursor_record() && let Some(tab_idx) = self.active_tab { @@ -174,11 +226,11 @@ impl UltraLogApp { ui.label( egui::RichText::new(t!( "timeline.record", - current = record + 1, - total = total_records + current = group_thousands(record + 1), + total = group_thousands(total_records) )) - .color(egui::Color32::LIGHT_GRAY) - .size(font_14), + .text_style(egui::TextStyle::Monospace) + .color(t.ink_muted), ); } } @@ -231,3 +283,16 @@ impl UltraLogApp { ctx.request_repaint(); } } + +#[cfg(test)] +mod tests { + use super::group_thousands; + + #[test] + fn counters_group_with_thin_spaces() { + assert_eq!(group_thousands(7), "7"); + assert_eq!(group_thousands(40_301), "40\u{2009}301"); + assert_eq!(group_thousands(223_559), "223\u{2009}559"); + assert_eq!(group_thousands(1_000_000), "1\u{2009}000\u{2009}000"); + } +} diff --git a/src/ui/toast.rs b/src/ui/toast.rs index c0472d9d..5cea6ff4 100644 --- a/src/ui/toast.rs +++ b/src/ui/toast.rs @@ -3,49 +3,59 @@ use eframe::egui; use crate::app::UltraLogApp; +use crate::state::ToastType; +use crate::ui::components::Status; +use crate::ui::theme::tokens::radius; + +/// How long a toast stays on screen. +const TOAST_DURATION: std::time::Duration = std::time::Duration::from_secs(3); impl UltraLogApp { /// Render toast notifications in the bottom right corner pub fn render_toast(&mut self, ctx: &egui::Context) { if let Some((message, time, toast_type)) = &self.toast_message { - if time.elapsed().as_secs() < 3 { + let shown_for = time.elapsed(); + if shown_for < TOAST_DURATION { + // Wake up to take the toast down even if nothing else repaints. + ctx.request_repaint_after(TOAST_DURATION - shown_for); let margin = 20.0; - // Get colors for this toast type - let bg_color = toast_type.color(); - let text_color = toast_type.text_color(); + let t = self.tokens; + let fill = toast_type.fill(&t); + let accent = toast_type.accent(&t); + let shadow = ctx.global_style().visuals.popup_shadow; egui::Area::new(egui::Id::new("toast")) .anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-margin, -margin)) - .order(egui::Order::Foreground) + // Above windows and not interactable, so a toast never takes Esc or clicks + // from the window under it. + .order(egui::Order::Tooltip) + .interactable(false) .show(ctx, |ui| { egui::Frame::NONE - .fill(egui::Color32::from_rgb( - bg_color[0], - bg_color[1], - bg_color[2], - )) - .corner_radius(8) + .fill(fill) + .stroke(egui::Stroke::new(1.0, accent)) + .corner_radius(radius::RADIUS_MD) .inner_margin(egui::Margin::symmetric(16, 12)) - .shadow(egui::epaint::Shadow { - offset: [2, 2], - blur: 8, - spread: 0, - color: egui::Color32::from_black_alpha(60), - }) + .shadow(shadow) .show(ui, |ui| { // Set min/max width for proper text wrapping ui.set_min_width(200.0); ui.set_max_width(400.0); - ui.label( - egui::RichText::new(message) - .color(egui::Color32::from_rgb( - text_color[0], - text_color[1], - text_color[2], - )) - .size(self.scaled_font(14.0)), - ); + let status = match toast_type { + ToastType::Info => Status::Info, + ToastType::Success => Status::Success, + ToastType::Warning => Status::Warning, + ToastType::Error => Status::Danger, + }; + ui.horizontal_top(|ui| { + ui.label(egui::RichText::new(status.icon()).color(accent)); + ui.label( + egui::RichText::new(message) + .color(t.ink) + .text_style(egui::TextStyle::Body), + ); + }); }); }); } else { diff --git a/src/ui/tool_properties_panel.rs b/src/ui/tool_properties_panel.rs index b6fcf830..87b72c89 100644 --- a/src/ui/tool_properties_panel.rs +++ b/src/ui/tool_properties_panel.rs @@ -10,6 +10,10 @@ use rust_i18n::t; use crate::app::UltraLogApp; use crate::normalize::sort_channels_by_priority; use crate::state::{ActiveTool, MAX_CHANNELS}; +use crate::ui::components::{self, Kind, icon}; +use crate::ui::fonts::{self, style}; +use crate::ui::plot_style; +use crate::ui::theme::tokens::{radius, space, stroke}; impl UltraLogApp { /// Render the tool properties panel content (called from side_panel.rs) @@ -28,9 +32,6 @@ impl UltraLogApp { /// Render Log Viewer properties (channel selection) fn render_log_viewer_properties(&mut self, ui: &mut egui::Ui) { // Pre-compute scaled font sizes - let font_12 = self.scaled_font(12.0); - let font_14 = self.scaled_font(14.0); - let _font_16 = self.scaled_font(16.0); // Get active tab info including stacked mode let tab_info = self.active_tab.and_then(|tab_idx| { @@ -47,148 +48,74 @@ impl UltraLogApp { } }); - if let Some((file_index, current_search, selected_count, stacked_mode)) = tab_info { - let channel_count = self.files[file_index].log.channels.len(); + if let Some((tab_file, current_search, _, stacked_mode)) = tab_info { + let Some(tab_idx) = self.active_tab else { + return; + }; // Stacked Mode Toggle Section - Pill Style ui.vertical(|ui| { ui.horizontal(|ui| { - ui.label(egui::RichText::new("Plot Mode:").size(font_14).strong()); + ui.label(egui::RichText::new("Plot mode").heading().strong()); }); ui.add_space(4.0); - ui.horizontal(|ui| { - // Single Plot button - let is_single = !stacked_mode; - let single_fill = if is_single { - egui::Color32::from_rgb(70, 70, 70) - } else { - egui::Color32::from_rgb(45, 45, 45) - }; - let single_text_color = if is_single { - egui::Color32::WHITE - } else { - egui::Color32::from_rgb(180, 180, 180) - }; - let single_stroke = if is_single { - egui::Stroke::new(1.5, egui::Color32::from_rgb(113, 120, 78)) - } else { - egui::Stroke::new(1.0, egui::Color32::from_rgb(80, 80, 80)) - }; + let mut stacked = stacked_mode; + if components::segmented( + ui, + &mut stacked, + &[(false, "Single plot"), (true, "Stacked plots")], + ) { + self.toggle_stacked_mode(); + } + }); - let single_btn = ui.add( - egui::Button::new( - egui::RichText::new("Single Plot") - .size(font_14) - .color(single_text_color), - ) - .fill(single_fill) - .stroke(single_stroke) - .corner_radius(egui::CornerRadius::same(16)) - .min_size(egui::vec2(100.0, 32.0)), - ); - - if single_btn.clicked() && stacked_mode { - self.toggle_stacked_mode(); - } - if single_btn.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); - ui.add_space(4.0); + self.render_compare_row(ui, tab_idx); - // Stacked Plots button - let is_stacked = stacked_mode; - let stacked_fill = if is_stacked { - egui::Color32::from_rgb(70, 70, 70) - } else { - egui::Color32::from_rgb(45, 45, 45) - }; - let stacked_text_color = if is_stacked { - egui::Color32::WHITE - } else { - egui::Color32::from_rgb(180, 180, 180) - }; - let stacked_stroke = if is_stacked { - egui::Stroke::new(1.5, egui::Color32::from_rgb(113, 120, 78)) - } else { - egui::Stroke::new(1.0, egui::Color32::from_rgb(80, 80, 80)) - }; - - let stacked_btn = ui.add( - egui::Button::new( - egui::RichText::new("Stacked Plots") - .size(font_14) - .color(stacked_text_color), - ) - .fill(stacked_fill) - .stroke(stacked_stroke) - .corner_radius(egui::CornerRadius::same(16)) - .min_size(egui::vec2(100.0, 32.0)), - ); - - if stacked_btn.clicked() && !stacked_mode { - self.toggle_stacked_mode(); - } - if stacked_btn.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - }); - }); + // The list shows log A's channels, or log B's with the A | B switch on B. + let file_index = match &self.tabs[tab_idx].compare { + Some(c) if c.list_b && c.file_index < self.files.len() => c.file_index, + _ => tab_file, + }; + let channel_count = self.files[file_index].log.channels.len(); + let selected_count = self.tabs[tab_idx].total_channel_units(); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // Computed Channels button - let primary_color = egui::Color32::from_rgb(113, 120, 78); - let computed_btn = egui::Frame::NONE - .fill(primary_color) - .corner_radius(4) - .inner_margin(egui::vec2(10.0, 6.0)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new("\u{0192}") - .color(egui::Color32::WHITE) - .size(font_14), - ); - ui.label( - egui::RichText::new(t!("channels.computed_channels")) - .color(egui::Color32::WHITE) - .size(font_14), - ); - }); - }); - - if computed_btn - .response - .interact(egui::Sense::click()) - .on_hover_text(t!("channels.computed_channels_tooltip")) - .clicked() + if components::button( + ui, + Kind::Secondary, + Some("\u{0192}"), + t!("channels.computed_channels"), + ) + .on_hover_text(t!("channels.computed_channels_tooltip")) + .clicked() { self.show_computed_channels_manager = true; } - if computed_btn.response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - ui.add_space(8.0); // Search box let mut search_text = current_search; let mut search_changed = false; egui::Frame::NONE - .fill(egui::Color32::from_rgb(50, 50, 50)) + .fill(self.tokens.bg_raised) .corner_radius(4) .inner_margin(egui::vec2(8.0, 6.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label( - egui::RichText::new("\u{1F50D}") - .size(font_14) - .color(egui::Color32::GRAY), + egui::RichText::new(icon::MAGNIFYING_GLASS) + .text_style(egui::TextStyle::Body) + .color(self.tokens.ink_subtle), ); let response = ui.add( egui::TextEdit::singleline(&mut search_text) @@ -214,8 +141,8 @@ impl UltraLogApp { max = MAX_CHANNELS, total = channel_count )) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); ui.add_space(4.0); @@ -233,29 +160,192 @@ impl UltraLogApp { ui.add_space(40.0); ui.vertical_centered(|ui| { ui.label( - egui::RichText::new("\u{1F4CA}") - .size(32.0) - .color(egui::Color32::from_rgb(100, 100, 100)), + egui::RichText::new(icon::TRAY) + .text_style(crate::ui::fonts::text_style( + crate::ui::fonts::style::HEADLINE, + )) + .color(self.tokens.ink_subtle), ); ui.add_space(8.0); ui.label( egui::RichText::new(t!("channels.no_file_selected")) - .size(font_14) - .color(egui::Color32::GRAY), + .text_style(egui::TextStyle::Body) + .color(self.tokens.ink_muted), ); ui.add_space(4.0); ui.label( egui::RichText::new(t!("channels.load_file_to_view")) - .size(font_12) - .color(egui::Color32::from_rgb(100, 100, 100)), + .small() + .color(self.tokens.ink_subtle), ); }); } } + /// The Compare row (issue #98): choose log B (None, another loaded file, or Open file…), + /// and the A | B switch for the channel list while comparing. + fn render_compare_row(&mut self, ui: &mut egui::Ui, tab_idx: usize) { + let tab_file = self.tabs[tab_idx].file_index; + let current = self.tabs[tab_idx].compare_file(); + let others: Vec<(usize, String)> = self + .files + .iter() + .enumerate() + .filter(|(i, _)| *i != tab_file) + .map(|(i, f)| (i, f.name.clone())) + .collect(); + + ui.label(egui::RichText::new(t!("compare.title")).heading().strong()); + ui.add_space(4.0); + + let mut choice: Option> = None; + let mut open_file = false; + let selected_text = current + .and_then(|i| self.files.get(i)) + .map_or_else(|| t!("compare.none").to_string(), |f| f.name.clone()); + egui::ComboBox::from_id_salt(("compare_file", self.tabs[tab_idx].id)) + .selected_text(selected_text) + .width(ui.available_width() - space::SPACE_2) + .show_ui(ui, |ui| { + if ui + .selectable_label(current.is_none(), t!("compare.none")) + .clicked() + { + choice = Some(None); + } + for (i, name) in &others { + if ui.selectable_label(current == Some(*i), name).clicked() { + choice = Some(Some(*i)); + } + } + ui.separator(); + if ui + .selectable_label(false, t!("compare.open_file")) + .clicked() + { + open_file = true; + } + }); + + if current.is_some() { + ui.add_space(4.0); + let mut list_b = self.tabs[tab_idx] + .compare + .as_ref() + .is_some_and(|c| c.list_b); + let (a, b) = (t!("compare.log_a"), t!("compare.log_b")); + if components::segmented(ui, &mut list_b, &[(false, a.as_ref()), (true, b.as_ref())]) + && let Some(compare) = self.tabs[tab_idx].compare.as_mut() + { + compare.list_b = list_b; + } + ui.label( + egui::RichText::new(t!("compare.hint")) + .small() + .color(self.tokens.ink_subtle), + ); + ui.add_space(6.0); + self.render_compare_offset(ui, tab_idx); + } else if others.is_empty() { + ui.label( + egui::RichText::new(t!("compare.no_other_files")) + .small() + .color(self.tokens.ink_subtle), + ); + } + + if let Some(file) = choice { + self.choose_compare_file(tab_idx, file); + } + if open_file { + self.pick_compare_file(tab_idx); + } + } + + /// Log B's time offset (D3): a seconds field, nudge buttons, Reset, and Align at cursor. + fn render_compare_offset(&mut self, ui: &mut egui::Ui, tab_idx: usize) { + let Some(compare) = self.tabs[tab_idx].compare.as_ref() else { + return; + }; + let mut offset = compare.offset_s; + let picking = compare.picking_alignment; + let has_cursor = self.tabs[tab_idx].cursor_time.is_some(); + + ui.horizontal(|ui| { + ui.label(egui::RichText::new(t!("compare.offset")).text_style(egui::TextStyle::Body)); + ui.add( + egui::DragValue::new(&mut offset) + .speed(0.01) + .fixed_decimals(2) + .suffix(" s"), + ) + .on_hover_text(t!("compare.offset_hint")); + }); + ui.horizontal(|ui| { + for (label, step) in [("−1", -1.0), ("−0.1", -0.1), ("+0.1", 0.1), ("+1", 1.0)] { + if components::button_sm(ui, Kind::Secondary, None, label).clicked() { + offset += step; + } + } + if components::button_sm(ui, Kind::Ghost, None, t!("compare.reset")).clicked() { + offset = 0.0; + } + }); + // Round away float drift from the nudges (0.1 steps). + let offset = (offset * 1000.0).round() / 1000.0; + if offset + != self.tabs[tab_idx] + .compare + .as_ref() + .map_or(0.0, |c| c.offset_s) + { + self.set_compare_offset(tab_idx, offset); + } + + ui.add_space(4.0); + if picking { + components::notice( + ui, + components::Status::Info, + &t!("compare.align_pick"), + &t!("compare.align_pick_rest"), + ); + if components::button(ui, Kind::Ghost, Some(icon::X), t!("compare.align_cancel")) + .clicked() + { + self.cancel_alignment(tab_idx); + } + } else { + let response = ui + .add_enabled_ui(has_cursor, |ui| { + components::button( + ui, + Kind::Secondary, + Some(icon::CROSSHAIR), + t!("compare.align"), + ) + }) + .inner + .on_hover_text(t!("compare.align_hint")) + .on_disabled_hover_text(t!("compare.align_no_cursor")); + if response.clicked() { + self.start_alignment(tab_idx); + } + } + } + + /// The native file dialog for "Open file…" in the Compare row. + pub fn pick_compare_file(&mut self, tab_idx: usize) { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Log Files", crate::state::SUPPORTED_EXTENSIONS) + .pick_file() + { + self.open_compare_file(tab_idx, path); + } + } + /// Render the channel list in compact form fn render_channel_list_compact(&mut self, ui: &mut egui::Ui, file_index: usize, search: &str) { - let font_14 = self.scaled_font(14.0); let search_lower = search.to_lowercase(); let mut channel_to_add: Option<(usize, usize)> = None; @@ -300,39 +390,83 @@ impl UltraLogApp { .position(|c| c.file_index == file_index && c.channel_index == channel_index); let is_selected = selected_idx.is_some(); - let text_color = if is_empty { - egui::Color32::from_rgb(100, 100, 100) - } else if is_selected { - egui::Color32::WHITE - } else { - egui::Color32::LIGHT_GRAY - }; + let t = self.tokens; + let text_color = if is_empty { t.ink_disabled } else { t.ink }; + let row_height = 28.0; + let (rect, response) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), row_height), + egui::Sense::click(), + ); + let painter = ui.painter(); + let corner = egui::CornerRadius::same(radius::RADIUS_MD); + if is_selected { + painter.rect_filled(rect, corner, t.bg_active); + } else if response.hovered() { + painter.rect_filled(rect, corner, t.bg_hover); + } - let bg_color = if is_selected { - egui::Color32::from_rgb(55, 60, 50) + // Checkbox (same look as components::checkbox) + let boxr = egui::Rect::from_center_size( + egui::pos2(rect.left() + space::SPACE_2 + 8.0, rect.center().y), + egui::vec2(16.0, 16.0), + ); + let box_corner = egui::CornerRadius::same(radius::RADIUS_XS + 1); + if is_selected { + painter.rect_filled(boxr, box_corner, t.cyan); + painter.text( + boxr.center(), + egui::Align2::CENTER_CENTER, + icon::CHECK, + egui::FontId::proportional(12.0), + t.on_cyan, + ); } else { - egui::Color32::TRANSPARENT - }; + painter.rect( + boxr, + box_corner, + t.bg_field, + egui::Stroke::new(stroke::STROKE_HAIRLINE, t.line_control), + egui::StrokeKind::Inside, + ); + } - let frame = egui::Frame::NONE - .fill(bg_color) - .corner_radius(3) - .inner_margin(egui::Margin::symmetric(6, 3)); + let body = egui::TextStyle::Body.resolve(ui.style()); + painter.text( + egui::pos2(boxr.right() + space::SPACE_3, rect.center().y), + egui::Align2::LEFT_CENTER, + display_name, + body, + text_color, + ); - let response = frame - .show(ui, |ui| { - ui.horizontal(|ui| { - let check = if is_selected { "\u{2611}" } else { "\u{2610}" }; - ui.label(egui::RichText::new(check).size(font_14).color(text_color)); - ui.label( - egui::RichText::new(display_name) - .size(font_14) - .color(text_color), - ); - }); - }) - .response - .interact(egui::Sense::click()); + // Right side: unit in mono, and the series swatch when the channel is on the chart + let unit = file.log.channels[channel_index].unit(); + let display_unit = self.unit_preferences.convert_value(0.0, unit).1; + let mut right = rect.right() - space::SPACE_2; + if !display_unit.is_empty() { + let galley = painter.layout_no_wrap( + display_unit.to_string(), + fonts::text_style(style::DATA_SM).resolve(ui.style()), + t.ink_subtle, + ); + right -= galley.size().x; + painter.galley( + egui::pos2(right, rect.center().y - galley.size().y / 2.0), + galley, + t.ink_subtle, + ); + right -= space::SPACE_2; + } + if let Some(idx) = selected_idx { + let (color, kind) = self.channel_style(&selected_channels[idx]); + plot_style::swatch( + painter, + egui::pos2(right - 14.0, rect.center().y), + 14.0, + color, + kind, + ); + } if response.clicked() { if let Some(idx) = selected_idx { @@ -353,12 +487,12 @@ impl UltraLogApp { // Channels with data if !channels_with.is_empty() { egui::CollapsingHeader::new( - egui::RichText::new(format!( - "\u{1F4CA} {}", - t!("channels.with_data", count = channels_with.len()) - )) - .size(font_14) - .strong(), + egui::RichText::new( + t!("channels.with_data", count = channels_with.len()).to_uppercase(), + ) + .text_style(fonts::text_style(style::OVERLINE)) + .extra_letter_spacing(0.9) + .color(self.tokens.ink_muted), ) .default_open(true) .show(ui, |ui| { @@ -380,12 +514,12 @@ impl UltraLogApp { // Empty channels if !channels_without.is_empty() { egui::CollapsingHeader::new( - egui::RichText::new(format!( - "\u{1F4ED} {}", - t!("channels.empty", count = channels_without.len()) - )) - .size(font_14) - .color(egui::Color32::GRAY), + egui::RichText::new( + t!("channels.empty", count = channels_without.len()).to_uppercase(), + ) + .text_style(fonts::text_style(style::OVERLINE)) + .extra_letter_spacing(0.9) + .color(self.tokens.ink_subtle), ) .default_open(false) .show(ui, |ui| { @@ -414,7 +548,6 @@ impl UltraLogApp { /// Render stacked plot list with channels organized by plot fn render_stacked_plot_list(&mut self, ui: &mut egui::Ui, file_index: usize, search: &str) { - let font_14 = self.scaled_font(14.0); let search_lower = search.to_lowercase(); let Some(tab_idx) = self.active_tab else { @@ -436,23 +569,7 @@ impl UltraLogApp { let mut plot_to_delete: Option = None; // "+ New Plot" button - let new_plot_btn = egui::Frame::NONE - .fill(egui::Color32::from_rgb(71, 108, 155)) - .corner_radius(4) - .inner_margin(egui::vec2(10.0, 6.0)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new("+ New Plot") - .color(egui::Color32::WHITE) - .size(font_14), - ); - }); - }); - - if new_plot_btn - .response - .interact(egui::Sense::click()) + if components::button(ui, Kind::Secondary, Some(icon::PLUS), "New plot") .on_hover_text("Create a new plot area") .clicked() { @@ -461,10 +578,6 @@ impl UltraLogApp { self.create_plot_area(name); } - if new_plot_btn.response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - ui.add_space(8.0); // Render each plot area with its channels @@ -477,21 +590,27 @@ impl UltraLogApp { .iter() .filter_map(|&idx| { selected_channels.get(idx).map(|ch| { - let name = ch.channel.name(); + // "A · " / "B · " while comparing: both halves of a pair + // have the same name. + let name = match self.log_tag(ch) { + Some(tag) => format!("{tag} \u{00B7} {}", ch.channel.name()), + None => ch.channel.name(), + }; (idx, name) }) }) .collect(); let plot_id = plot_area.id; - let has_capacity = plot_area.has_capacity(); + let has_capacity = self.tabs[tab_idx].plot_has_capacity(plot_id); + // A compare pair counts as one channel (R2). + let plot_units = self.tabs[tab_idx].channel_units(&plot_area.channel_indices); ui.push_id(format!("plot_{}", plot_id), |ui| { // Custom header with delete button let header_response = ui .horizontal(|ui| { - let header_text = - format!("{} ({}/10)", plot_area.name, plot_channels.len()); + let header_text = format!("{} ({plot_units}/10)", plot_area.name); // Collapsing state let id = ui.id().with(plot_id); @@ -504,16 +623,22 @@ impl UltraLogApp { ); let center = rect.center(); let color = if response.hovered() { - egui::Color32::WHITE + self.tokens.ink } else { - egui::Color32::LIGHT_GRAY + self.tokens.ink_muted }; - if open { - crate::ui::icons::draw_triangle_down(ui, center, 12.0, color); - } else { - crate::ui::icons::draw_triangle_right(ui, center, 12.0, color); - } + ui.painter().text( + center, + egui::Align2::CENTER_CENTER, + if open { + icon::CARET_DOWN + } else { + icon::CARET_RIGHT + }, + egui::FontId::proportional(14.0), + color, + ); if response.clicked() { open = !open; @@ -526,9 +651,9 @@ impl UltraLogApp { // Header label ui.label( egui::RichText::new(&header_text) - .size(font_14) + .heading() .strong() - .color(egui::Color32::from_rgb(159, 166, 119)), + .color(self.tokens.ink), ); // Right-aligned delete button (only if not last plot) @@ -587,10 +712,7 @@ impl UltraLogApp { ui.painter().rect_stroke( rect, egui::CornerRadius::same(4), - egui::Stroke::new( - 2.0, - egui::Color32::from_rgb(71, 108, 155), - ), + egui::Stroke::new(2.0, self.tokens.cyan), egui::StrokeKind::Inside, ); } @@ -604,8 +726,8 @@ impl UltraLogApp { ui.add_space(30.0); ui.label( egui::RichText::new("Drag channels here") - .size(font_14) - .color(egui::Color32::GRAY) + .text_style(egui::TextStyle::Body) + .color(self.tokens.ink_subtle) .italics(), ); }); @@ -613,7 +735,7 @@ impl UltraLogApp { child_ui.add_space(10.0); for (channel_idx, channel_name) in &plot_channels { let frame = egui::Frame::NONE - .fill(egui::Color32::from_rgb(55, 60, 50)) + .fill(self.tokens.bg_active) .corner_radius(3) .inner_margin(egui::Margin::symmetric(6, 3)); @@ -621,14 +743,14 @@ impl UltraLogApp { .show(&mut child_ui, |ui| { ui.horizontal(|ui| { ui.label( - egui::RichText::new("✓") - .size(font_14) - .color(egui::Color32::WHITE), + egui::RichText::new(icon::CHECK) + .text_style(egui::TextStyle::Body) + .color(self.tokens.ink), ); ui.label( egui::RichText::new(channel_name) - .size(font_14) - .color(egui::Color32::WHITE), + .text_style(egui::TextStyle::Body) + .color(self.tokens.ink), ); }); }) @@ -659,11 +781,7 @@ impl UltraLogApp { ui.add_space(8.0); // Available channels section - ui.label( - egui::RichText::new("Available Channels") - .size(font_14) - .strong(), - ); + ui.label(egui::RichText::new("Available Channels").heading().strong()); ui.add_space(4.0); // Get all available channels not yet selected @@ -702,9 +820,9 @@ impl UltraLogApp { } let text_color = if is_empty { - egui::Color32::from_rgb(100, 100, 100) + self.tokens.ink_disabled } else { - egui::Color32::LIGHT_GRAY + self.tokens.ink_muted }; // Make channel draggable @@ -717,9 +835,9 @@ impl UltraLogApp { let is_hovered = ui.ui_contains_pointer(); let fill_color = if is_hovered { - egui::Color32::from_rgb(60, 60, 60) + self.tokens.bg_hover } else { - egui::Color32::from_rgb(45, 45, 45) + self.tokens.bg_raised }; let frame = egui::Frame::NONE @@ -730,19 +848,21 @@ impl UltraLogApp { frame.show(ui, |ui| { ui.horizontal(|ui| { // Larger, more prominent grab handle icon - ui.label(egui::RichText::new("☰").size(font_14).color( - if is_hovered { - egui::Color32::WHITE - } else { - text_color - }, - )); + ui.label( + egui::RichText::new("☰") + .text_style(egui::TextStyle::Body) + .color(if is_hovered { + self.tokens.ink + } else { + text_color + }), + ); ui.add_space(4.0); ui.label( egui::RichText::new(display_name) - .size(font_14) + .text_style(egui::TextStyle::Body) .color(if is_hovered { - egui::Color32::WHITE + self.tokens.ink } else { text_color }), @@ -762,10 +882,11 @@ impl UltraLogApp { if !channels_with.is_empty() { egui::CollapsingHeader::new( egui::RichText::new(format!( - "\u{1F4CA} Channels with Data ({})", + "{} Channels with data ({})", + icon::WAVEFORM, channels_with.len() )) - .size(font_14) + .heading() .strong(), ) .default_open(true) @@ -782,11 +903,12 @@ impl UltraLogApp { if !channels_without.is_empty() { egui::CollapsingHeader::new( egui::RichText::new(format!( - "\u{1F4ED} Empty Channels ({})", + "{} Empty channels ({})", + icon::TRAY, channels_without.len() )) - .size(font_14) - .color(egui::Color32::GRAY), + .text_style(egui::TextStyle::Body) + .color(self.tokens.ink_subtle), ) .default_open(false) .show(ui, |ui| { diff --git a/src/ui/tool_switcher.rs b/src/ui/tool_switcher.rs index 36054eae..4d0fb0d1 100644 --- a/src/ui/tool_switcher.rs +++ b/src/ui/tool_switcher.rs @@ -1,72 +1,122 @@ -//! Tool switcher component for switching between different views. +//! ModeNav: the top-level workspace switch (`design/design-system/components/ModeNav/README.md`). //! -//! Renders a pill-style tab bar at the top of the main content area -//! allowing users to switch between Log Viewer and Scatter Plots views. +//! Overline type (Saira 600, uppercase, 0.08em) with a 16px icon. The active mode is `cyan` with +//! a 2px cyan bar on the bottom edge carrying `glow-cyan`, the only glow in the chrome besides +//! the primary button hover. -use eframe::egui; +use eframe::egui::{ + self, Align, CornerRadius, Rect, Sense, TextFormat, pos2, text::LayoutJob, vec2, +}; use rust_i18n::t; use crate::app::UltraLogApp; use crate::state::ActiveTool; +use crate::ui::components::icon; +use crate::ui::fonts::{self, style}; +use crate::ui::theme::{ + paint, + tokens::{space, stroke}, +}; + +/// Height of the mode nav row (px); the active bar sits on its bottom edge. +pub const MODE_NAV_HEIGHT: f32 = 40.0; + +impl ActiveTool { + /// The Phosphor icon for this mode. + fn icon(self) -> &'static str { + match self { + ActiveTool::LogViewer => icon::WAVEFORM, + ActiveTool::ScatterPlot => icon::CHART_SCATTER, + ActiveTool::Histogram => icon::GRID_FOUR, + ActiveTool::LambdaDelay => icon::TIMER, + ActiveTool::AccelEnrich => icon::CHART_LINE_UP, + } + } +} impl UltraLogApp { - /// Render the tool switcher pill tabs + /// Render the mode nav pub fn render_tool_switcher(&mut self, ui: &mut egui::Ui) { - ui.horizontal(|ui| { - ui.add_space(10.0); + let t = self.tokens; + let overline = fonts::text_style(style::OVERLINE).resolve(ui.style()); + let icon_font = egui::FontId::proportional((overline.size * 16.0 / 11.0).round()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = space::SPACE_4; for tool in ActiveTool::ALL { - let is_selected = self.active_tool == tool; - - // Style the button based on selection state - let button_fill = if is_selected { - egui::Color32::from_rgb(70, 70, 70) - } else { - egui::Color32::from_rgb(45, 45, 45) - }; - - let text_color = if is_selected { - egui::Color32::WHITE - } else { - egui::Color32::from_rgb(180, 180, 180) - }; - - let stroke = if is_selected { - egui::Stroke::new(1.5, egui::Color32::from_rgb(113, 120, 78)) - } else { - egui::Stroke::new(1.0, egui::Color32::from_rgb(80, 80, 80)) - }; - - // Get translated tool name - let tool_name = match tool { + let selected = self.active_tool == tool; + let label = match tool { ActiveTool::LogViewer => t!("tools.log_viewer"), ActiveTool::ScatterPlot => t!("tools.scatter_plots"), ActiveTool::Histogram => t!("tools.histogram"), ActiveTool::LambdaDelay => t!("tools.lambda_delay"), ActiveTool::AccelEnrich => t!("tools.accel_enrich"), + } + .to_uppercase(); + + let job = |color| { + let mut job = LayoutJob::default(); + let base = TextFormat { + color, + valign: Align::Center, + ..Default::default() + }; + job.append( + tool.icon(), + 0.0, + TextFormat { + font_id: icon_font.clone(), + ..base.clone() + }, + ); + job.append( + &label, + space::SPACE_2, + TextFormat { + font_id: overline.clone(), + extra_letter_spacing: overline.size * 0.08, + ..base + }, + ); + job }; - // Create pill-style button - let response = ui.add( - egui::Button::new( - egui::RichText::new(tool_name.as_ref()) - .size(self.scaled_font(14.0)) - .color(text_color), - ) - .fill(button_fill) - .stroke(stroke) - .corner_radius(egui::CornerRadius::same(16)) - .min_size(egui::vec2(100.0, 32.0)), + let width = ui.painter().layout_job(job(t.ink_muted)).size().x; + let (rect, response) = ui.allocate_exact_size( + vec2(width + 2.0 * space::SPACE_2, MODE_NAV_HEIGHT), + Sense::click(), + ); + let color = if selected { + t.cyan + } else if response.hovered() { + t.ink + } else { + t.ink_muted + }; + let galley = ui.painter().layout_job(job(color)); + let text_pos = pos2( + rect.center().x - galley.size().x / 2.0, + rect.center().y - galley.size().y / 2.0, ); + ui.painter().galley(text_pos, galley, color); - if response.clicked() { - self.set_active_tool(tool); + if selected { + let bar = Rect::from_min_max( + pos2(rect.left(), rect.bottom() - stroke::STROKE_INDICATOR), + rect.right_bottom(), + ); + if let Some(glow) = paint::glow(&t, bar, 0, t.cyan) { + ui.painter().add(glow); + } + ui.painter().rect_filled(bar, CornerRadius::ZERO, t.cyan); } + if response.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } - - ui.add_space(4.0); + if response.clicked() { + self.set_active_tool(tool); + } } }); } diff --git a/src/ui/tools_panel.rs b/src/ui/tools_panel.rs index 43ec0aff..19bac0db 100644 --- a/src/ui/tools_panel.rs +++ b/src/ui/tools_panel.rs @@ -8,13 +8,11 @@ use rust_i18n::t; use crate::analysis::tables::GeneratorKind; use crate::app::UltraLogApp; use crate::state::ActiveTool; +use crate::ui::components::{self, Kind, icon}; impl UltraLogApp { /// Render the tools panel content (called from side_panel.rs) pub fn render_tools_panel_content(&mut self, ui: &mut egui::Ui) { - let _font_12 = self.scaled_font(12.0); - let _font_14 = self.scaled_font(14.0); - // Analysis Tools Section self.render_tools_analysis_section(ui); @@ -42,99 +40,82 @@ impl UltraLogApp { /// Render the analysis tools section fn render_tools_analysis_section(&mut self, ui: &mut egui::Ui) { - let font_12 = self.scaled_font(12.0); - let font_14 = self.scaled_font(14.0); - - egui::CollapsingHeader::new( - egui::RichText::new("📈 Analysis Tools") - .size(font_14) - .strong(), - ) - .default_open(true) - .show(ui, |ui| { - ui.label( - egui::RichText::new("Run signal processing and statistical analysis on log data.") - .size(font_12) - .color(egui::Color32::GRAY), - ); - ui.add_space(8.0); - - let has_file = self.selected_file.is_some() && !self.files.is_empty(); - - if has_file { - // Show available analyzers count - let analyzer_count = self.analyzer_registry.all().len(); + egui::CollapsingHeader::new(egui::RichText::new("📈 Analysis Tools").heading().strong()) + .default_open(true) + .show(ui, |ui| { ui.label( - egui::RichText::new(format!("{} analyzers available", analyzer_count)) - .size(font_12) - .color(egui::Color32::GRAY), + egui::RichText::new( + "Run signal processing and statistical analysis on log data.", + ) + .small() + .color(self.tokens.ink_subtle), ); - ui.add_space(4.0); + ui.add_space(8.0); - // Open full analysis panel button - let primary_color = egui::Color32::from_rgb(113, 120, 78); - let btn = egui::Frame::NONE - .fill(primary_color) - .corner_radius(4) - .inner_margin(egui::vec2(12.0, 6.0)) - .show(ui, |ui| { - ui.label( - egui::RichText::new("Open Analysis Panel") - .color(egui::Color32::WHITE) - .size(font_14), - ); - }); + let has_file = self.selected_file.is_some() && !self.files.is_empty(); - if btn.response.interact(egui::Sense::click()).clicked() { - self.show_analysis_panel = true; - } + if has_file { + // Show available analyzers count + let analyzer_count = self.analyzer_registry.all().len(); + ui.label( + egui::RichText::new(format!("{} analyzers available", analyzer_count)) + .small() + .color(self.tokens.ink_subtle), + ); + ui.add_space(4.0); - if btn.response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } + // Open full analysis panel button + if components::button( + ui, + Kind::Primary, + Some(icon::FLASK), + "Open analysis panel", + ) + .clicked() + { + self.show_analysis_panel = true; + } - // Show results count if any - if let Some(file_idx) = self.selected_file - && let Some(results) = self.analysis_results.get(&file_idx) - && !results.is_empty() - { - ui.add_space(8.0); + // Show results count if any + if let Some(file_idx) = self.selected_file + && let Some(results) = self.analysis_results.get(&file_idx) + && !results.is_empty() + { + ui.add_space(8.0); + ui.label( + egui::RichText::new(format!( + "{} {} analysis results for current file", + icon::CHECK, + results.len() + )) + .small() + .color(self.tokens.success), + ); + } + } else { ui.label( - egui::RichText::new(format!( - "✓ {} analysis results for current file", - results.len() - )) - .size(font_12) - .color(egui::Color32::from_rgb(150, 200, 150)), + egui::RichText::new("Load a file to access analysis tools") + .small() + .color(self.tokens.ink_subtle) + .italics(), ); } - } else { - ui.label( - egui::RichText::new("Load a file to access analysis tools") - .size(font_12) - .color(egui::Color32::from_rgb(100, 100, 100)) - .italics(), - ); - } - }); + }); } /// Render the table generators section (lambda delay / accel enrichment) fn render_tools_table_section(&mut self, ui: &mut egui::Ui) { - let font_12 = self.scaled_font(12.0); - let font_14 = self.scaled_font(14.0); - egui::CollapsingHeader::new( - egui::RichText::new(format!("📊 {}", t!("table_gen.section_title"))) - .size(font_14) + egui::RichText::new(format!("{} {}", icon::TABLE, t!("table_gen.section_title"))) + .heading() .strong(), ) .default_open(true) .show(ui, |ui| { ui.label( egui::RichText::new(t!("table_gen.section_help")) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); ui.add_space(8.0); @@ -145,16 +126,19 @@ impl UltraLogApp { let enabled = has_file || status.is_some(); ui.horizontal(|ui| { let tool = ActiveTool::for_generator(kind); - let btn = egui::Button::new(egui::RichText::new(name).size(font_12)) + let btn = egui::Button::new(egui::RichText::new(name).small()) .selected(self.active_tool == tool); if ui.add_enabled(enabled, btn).clicked() { self.set_active_tool(tool); } if let Some(status) = status { ui.label( - egui::RichText::new(status) - .size(font_12) - .color(egui::Color32::from_rgb(150, 200, 150)), + egui::RichText::new(format!( + "{} {status}", + crate::ui::components::Status::Success.icon() + )) + .small() + .color(self.tokens.success), ); } }); @@ -162,8 +146,8 @@ impl UltraLogApp { if !has_file { ui.label( egui::RichText::new(t!("table_gen.load_file_hint")) - .size(font_12) - .color(egui::Color32::from_rgb(100, 100, 100)) + .small() + .color(self.tokens.ink_subtle) .italics(), ); } @@ -172,20 +156,17 @@ impl UltraLogApp { /// Render the computed channels section fn render_tools_computed_section(&mut self, ui: &mut egui::Ui) { - let font_12 = self.scaled_font(12.0); - let font_14 = self.scaled_font(14.0); - egui::CollapsingHeader::new( egui::RichText::new("ƒ Computed Channels") - .size(font_14) + .heading() .strong(), ) .default_open(true) .show(ui, |ui| { ui.label( egui::RichText::new("Create virtual channels from mathematical formulas.") - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); ui.add_space(8.0); @@ -193,55 +174,19 @@ impl UltraLogApp { let template_count = self.computed_library.templates.len(); ui.label( egui::RichText::new(format!("{} templates in library", template_count)) - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); ui.add_space(4.0); // Buttons row ui.horizontal(|ui| { - // New Channel button - let accent_color = egui::Color32::from_rgb(113, 120, 78); - let new_btn = egui::Frame::NONE - .fill(accent_color) - .corner_radius(4) - .inner_margin(egui::vec2(10.0, 5.0)) - .show(ui, |ui| { - ui.label( - egui::RichText::new("+ New") - .color(egui::Color32::WHITE) - .size(font_12), - ); - }); - - if new_btn.response.interact(egui::Sense::click()).clicked() { + if components::button_sm(ui, Kind::Ghost, Some(icon::PLUS), "New").clicked() { self.formula_editor_state.open_new(); } - - if new_btn.response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - - // Manage Library button - let manage_btn = egui::Frame::NONE - .fill(egui::Color32::from_rgb(60, 60, 60)) - .corner_radius(4) - .inner_margin(egui::vec2(10.0, 5.0)) - .show(ui, |ui| { - ui.label( - egui::RichText::new("Manage Library") - .color(egui::Color32::LIGHT_GRAY) - .size(font_12), - ); - }); - - if manage_btn.response.interact(egui::Sense::click()).clicked() { + if components::button_sm(ui, Kind::Secondary, None, "Manage library").clicked() { self.show_computed_channels_manager = true; } - - if manage_btn.response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } }); // Show applied channels for current file @@ -252,11 +197,12 @@ impl UltraLogApp { ui.add_space(8.0); ui.label( egui::RichText::new(format!( - "✓ {} computed channels on current file", + "{} {} computed channels on current file", + icon::CHECK, channels.len() )) - .size(font_12) - .color(egui::Color32::from_rgb(150, 200, 150)), + .small() + .color(self.tokens.success), ); } @@ -265,8 +211,8 @@ impl UltraLogApp { ui.add_space(8.0); ui.label( egui::RichText::new("Quick Apply:") - .size(font_12) - .color(egui::Color32::GRAY), + .small() + .color(self.tokens.ink_subtle), ); // Show first few templates as quick apply buttons @@ -282,8 +228,8 @@ impl UltraLogApp { let response = ui.add( egui::Label::new( egui::RichText::new(format!(" • {}", name)) - .size(font_12) - .color(egui::Color32::from_rgb(150, 180, 220)), + .small() + .color(self.tokens.cyan), ) .sense(egui::Sense::click()), ); @@ -308,132 +254,65 @@ impl UltraLogApp { /// Render the export section fn render_tools_export_section(&mut self, ui: &mut egui::Ui) { - let font_12 = self.scaled_font(12.0); - let font_14 = self.scaled_font(14.0); + egui::CollapsingHeader::new( + egui::RichText::new(format!("{} Export", icon::EXPORT)) + .heading() + .strong(), + ) + .default_open(true) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Export visualizations as images or documents.") + .small() + .color(self.tokens.ink_subtle), + ); + ui.add_space(8.0); - egui::CollapsingHeader::new(egui::RichText::new("📤 Export").size(font_14).strong()) - .default_open(true) - .show(ui, |ui| { - ui.label( - egui::RichText::new("Export visualizations as images or documents.") - .size(font_12) - .color(egui::Color32::GRAY), + let has_file = self.selected_file.is_some() && !self.files.is_empty(); + let has_channels = !self.get_selected_channels().is_empty(); + let has_table = self + .active_tool + .generator_kind() + .is_some_and(|k| self.table_generator.table(k).is_some()); + + // Determine what can be exported based on active tool + let can_export_png = match self.active_tool { + ActiveTool::LogViewer => has_file && has_channels, + ActiveTool::ScatterPlot => has_file, + ActiveTool::Histogram => has_file, + ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => has_table, + }; + + ui.add_enabled_ui(can_export_png, |ui| { + let export = components::button( + ui, + Kind::Secondary, + Some(icon::DOWNLOAD_SIMPLE), + t!("export.open"), ); - ui.add_space(8.0); - - let has_file = self.selected_file.is_some() && !self.files.is_empty(); - let has_channels = !self.get_selected_channels().is_empty(); - let has_table = self - .active_tool - .generator_kind() - .is_some_and(|k| self.table_generator.table(k).is_some()); - - // Determine what can be exported based on active tool - let can_export_png = match self.active_tool { - ActiveTool::LogViewer => has_file && has_channels, - ActiveTool::ScatterPlot => has_file, - ActiveTool::Histogram => has_file, - ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => has_table, - }; - - let can_export_pdf = can_export_png; - - ui.horizontal(|ui| { - // PNG Export - ui.add_enabled_ui(can_export_png, |ui| { - let btn = egui::Frame::NONE - .fill(if can_export_png { - egui::Color32::from_rgb(71, 108, 155) - } else { - egui::Color32::from_rgb(50, 50, 50) - }) - .corner_radius(4) - .inner_margin(egui::vec2(12.0, 6.0)) - .show(ui, |ui| { - ui.label( - egui::RichText::new("PNG") - .color(if can_export_png { - egui::Color32::WHITE - } else { - egui::Color32::GRAY - }) - .size(font_14), - ); - }); - - if can_export_png && btn.response.interact(egui::Sense::click()).clicked() { - match self.active_tool { - ActiveTool::LogViewer => self.export_chart_png(), - ActiveTool::ScatterPlot => self.export_scatter_plot_png(), - ActiveTool::Histogram => self.export_histogram_png(), - ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => { - self.export_table_png() - } - } - } - - if btn.response.hovered() && can_export_png { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - }); - - // PDF Export - ui.add_enabled_ui(can_export_pdf, |ui| { - let btn = egui::Frame::NONE - .fill(if can_export_pdf { - egui::Color32::from_rgb(155, 71, 71) - } else { - egui::Color32::from_rgb(50, 50, 50) - }) - .corner_radius(4) - .inner_margin(egui::vec2(12.0, 6.0)) - .show(ui, |ui| { - ui.label( - egui::RichText::new("PDF") - .color(if can_export_pdf { - egui::Color32::WHITE - } else { - egui::Color32::GRAY - }) - .size(font_14), - ); - }); - - if can_export_pdf && btn.response.interact(egui::Sense::click()).clicked() { - match self.active_tool { - ActiveTool::LogViewer => self.export_chart_pdf(), - ActiveTool::ScatterPlot => self.export_scatter_plot_pdf(), - ActiveTool::Histogram => self.export_histogram_pdf(), - ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => { - self.export_table_pdf() - } - } - } - - if btn.response.hovered() && can_export_pdf { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - }); - }); - - // Show appropriate help text based on tool and state - if !has_file { - ui.add_space(4.0); - ui.label( - egui::RichText::new("Load a file to enable export") - .size(font_12) - .color(egui::Color32::from_rgb(100, 100, 100)) - .italics(), - ); - } else if self.active_tool == ActiveTool::LogViewer && !has_channels { - ui.add_space(4.0); - ui.label( - egui::RichText::new("Select channels to enable chart export") - .size(font_12) - .color(egui::Color32::from_rgb(100, 100, 100)) - .italics(), - ); + if export.clicked() { + self.show_export_dialog = true; } }); + + // Show appropriate help text based on tool and state + if !has_file { + ui.add_space(4.0); + ui.label( + egui::RichText::new("Load a file to enable export") + .small() + .color(self.tokens.ink_subtle) + .italics(), + ); + } else if self.active_tool == ActiveTool::LogViewer && !has_channels { + ui.add_space(4.0); + ui.label( + egui::RichText::new("Select channels to enable chart export") + .small() + .color(self.tokens.ink_subtle) + .italics(), + ); + } + }); } } diff --git a/src/ui/update_dialog.rs b/src/ui/update_dialog.rs index a2d1b495..3997cade 100644 --- a/src/ui/update_dialog.rs +++ b/src/ui/update_dialog.rs @@ -6,6 +6,7 @@ use eframe::egui; use rust_i18n::t; use crate::app::UltraLogApp; +use crate::ui::components; use crate::updater::{InstallResult, UpdateInfo, UpdateState}; impl UltraLogApp { @@ -18,7 +19,7 @@ impl UltraLogApp { let mut open = true; let mut should_close = false; - egui::Window::new(t!("update.update_available")) + let window_response = components::window(t!("update.update_available")) .open(&mut open) .resizable(false) .collapsible(false) @@ -44,6 +45,11 @@ impl UltraLogApp { _ => {} }); + components::finish_window( + ctx, + window_response.as_ref().map(|r| &r.response), + &mut open, + ); if !open || should_close { self.show_update_dialog = false; // Reset state if user dismissed without updating @@ -64,7 +70,7 @@ impl UltraLogApp { ui.label( egui::RichText::new(t!("update.new_version_available")) - .size(self.scaled_font(18.0)) + .text_style(crate::ui::fonts::text_style(crate::ui::fonts::style::TITLE)) .strong(), ); @@ -73,14 +79,14 @@ impl UltraLogApp { // Version comparison ui.horizontal(|ui| { ui.label(t!("update.current_version")); - ui.label(egui::RichText::new(&info.current_version).color(egui::Color32::GRAY)); + ui.label(egui::RichText::new(&info.current_version).color(self.tokens.ink_muted)); }); ui.horizontal(|ui| { ui.label(t!("update.new_version")); ui.label( egui::RichText::new(&info.new_version) - .color(egui::Color32::LIGHT_GREEN) + .color(self.tokens.success) .strong(), ); }); @@ -91,7 +97,7 @@ impl UltraLogApp { let size_mb = info.download_size as f64 / (1024.0 * 1024.0); ui.label( egui::RichText::new(t!("update.download_size", size = format!("{:.1}", size_mb))) - .color(egui::Color32::GRAY), + .color(self.tokens.ink_subtle), ); ui.add_space(10.0); @@ -134,7 +140,7 @@ impl UltraLogApp { ui.vertical_centered(|ui| { ui.add_space(20.0); - ui.label(egui::RichText::new(t!("update.downloading")).size(self.scaled_font(16.0))); + ui.label(egui::RichText::new(t!("update.downloading")).heading()); ui.add_space(15.0); @@ -142,7 +148,7 @@ impl UltraLogApp { ui.add_space(15.0); - ui.label(egui::RichText::new(t!("update.please_wait")).color(egui::Color32::GRAY)); + ui.label(egui::RichText::new(t!("update.please_wait")).color(self.tokens.ink_subtle)); ui.add_space(20.0); }); @@ -158,9 +164,13 @@ impl UltraLogApp { ui.add_space(20.0); ui.label( - egui::RichText::new(t!("update.download_complete")) - .size(self.scaled_font(16.0)) - .color(egui::Color32::LIGHT_GREEN), + egui::RichText::new(format!( + "{} {}", + crate::ui::components::Status::Success.icon(), + t!("update.download_complete") + )) + .heading() + .color(self.tokens.success), ); ui.add_space(15.0); @@ -168,13 +178,15 @@ impl UltraLogApp { ui.label(t!("update.install_instructions")); #[cfg(target_os = "windows")] - ui.label(egui::RichText::new(t!("update.windows_restart")).color(egui::Color32::GRAY)); + ui.label( + egui::RichText::new(t!("update.windows_restart")).color(self.tokens.ink_subtle), + ); #[cfg(target_os = "macos")] - ui.label(egui::RichText::new(t!("update.macos_dmg")).color(egui::Color32::GRAY)); + ui.label(egui::RichText::new(t!("update.macos_dmg")).color(self.tokens.ink_subtle)); #[cfg(target_os = "linux")] - ui.label(egui::RichText::new(t!("update.linux_restart")).color(egui::Color32::GRAY)); + ui.label(egui::RichText::new(t!("update.linux_restart")).color(self.tokens.ink_subtle)); ui.add_space(15.0); @@ -215,9 +227,13 @@ impl UltraLogApp { ui.add_space(20.0); ui.label( - egui::RichText::new(t!("update.update_error")) - .size(self.scaled_font(16.0)) - .color(egui::Color32::from_rgb(191, 78, 48)), + egui::RichText::new(format!( + "{} {}", + crate::ui::components::Status::Danger.icon(), + t!("update.update_error") + )) + .heading() + .color(self.tokens.danger), ); ui.add_space(15.0); diff --git a/src/ui/widgets/track_map.rs b/src/ui/widgets/track_map.rs index 4f91b891..8e797f2d 100644 --- a/src/ui/widgets/track_map.rs +++ b/src/ui/widgets/track_map.rs @@ -24,9 +24,8 @@ use crate::state::{ActiveTool, ColorSignature, GpsChannelCache, MapView, TrackCa use super::DataWidget; const EARTH_RADIUS_M: f64 = 6_378_137.0; -const TRACK_STROKE_WIDTH: f32 = 2.0; +const TRACK_STROKE_WIDTH: f32 = 3.0; const FADED_STROKE_WIDTH: f32 = 1.0; -const TRACK_BG: egui::Color32 = egui::Color32::from_rgb(20, 22, 28); const MAX_RENDER_POINTS: usize = 20_000; type ColorCacheStatus = (Option<(usize, usize)>, Colormap, Option<(f64, f64)>); static TILE_SOURCE: OnceLock = OnceLock::new(); @@ -372,6 +371,7 @@ fn render_toolbar(ui: &mut egui::Ui, app: &mut UltraLogApp, tab_idx: usize) { ui.horizontal_wrapped(|ui| { // Color by selector let selected_channels = app.tabs[tab_idx].selected_channels.clone(); + let tab_file = app.tabs[tab_idx].file_index; let color_label = match app.tabs[tab_idx].data_panel_state.track_map.color_channel { Some(i) => selected_channels .get(i) @@ -390,7 +390,12 @@ fn render_toolbar(ui: &mut egui::Ui, app: &mut UltraLogApp, tab_idx: usize) { { st.color_channel = None; } + // Log A only: the track is log A's, and log B's values do not line up with its + // records. for (i, sc) in selected_channels.iter().enumerate() { + if sc.file_index != tab_file { + continue; + } if ui .selectable_label(st.color_channel == Some(i), sc.channel.name()) .clicked() @@ -406,21 +411,21 @@ fn render_toolbar(ui: &mut egui::Ui, app: &mut UltraLogApp, tab_idx: usize) { ui.label(t!("track_map.colormap")); let st = &mut app.tabs[tab_idx].data_panel_state.track_map; let cmap_label = match st.colormap { - Colormap::Viridis => t!("track_map.colormap.viridis"), - Colormap::Turbo => t!("track_map.colormap.turbo"), + Colormap::Sequential => t!("track_map.colormap.sequential"), + Colormap::Diverging => t!("track_map.colormap.diverging"), }; egui::ComboBox::from_id_salt("track_map_colormap") .selected_text(cmap_label) .show_ui(ui, |ui| { ui.selectable_value( &mut st.colormap, - Colormap::Viridis, - t!("track_map.colormap.viridis"), + Colormap::Sequential, + t!("track_map.colormap.sequential"), ); ui.selectable_value( &mut st.colormap, - Colormap::Turbo, - t!("track_map.colormap.turbo"), + Colormap::Diverging, + t!("track_map.colormap.diverging"), ); }); @@ -630,7 +635,7 @@ fn render_canvas(ui: &mut egui::Ui, app: &mut UltraLogApp, tab_idx: usize, file_ let canvas_size = egui::vec2(avail.x, avail.y.max(160.0).max(footer_band_h + 80.0)); let (rect, _) = ui.allocate_exact_size(canvas_size, egui::Sense::hover()); let painter = ui.painter_at(rect); - painter.rect_filled(rect, 4.0, TRACK_BG); + painter.rect_filled(rect, 4.0, app.tokens.plot_bg); let map_rect = egui::Rect::from_min_max( rect.min, @@ -754,35 +759,58 @@ fn render_canvas(ui: &mut egui::Ui, app: &mut UltraLogApp, tab_idx: usize, file_ }; let colors_ref = cache.color_cache.as_deref(); - for pair in points_screen.windows(2) { - if pair[0].segment_id.is_none() || pair[0].segment_id != pair[1].segment_id { - continue; + let t = app.tokens; + // Two passes: a 1px bg_void casing under every segment, then the coloured trace, so the + // line separates from a satellite background. + for casing in [true, false] { + for pair in points_screen.windows(2) { + if pair[0].segment_id.is_none() || pair[0].segment_id != pair[1].segment_id { + continue; + } + let (Some(a), Some(b)) = (pair[0].position, pair[1].position) else { + continue; + }; + let record = pair[1].record; + let in_window = record > lap_start && record <= lap_end; + let color = match colors_ref { + Some(colors) => colors + .get(record.saturating_sub(1)) + .copied() + .unwrap_or(t.line_control), + None => t.series_3, + }; + let (color, width) = if in_window { + (color, TRACK_STROKE_WIDTH) + } else { + (color.gamma_multiply(0.25), FADED_STROKE_WIDTH) + }; + if casing { + if in_window { + map_painter.line_segment([a, b], egui::Stroke::new(width + 2.0, t.bg_void)); + } + } else { + map_painter.line_segment([a, b], egui::Stroke::new(width, color)); + } } - let (Some(a), Some(b)) = (pair[0].position, pair[1].position) else { - continue; - }; - let record = pair[1].record; - let in_window = record > lap_start && record <= lap_end; - let color = match colors_ref { - Some(colors) => colors - .get(record.saturating_sub(1)) - .copied() - .unwrap_or(egui::Color32::LIGHT_GRAY), - None => egui::Color32::from_rgb(120, 200, 255), - }; - let (color, width) = if in_window { - (color, TRACK_STROKE_WIDTH) - } else { - (color.gamma_multiply(0.25), FADED_STROKE_WIDTH) - }; - map_painter.line_segment([a, b], egui::Stroke::new(width, color)); } - if let Some(record) = app.tabs[tab_idx].cursor_record + // Hidden while the cursor is outside log A's span (a compare tab's range can be wider), + // instead of pinning the dot to the track's end. + let tab = &app.tabs[tab_idx]; + let in_span = tab + .cursor_time + .is_none_or(|t| app.record_at(tab.file_index, t).is_some()); + if in_span + && let Some(record) = tab.cursor_record && let Some(position) = project_record(record) { - map_painter.circle_stroke(position, 6.0, egui::Stroke::new(2.0, egui::Color32::WHITE)); - map_painter.circle_filled(position, 4.0, egui::Color32::from_rgb(255, 200, 0)); + // Current position: a plot_cursor dot with a 2px bg_void ring + map_painter.circle( + position, + 4.0, + app.tokens.plot_cursor, + egui::Stroke::new(2.0, app.tokens.bg_void), + ); } if response.clicked() @@ -863,7 +891,7 @@ fn render_canvas(ui: &mut egui::Ui, app: &mut UltraLogApp, tab_idx: usize, file_ let t1 = (i + 1) as f32 / steps as f32; let x0 = bar_rect.left() + t0 * bar_rect.width(); let x1 = bar_rect.left() + t1 * bar_rect.width(); - let color = colormap_sample(colormap, (t0 + t1) * 0.5); + let color = colormap_sample(&app.tokens, colormap, (t0 + t1) * 0.5); painter.rect_filled( egui::Rect::from_min_max( egui::pos2(x0, bar_rect.top()), @@ -900,17 +928,23 @@ fn render_canvas(ui: &mut egui::Ui, app: &mut UltraLogApp, tab_idx: usize, file_ } } +/// The `(file, channel)` the track is coloured by. Log A only: the track is log A's, and a log-B +/// channel's values do not line up with its records (issue #98). +fn color_source(app: &UltraLogApp, tab_idx: usize) -> Option<(usize, usize)> { + let tab = &app.tabs[tab_idx]; + let slot = tab.data_panel_state.track_map.color_channel?; + tab.selected_channels + .get(slot) + .filter(|selected| selected.file_index == tab.file_index) + .map(|selected| (selected.file_index, selected.channel_index)) +} + fn ensure_color_cache(app: &mut UltraLogApp, tab_idx: usize) -> ColorCacheStatus { let state = &app.tabs[tab_idx].data_panel_state.track_map; let colormap = state.colormap; let requested_min = state.color_min; let requested_max = state.color_max; - let channel = state.color_channel.and_then(|slot| { - app.tabs[tab_idx] - .selected_channels - .get(slot) - .map(|selected| (selected.file_index, selected.channel_index)) - }); + let channel = color_source(app, tab_idx); let (data_address, data_length) = channel.map_or((0, 0), |(file_index, channel_index)| { let values = app.get_channel_data_ref(file_index, channel_index); (values.as_ptr() as usize, values.len()) @@ -920,6 +954,7 @@ fn ensure_color_cache(app: &mut UltraLogApp, tab_idx: usize) -> ColorCacheStatus data_address, data_length, colormap, + theme_generation: app.theme_generation, requested_min, requested_max, }; @@ -960,7 +995,14 @@ fn ensure_color_cache(app: &mut UltraLogApp, tab_idx: usize) -> ColorCacheStatus } else { (minimum - 0.5, maximum + 0.5) }; - let colors = build_color_cache(record_count, colormap, minimum, maximum, values); + let colors = build_color_cache( + &app.tokens, + record_count, + colormap, + minimum, + maximum, + values, + ); let colors: std::sync::Arc<[egui::Color32]> = colors.into(); Some((colors, (minimum, maximum))) }); @@ -982,6 +1024,7 @@ fn ensure_color_cache(app: &mut UltraLogApp, tab_idx: usize) -> ColorCacheStatus } fn build_color_cache( + tokens: &crate::ui::theme::Tokens, record_count: usize, colormap: Colormap, minimum: f64, @@ -989,14 +1032,16 @@ fn build_color_cache( values: &[f64], ) -> Vec { let span = (maximum - minimum).max(f64::EPSILON); + // One lookup per record: a 200k-record log rebuilds this on every theme change. + let lut = crate::colormap::ColormapLut::new(tokens, colormap); (0..record_count.saturating_sub(1)) .map(|index| { let value = values.get(index + 1).copied().unwrap_or(f64::NAN); if value.is_finite() { let position = ((value - minimum) / span).clamp(0.0, 1.0) as f32; - colormap_sample(colormap, position) + lut.get(position) } else { - egui::Color32::DARK_GRAY + tokens.line_control } }) .collect() @@ -1160,8 +1205,7 @@ fn draw_tiles( ) { let src = tile_source(ui.ctx(), disk_cache_max_mb); - let alpha = (opacity.clamp(0.0, 1.0) * 255.0).round() as u8; - let tint = egui::Color32::from_white_alpha(alpha); + let tint = crate::ui::theme::image_tint(opacity); // Inverse: world_px = canvas_center + (world - center_world) * view_zoom + pan. let view_scale = f64::from(view_zoom); @@ -1240,7 +1284,7 @@ fn draw_tiles( painter.rect_stroke( dst, 0.0, - egui::Stroke::new(1.0, egui::Color32::from_rgb(40, 50, 60)), + egui::Stroke::new(1.0, crate::ui::theme::current(ui.ctx()).line), egui::StrokeKind::Inside, ); } @@ -1252,6 +1296,37 @@ mod tests { use std::path::PathBuf; use super::*; + + #[test] + fn the_track_is_never_coloured_by_log_b() { + let mut app = app_with_active_tile_map(); + app.files.push(app.files[0].clone()); + app.set_compare_file(0, Some(1)); + app.add_channel(0, 0); // brings log B's match along + assert_eq!(app.tabs[0].selected_channels.len(), 2); + + app.tabs[0].data_panel_state.track_map.color_channel = Some(0); + assert_eq!(color_source(&app, 0), Some((0, 0))); + app.tabs[0].data_panel_state.track_map.color_channel = Some(1); + assert_eq!(color_source(&app, 0), None); + } + + #[test] + fn track_colours_follow_the_theme_map_and_mark_missing_values() { + use crate::ui::theme::tokens as ut; + for t in [ut::NIGHT, ut::CVD] { + let values = [0.0, 0.0, 50.0, 100.0, f64::NAN]; + let colors = + build_color_cache(&t, values.len(), Colormap::Sequential, 0.0, 100.0, &values); + // Segment i takes the colour of record i + 1. + assert_eq!(colors[0], t.seq_0); + assert_eq!(colors[2], t.seq_6); + assert_eq!( + colors[3], t.line_control, + "NaN reads as no data, not as a low value" + ); + } + } use crate::parsers::aim::AimChannel; use crate::parsers::types::Meta; use crate::parsers::{Channel, EcuType, Log, Value}; @@ -1377,7 +1452,7 @@ mod tests { ui, TileProviderId::EsriWorldImagery, egui::TextStyle::Body.resolve(ui.style()), - egui::Color32::WHITE, + ui.visuals().text_color(), max_width, ); row_count = galley.rows.len(); diff --git a/tests/core/settings_tests.rs b/tests/core/settings_tests.rs index cb7895bb..201b86bf 100644 --- a/tests/core/settings_tests.rs +++ b/tests/core/settings_tests.rs @@ -328,3 +328,41 @@ fn test_settings_handles_unknown_fields() { // Should successfully deserialize, ignoring unknown field assert!(result.is_ok()); } + +// ============================================ +// Theme settings (Night Drive, 3.0.0) +// ============================================ + +#[test] +fn test_settings_from_before_3_0_open_at_stealth() { + // A 2.x settings file has no theme fields. It must load at the in-app default, + // intensity 0 (Stealth), and with High contrast off. + let json = r#"{"version":1,"language":"English","color_blind_mode":true}"#; + let settings: UserSettings = serde_json::from_str(json).unwrap(); + + assert_eq!(settings.ultra_intensity, 0); + assert!(!settings.high_contrast); + assert!(settings.color_blind_mode); +} + +#[test] +fn test_settings_default_theme_fields() { + let settings = UserSettings::default(); + assert_eq!(settings.ultra_intensity, 0); + assert!(!settings.high_contrast); +} + +#[test] +fn test_settings_theme_fields_round_trip() { + let settings = UserSettings { + ultra_intensity: 88, + high_contrast: true, + ..UserSettings::default() + }; + let json = serde_json::to_string(&settings).unwrap(); + let back: UserSettings = serde_json::from_str(&json).unwrap(); + + assert_eq!(back.ultra_intensity, 88); + assert!(back.high_contrast); + assert_eq!(back, settings); +} diff --git a/tests/core/state_tests.rs b/tests/core/state_tests.rs index 3f847771..0c1c0338 100644 --- a/tests/core/state_tests.rs +++ b/tests/core/state_tests.rs @@ -12,11 +12,12 @@ use ultralog::parsers::Channel; use ultralog::parsers::haltech::{ChannelType, HaltechChannel}; use ultralog::parsers::types::{EcuType, Log, Value}; use ultralog::state::{ - ActiveTool, CHART_COLORS, COLORBLIND_COLORS, CacheKey, HistogramConfig, HistogramGridSize, - HistogramMode, HistogramState, LoadResult, LoadedFile, LoadingState, MAX_CHANNELS, - MAX_CHART_POINTS, SUPPORTED_EXTENSIONS, ScatterPlotConfig, ScatterPlotState, SelectedChannel, - SelectedHeatmapPoint, SelectedHistogramCell, Tab, ToastType, + ActiveTool, CacheKey, HistogramConfig, HistogramGridSize, HistogramMode, HistogramState, + LoadResult, LoadedFile, LoadingState, MAX_CHANNELS, MAX_CHART_POINTS, SUPPORTED_EXTENSIONS, + ScatterPlotConfig, ScatterPlotState, SelectedChannel, SelectedHeatmapPoint, + SelectedHistogramCell, Tab, ToastType, next_color_index, }; +use ultralog::ui::theme::tokens as ultralog_theme; // ============================================ // Constant Tests @@ -63,64 +64,34 @@ fn test_supported_extensions_contains_common() { // Color Palette Tests // ============================================ -#[test] -fn test_chart_colors_not_empty() { - assert!(!CHART_COLORS.is_empty(), "Should have chart colors"); - assert_eq!(CHART_COLORS.len(), 10, "Should have 10 chart colors"); -} - -#[test] -fn test_chart_colors_valid_rgb() { - for (i, color) in CHART_COLORS.iter().enumerate() { - assert_eq!(color.len(), 3, "Color {} should have 3 components", i); - // RGB values are u8, so already in 0-255 range - } -} - -#[test] -fn test_chart_colors_unique() { - let mut unique_colors: Vec<&[u8; 3]> = Vec::new(); - for color in CHART_COLORS { - assert!( - !unique_colors.contains(&color), - "Chart colors should be unique" - ); - unique_colors.push(color); +fn channel_with_color(color_index: usize) -> SelectedChannel { + SelectedChannel { + file_index: 0, + channel_index: color_index, + channel: Channel::Haltech(HaltechChannel { + name: format!("ch{color_index}"), + id: String::new(), + r#type: ChannelType::Raw, + display_min: None, + display_max: None, + }), + color_index, } } #[test] -fn test_colorblind_colors_not_empty() { - assert!( - !COLORBLIND_COLORS.is_empty(), - "Should have colorblind colors" - ); - assert_eq!( - COLORBLIND_COLORS.len(), - 10, - "Should have 10 colorblind colors" - ); +fn test_next_color_index_fills_the_lowest_gap() { + let selected: Vec<_> = [0, 1, 3].into_iter().map(channel_with_color).collect(); + assert_eq!(next_color_index(&selected), 2); } #[test] -fn test_colorblind_colors_valid_rgb() { - for (i, color) in COLORBLIND_COLORS.iter().enumerate() { - assert_eq!( - color.len(), - 3, - "Colorblind color {} should have 3 components", - i - ); - } -} - -#[test] -fn test_chart_and_colorblind_same_count() { - assert_eq!( - CHART_COLORS.len(), - COLORBLIND_COLORS.len(), - "Chart and colorblind palettes should have same count" - ); +fn test_next_color_index_continues_past_the_palette() { + // Stacked mode allows 60 channels; slots keep counting so the theme can repeat colours + // with a dashed or dotted stroke instead of reusing slot 0. + let selected: Vec<_> = (0..12).map(channel_with_color).collect(); + assert_eq!(next_color_index(&selected), 12); + assert_eq!(next_color_index(&[]), 0); } // ============================================ @@ -133,36 +104,59 @@ fn test_toast_type_default() { assert!(matches!(toast, ToastType::Info)); } -#[test] -fn test_toast_type_colors() { - let info_color = ToastType::Info.color(); - let success_color = ToastType::Success.color(); - let warning_color = ToastType::Warning.color(); - let error_color = ToastType::Error.color(); +const TOAST_TYPES: [ToastType; 4] = [ + ToastType::Info, + ToastType::Success, + ToastType::Warning, + ToastType::Error, +]; + +fn relative_luminance(c: eframe::egui::Color32) -> f32 { + let lin = |v: u8| { + let v = v as f32 / 255.0; + if v <= 0.04045 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + }; + let [r, g, b, _] = c.to_srgba_unmultiplied(); + 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) +} - // Each type should have unique color - assert_ne!(info_color, success_color); - assert_ne!(info_color, warning_color); - assert_ne!(info_color, error_color); - assert_ne!(success_color, warning_color); - assert_ne!(success_color, error_color); - assert_ne!(warning_color, error_color); +fn contrast(a: eframe::egui::Color32, b: eframe::egui::Color32) -> f32 { + let (x, y) = (relative_luminance(a), relative_luminance(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) } #[test] -fn test_toast_type_text_colors() { - let info_text = ToastType::Info.text_color(); - let success_text = ToastType::Success.text_color(); - let warning_text = ToastType::Warning.text_color(); - let error_text = ToastType::Error.text_color(); - - // Warning should have dark text (for amber background) - assert_eq!(warning_text, [30, 30, 30]); - - // Others should have white text - assert_eq!(info_text, [255, 255, 255]); - assert_eq!(success_text, [255, 255, 255]); - assert_eq!(error_text, [255, 255, 255]); +fn test_toast_type_colors_are_distinct_in_every_app_theme() { + for t in [ + ultralog_theme::NIGHT, + ultralog_theme::CVD, + ultralog_theme::HC, + ] { + for (i, a) in TOAST_TYPES.iter().enumerate() { + for b in &TOAST_TYPES[i + 1..] { + assert_ne!(a.accent(&t), b.accent(&t)); + assert_ne!(a.fill(&t), b.fill(&t)); + } + } + } +} + +#[test] +fn test_toast_text_is_readable_on_its_fill() { + for t in [ + ultralog_theme::NIGHT, + ultralog_theme::CVD, + ultralog_theme::HC, + ] { + for toast in TOAST_TYPES { + let ratio = contrast(t.ink, toast.fill(&t)); + assert!(ratio >= 4.5, "{toast:?}: {ratio:.2}"); + } + } } #[test] diff --git a/tests/core/table_generator_tests.rs b/tests/core/table_generator_tests.rs index d376b689..82fa5739 100644 --- a/tests/core/table_generator_tests.rs +++ b/tests/core/table_generator_tests.rs @@ -240,13 +240,14 @@ fn haltech_blip_log_is_all_rejected_with_a_breakdown() { assert!(report.summary().contains("rejected")); // Every event, accepted or not, stays in the list. assert_eq!(events.len(), report.candidates); - // The Haltech sentinel rows are masked, not treated as readings: the - // wideband column carries -2147483.637 samples and nothing panicked or - // produced an absurd delay. + // The raw file has -2147483637 "no reading" sentinels in the wideband + // column. The Haltech parser now fills them like blank fields, so no + // scaled sentinel (-2147483.637 λ) reaches the generators or the UI, and + // nothing panicked or produced an absurd delay. let wb = column(&log, "Wideband O2 1"); assert!( - wb.iter().any(|v| *v < -1e6), - "fixture should contain sentinels" + wb.iter().all(|v| *v > -1e6), + "scaled sentinels must not reach the log" ); for e in events.iter().filter(|e| e.accepted()) { assert!(e.value(0).is_finite() && e.value(0) < 2000.0);