From fd6edd66f5379c29b7e53190e732da9aa22d106e Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Sun, 31 May 2026 15:11:51 +0300 Subject: [PATCH 01/23] GUI: first draft. This iteration work with a debug dump of dso3d12 --- .gitignore | 5 +- Cargo.toml | 30 +- docs/GUI_DESIGN.md | 191 +++ docs/USER_MANUAL.md | 147 +++ dso3d12-parser/Cargo.toml | 15 + dso3d12-parser/src/lib.rs | 423 +++++++ flasher/Cargo.toml | 19 + {src => flasher/src}/flash.rs | 0 gui/Cargo.toml | 29 + gui/src/app.rs | 2084 +++++++++++++++++++++++++++++++++ gui/src/canvas.rs | 553 +++++++++ gui/src/flash.rs | 327 ++++++ gui/src/logging.rs | 107 ++ gui/src/main.rs | 20 + gui/src/range_slider.rs | 282 +++++ gui/src/serial.rs | 221 ++++ gui/src/settings.rs | 123 ++ 17 files changed, 4560 insertions(+), 16 deletions(-) create mode 100644 docs/GUI_DESIGN.md create mode 100644 docs/USER_MANUAL.md create mode 100644 dso3d12-parser/Cargo.toml create mode 100644 dso3d12-parser/src/lib.rs create mode 100644 flasher/Cargo.toml rename {src => flasher/src}/flash.rs (100%) create mode 100644 gui/Cargo.toml create mode 100644 gui/src/app.rs create mode 100644 gui/src/canvas.rs create mode 100644 gui/src/flash.rs create mode 100644 gui/src/logging.rs create mode 100644 gui/src/main.rs create mode 100644 gui/src/range_slider.rs create mode 100644 gui/src/serial.rs create mode 100644 gui/src/settings.rs diff --git a/.gitignore b/.gitignore index 3da2ca5..e6becf5 100644 --- a/.gitignore +++ b/.gitignore @@ -34,5 +34,8 @@ *.orig *.rej -./firmware *.fls + +prompts/ +scripts/ +firmware/ diff --git a/Cargo.toml b/Cargo.toml index b19c84f..c607925 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,19 +1,19 @@ -[package] -name = "wm-tools" -version = "0.1.2" +[workspace] +resolver = "2" +members = [ + "flasher", + "dso3d12-parser", + "gui", +] + +[workspace.package] +version = "0.2.0" edition = "2021" -description = "A tool for Winner Micro MCU (including DSO 3D12 oscilloscope firmware)" +description = "Tools for ZeeWeii DSO3D12 oscilloscope and Winner Micro MCUs" +license = "MIT" repository = "https://github.com/rssdev10/wm-tools" -[[bin]] -name = "flash" -path = "src/flash.rs" - -[dependencies] +[workspace.dependencies] anyhow = "1.0" -clap = { version = "4.0", features = ["derive"] } -colored = "3.0" -crc = "3.0" -indicatif = "0.18" -serialport = "4.9.0" -tabled = "0.20" +thiserror = "2.0" +log = "0.4" diff --git a/docs/GUI_DESIGN.md b/docs/GUI_DESIGN.md new file mode 100644 index 0000000..8bfb73a --- /dev/null +++ b/docs/GUI_DESIGN.md @@ -0,0 +1,191 @@ +# DSO3D12 Viewer — GUI Design + +This document describes the GUI layout, interaction model and state of the +`dso3d12-gui` application. **Update this document whenever the GUI changes.** + +## Window layout + +``` +┌───────────────────────────────────────────────────────────────┐ +│ DSO3D12 │ Port: [▾] [Start Listening] STATUS │ ← menu bar +├──────────────────────────────────────────────┬────────────────┤ +│ │ Measurement │ +│ │ ──────────── │ +│ GRAPH CANVAS │ CH1 V: … │ +│ (oscilloscope grid + │ Time range: … │ +│ traces + cursors) │ ──────────── │ +│ │ ☐ Cursor X │ +│ │ [═══●═════●═] │ +│ │ Δt, freq Hz │ +│ │ │ +│ │ ☐ Cursor Y │ +│ │ [═══●═════●═] │ +│ │ ΔV in volts │ +│ │ ──────────── │ +│ │ CH1/2 stats │ +├──────────────────────────────────────────────┴────────────────┤ +│ ☑ CH1 ☑ CH2 │ ☐ Device X ☐ Device Y │ View: [Line ▾] │ ← data controls +├───────────────────────────────────────────────────────────────┤ +│ [#1 (60000s)] [#2 (60000s)] [#3] … │ ← thumbnails +├───────────────────────────────────────────────────────────────┤ +│ [Load Capture] [Save Capture] │ [Save PNG] [Export Data] │ │ ← actions +│ [App Settings] │ +├───────────────────────────────────────────────────────────────┤ +│ ☑ Show instructions │ +│ ┌── How to capture from a ZeeWeii DSO3D12 ──────────────────┐ │ +│ └───────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────┘ +``` + +## Panels + +### 1. Menu bar (top) +* Application label. +* `Port` `pick_list` of detected serial ports (auto-scanned every 10 s). +* Last-used port shown as text. +* `Listen` / `Stop` toggle button — starts/stops the background serial + listener thread that auto-detects captures. +* Right-aligned **status text** ("Ready.", "Listening…", "Received N captures", + "Receiving… 1234 bytes", error messages). +* Version number shown in window title. + +### 2. Graph canvas +Custom `iced::widget::canvas` (`canvas.rs`, `Scope`). 10×8 grid, centred axes, +yellow CH1, cyan CH2. Renders: +* Traces in **Line / Dot / Smooth** mode (smooth = moving average). +* Pink interactive cursors (range-shaded) for **measurement cursors**. + Cursors can also be moved by clicking/dragging directly on the graph + (nearest cursor line snaps to click position). +* Green thin lines for **device cursors** (data-supplied, read-only) with + numeric labels (sample numbers for X, voltage for Y). +* Optional **axis scales** toggled via "Show scales" setting. Scale values + are derived from user-configurable V/cell and time/cell parameters. + +### 3. Measurement panel (right, 220 px) +Vertical column inside a rounded container. + +**Scale configuration** (always visible): +* **V/cell** text input — volts per division (default 1.0 V). With "V" unit. +* **t/cell** text input — time per division in ms (default 1.0 ms). With "ms" unit. +* Label: "AC mode: 8×V, 12×t cells" — documents the scope grid geometry. + +These values drive all voltage/time calculations throughout the panel and +graph scales. The oscilloscope screen has 8 vertical and 12 horizontal cells. + +**Signal information** (shown when a capture is loaded): +* Voltage range for CH1/CH2 (computed from V/cell × 8 cells, centered at 0). +* Time range (computed from t/cell × 12 cells) + sample count. + +**Two range sliders**, one per axis, each a custom widget (`range_slider.rs`) +drawn with `canvas::Program`. Each slider has **two knobs** (low, high); the +user drags whichever knob is nearer. + +Below each slider the panel shows cursor measurements: +* X cursors → Δt in ms + frequency in Hz (using real t/cell scale). +* Y cursors → ΔV in volts + absolute voltage pair (using real V/cell scale). + +A divider, then per-channel basic stats (`n`, `min`, `max`, `avg`). + +### 4. Data-controls panel (below graph) +* CH1 / CH2 visibility toggles. +* **Device cursor** toggles — **disabled** when no device-cursor data in capture. +* View-mode `pick_list`: `Line`, `Dot`, `Smooth`. + +### 5. Thumbnails strip +`scrollable` horizontal row of buttons, one per loaded capture showing sample +count. The active capture uses `primary` style. Clicking loads that capture +into the main graph. + +### 6. Action buttons row +`Load Capture` · `Save Capture` │ `Save PNG` · `Export Data` │ +`Application Settings`. + +### 7. Instructions panel +Toggleable text block. Visibility persisted in settings. + +### 8. Settings dialog +Full-page replacement view (el15 card-based style). Sections: + +**Serial** (bordered card): +* Default serial port (text input, shows available ports). +* Baud rate (text input, default 115200). +* Auto-listen on startup toggle. +* Beep on new capture toggle. + +**Display** (bordered card): +* Show axis scales toggle. +* Show instructions panel toggle. + +**About** (bordered card): +* App name + version. +* GitHub link. +* Config directory path. +* "Firmware Update…" button opens firmware dialog. + +"Save & Close" button writes to JSON and returns to main view. + +### 9. Firmware update dialog +Full-page replacement view with: +* Warning banner about bricking risk. +* Port picker (pick_list of available ports). +* File browser button for .bin firmware selection. +* Progress bar during flash. +* Status text with step-by-step updates. +* Start Flash / Close buttons. +* Uses XMODEM-1K protocol (same as CLI flasher) in a background thread. + +## Cursor model + +| Kind | Source | Editable | UI location | +|--------------|-----------------|----------|---------------------| +| Measurement | User (sliders) | Yes | Right-hand panel | +| Device | Capture data | No | Data-controls panel | + +Never share state between the two. + +## Serial port + +* Uses `serialport` crate: 115200 baud, 8N1, no flow control. +* Background thread (`serial.rs`) reads continuously. +* Capture detection: 500 ms timeout with no new bytes → parse accumulated buffer. +* Auto-reconnect: on disconnect, retries every 2 s until stopped. +* Selected port persisted in settings; app tries to reconnect on startup. + +## PNG export + +`Save PNG` renders a 1200×600 pixel image of the current capture with grid + +traces, written as a valid PNG file using a minimal built-in encoder (no +external image crate needed). Filename is auto-generated as +`capture_YYYYMMDD_HHMMSS.png` saved to the Downloads directory (no file picker). + +## Persisted state (`settings.json`) + +```jsonc +{ + "serial_port": "/dev/tty.usbserial-1110", + "baud_rate": 115200, + "show_instructions": true, + "show_ch1": true, "show_ch2": true, + "measurement_cursor_x_enabled": false, + "measurement_cursor_y_enabled": false, + "device_cursor_x_enabled": false, + "device_cursor_y_enabled": false, + "view_mode": "Line", + "cursor_x_range": [0.3, 0.7], + "cursor_y_range": [0.3, 0.7], + "show_scales": false, + "alert_on_data": true, + "auto_listen": false +} +``` + +## Logging + +`logging.rs` — global file+stderr logger → `/dso3d12-gui/dso3d12-gui.log`. +Rotates at ~1 MB. Default level: info; override with `RUST_LOG`. + +## Voltage & time assumptions + +* ADC 0..255 mapped to ±5 V (0 = −5 V, 128 = 0 V, 255 ≈ +5 V). +* Sample rate assumed 1 MHz (1 µs/sample) for time calculations. +* These will be refined when device metadata extraction is implemented. diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md new file mode 100644 index 0000000..3163dbd --- /dev/null +++ b/docs/USER_MANUAL.md @@ -0,0 +1,147 @@ +# DSO3D12 Viewer — User Manual + +A desktop application to view, measure and export waveform captures from the +ZeeWeii DSO3D12 pocket oscilloscope. + +## Installing + +```bash +cargo install --path gui +``` + +Or during development: + +```bash +cargo run -p dso3d12-gui +``` + +## Connecting to the oscilloscope + +1. Connect the DSO3D12 via USB (a USB-to-serial adapter appears as e.g. + `/dev/tty.usbserial-1110` on macOS). +2. In the top menu bar, select the port from the **Port** dropdown. +3. Click **Start Listening**. + +The status bar shows "Listening on …". The app will now automatically detect +and display captures when you trigger a dump from the device. + +### Triggering a capture on the device + +1. Feed a signal into the scope. +2. Press **Stop** to freeze the sample buffer. +3. Press **Menu**, then **Stop** so the Channel 1 Measurements menu is visible. +4. **Long-press Save** to initiate the debug data transmission. + +The app detects end-of-transmission after 500 ms of silence and parses the +received data. + +### Auto-reconnect + +If the USB cable is disconnected, the app automatically retries every 2 seconds. +Re-plug and the connection resumes without manual intervention. + +## Loading captures from files + +Click **Load Capture** and select a `.bin` / `.dat` / `.raw` file previously +saved from the device. Files can contain one or many consecutive captures. + +## Viewing the graph + +* **Grid**: 10 × 8 divisions (oscilloscope style). +* **CH1** (yellow), **CH2** (cyan) — toggle visibility in the controls panel. +* **View mode** dropdown: Line, Dot, or Smooth (moving-average filter). + +## Scale configuration + +At the top of the **Measurement** panel you can set the real-world scale: + +| Field | Default | Meaning | +|----------|---------|-------------------------------------------------| +| V/cell | 1.0 V | Volts per vertical division on the scope screen | +| t/cell | 1.0 ms | Time per horizontal division | + +The DSO3D12 screen has **8 vertical** and **12 horizontal** divisions (AC mode). +All voltage and time calculations, axis labels, and PNG scales use these values. + +## Measurement cursors + +In the right-hand **Measurement** panel: + +| Toggle | Slider | Readings | +|------------------|----------------------------|-----------------------------------| +| ☐ Cursor X (time) | Two knobs on horizontal range | Δt (ms), frequency (Hz) | +| ☐ Cursor Y (voltage) | Two knobs on vertical range | ΔV (volts), absolute voltages | + +**Drag the knob closest to your click** in the slider — or **click/drag +directly on the graph** to move the nearest cursor line. The shaded band +between cursors is drawn on the canvas in pink and updates in real time. + +### Signal information + +When a capture is loaded the panel also shows: +* Voltage range for each channel (derived from V/cell setting). +* Total time range (derived from t/cell setting) + sample count. +* Per-channel statistics (sample count, min, max, average ADC). + +## Device cursors + +If a capture contains device-set cursor data, the **Device X** / **Device Y** +toggles in the controls panel become enabled. These cursors are drawn as green +lines and **cannot be moved** — they reflect the device's own measurement state. + +Currently the parser does not extract device-cursor metadata, so these toggles +remain disabled. + +## Thumbnails + +When multiple captures are loaded (from a multi-capture file or via serial), +a scrollable strip of buttons appears below the controls panel. Click any +thumbnail to switch the active capture. + +## Saving & exporting + +| Button | Action | +|-----------------|---------------------------------------------------------------| +| Save Capture | Writes the raw dump to a `.bin` file. | +| Save PNG | Exports a 1200×600 px PNG image of the graph with grid+traces.| +| Export Data | Writes a CSV file with columns `index, ch1 [, ch2]`. | + +## Application Settings + +Click **Application Settings** to open the settings dialog: +* **Default serial port** — pre-filled on startup. +* **Baud rate** — default 115200 (matches the DSO3D12 debug mode). + +Settings are persisted to: +``` +~/Library/Application Support/dso3d12-gui/settings.json (macOS) +~/.config/dso3d12-gui/settings.json (Linux) +%APPDATA%\dso3d12-gui\settings.json (Windows) +``` + +## Logs + +Activity is logged to `/dso3d12-gui/dso3d12-gui.log` and stderr. +Rotation at ~1 MB. Increase verbosity: + +```bash +RUST_LOG=debug dso3d12-gui +``` + +## Troubleshooting + +| Symptom | Fix | +|--------------------------------------|---------------------------------------------------------| +| Port not listed | Check USB connection; try `ls /dev/tty.*` in terminal. | +| "Cannot open port" error | Another process may hold the port; close other apps. | +| Listening but no capture appears | Ensure you long-press Save on the device (not short). | +| Graph empty after load | File may not be DSO3D12 format; check status bar error. | +| Cursors don't appear | Enable them in the Measurement panel checkboxes. | +| Device cursor toggles greyed out | Capture has no device-cursor metadata (normal today). | + +## Voltage & time reference + +* Voltage range = V/cell × 8 cells (centered at 0 → ±4 × V/cell). +* Time range = t/cell × 12 cells. +* Set V/cell and t/cell in the Measurement panel to match your scope settings. +* These defaults will be refined when device metadata parsing is extended. diff --git a/dso3d12-parser/Cargo.toml b/dso3d12-parser/Cargo.toml new file mode 100644 index 0000000..49e5d59 --- /dev/null +++ b/dso3d12-parser/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "dso3d12-parser" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Parser for ZeeWeii DSO3D12 oscilloscope raw debug dumps" + +[dependencies] +thiserror = { workspace = true } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } + +[dev-dependencies] diff --git a/dso3d12-parser/src/lib.rs b/dso3d12-parser/src/lib.rs new file mode 100644 index 0000000..f285105 --- /dev/null +++ b/dso3d12-parser/src/lib.rs @@ -0,0 +1,423 @@ +//! Parser for ZeeWeii DSO3D12 oscilloscope raw debug dumps. +//! +//! Format (as transmitted by the device after the "Long-press Save" debug +//! dump action, captured directly from the serial port): +//! +//! ```text +//! --------CH1--------\r\n +//! NNN \0NNN \0NNN \0... (Buffer Depth samples; default 60000 per channel) +//! [optional] +//! --------CH2--------\r\n +//! NNN \0NNN \0... +//! ``` +//! +//! Each sample is an ASCII decimal integer 0..=255 (one byte of ADC data), +//! followed by a single space (`0x20`) and a NUL byte (`0x00`). + +use thiserror::Error; + +/// Default total buffer depth in samples that the DSO3D12 transmits per +/// channel for a complete debug dump. +pub const DEFAULT_BUFFER_DEPTH: usize = 60_000; + +/// ASCII header marking the start of channel 1 samples. +pub const CH1_MARKER: &[u8] = b"--------CH1--------"; + +/// ASCII header marking the start of channel 2 samples. +pub const CH2_MARKER: &[u8] = b"--------CH2--------"; + +/// A single decoded oscilloscope capture. +/// +/// Sample values are raw 8-bit ADC counts (0..=255). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Capture { + /// Channel 1 samples (always present). + pub ch1: Vec, + /// Channel 2 samples (present only if the dump contained `--CH2--`). + pub ch2: Option>, +} + +impl Capture { + /// Returns the number of samples in the longest channel. + pub fn sample_count(&self) -> usize { + let n1 = self.ch1.len(); + let n2 = self.ch2.as_ref().map(|v| v.len()).unwrap_or(0); + n1.max(n2) + } +} + +/// Errors returned by the parser. +#[derive(Debug, Error)] +pub enum ParseError { + #[error("no CH1 marker found in input")] + MissingCh1Marker, + + #[error("malformed sample at byte offset {offset}: {reason}")] + MalformedSample { offset: usize, reason: &'static str }, +} + +/// Parse every capture contained in `data`. +/// +/// A new capture starts at every occurrence of [`CH1_MARKER`]. The parser is +/// tolerant: it will skip over any non-sample bytes (extra whitespace, status +/// strings printed by the device between captures, etc.) until it sees the +/// next ASCII digit or marker. +pub fn parse_captures(data: &[u8]) -> Result, ParseError> { + let mut captures = Vec::new(); + let mut cursor = 0; + + while let Some(start) = find_subslice(&data[cursor..], CH1_MARKER) { + let abs = cursor + start; + let ch1_body = skip_marker_eol(data, abs + CH1_MARKER.len()); + + // CH1 samples end either at the CH2 marker or at the next CH1 marker + // (next capture) or at end of file. + let ch1_end = find_next_boundary(data, ch1_body); + let ch1 = decode_ascii_samples(&data[ch1_body..ch1_end]); + + // Check whether the next boundary is a CH2 within the same capture. + let mut next_cursor = ch1_end; + let ch2 = if data[ch1_end..].starts_with(CH2_MARKER) { + let ch2_body = skip_marker_eol(data, ch1_end + CH2_MARKER.len()); + let ch2_end = find_next_boundary(data, ch2_body); + let samples = decode_ascii_samples(&data[ch2_body..ch2_end]); + next_cursor = ch2_end; + Some(samples) + } else { + None + }; + + captures.push(Capture { ch1, ch2 }); + cursor = next_cursor; + } + + if captures.is_empty() { + return Err(ParseError::MissingCh1Marker); + } + Ok(captures) +} + +/// Parse exactly one capture: the first `--------CH1--------` block found in +/// `data` plus its optional companion `--------CH2--------` block. +pub fn parse_first(data: &[u8]) -> Result { + parse_captures(data).map(|mut v| v.remove(0)) +} + +/// Returns `true` if `data` looks like (or could become) the start of a valid +/// DSO3D12 raw debug dump — used by the live-serial state machine to decide +/// whether to keep buffering or discard a noise stream. +pub fn looks_like_dump_start(data: &[u8]) -> bool { + if data.is_empty() { + return false; + } + // Accept any prefix of the marker as a partial header. + let limit = data.len().min(CH1_MARKER.len()); + CH1_MARKER.starts_with(&data[..limit]) +} + +/// Convert a raw ADC byte (0..=255) into a normalized signed value in `[-1, 1]`, +/// matching the way the device places "screen center" at code 128. +/// +/// This is the simplest scaling we can apply without device-side metadata +/// (volt-scale, probe mode, zero offset). Callers that have access to the +/// settings buffer can substitute their own conversion. +pub fn adc_to_normalized(byte: u8) -> f32 { + // Match ZeeTweak: y_screen = 255 - raw; center = 128. + let centered = (255i16 - byte as i16) - 128; + centered as f32 / 128.0 +} + +// ---------- internals ---------- + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || haystack.len() < needle.len() { + return None; + } + haystack + .windows(needle.len()) + .position(|w| w == needle) +} + +/// Advance past `\r`, `\n`, or `\r\n` line endings immediately after a marker. +fn skip_marker_eol(data: &[u8], mut i: usize) -> usize { + if i < data.len() && data[i] == b'\r' { + i += 1; + } + if i < data.len() && data[i] == b'\n' { + i += 1; + } + i +} + +/// Find the next CH1 or CH2 marker in `data` starting at `from`, or end of +/// data if none. +fn find_next_boundary(data: &[u8], from: usize) -> usize { + let mut best = data.len(); + if let Some(p) = find_subslice(&data[from..], CH1_MARKER) { + best = best.min(from + p); + } + if let Some(p) = find_subslice(&data[from..], CH2_MARKER) { + best = best.min(from + p); + } + best +} + +/// Decode a sequence of ASCII samples of the form `"NN \0NNN \0..."` into +/// byte values. Malformed tokens are silently skipped; this keeps the parser +/// robust against the occasional dropped serial byte. +fn decode_ascii_samples(body: &[u8]) -> Vec { + let mut out = Vec::with_capacity(DEFAULT_BUFFER_DEPTH); + let mut acc: u32 = 0; + let mut have_digit = false; + + for &b in body { + match b { + b'0'..=b'9' => { + acc = acc.saturating_mul(10).saturating_add((b - b'0') as u32); + have_digit = true; + } + // Terminators that complete a token. + b' ' | 0x00 | b'\r' | b'\n' | b'\t' => { + if have_digit { + if acc <= 255 { + out.push(acc as u8); + } + acc = 0; + have_digit = false; + } + } + _ => { + // Unknown byte: drop the in-flight token to avoid pollution. + acc = 0; + have_digit = false; + } + } + } + if have_digit && acc <= 255 { + out.push(acc as u8); + } + out +} + +// ── Metadata (timestamps appended after capture data) ────────────────────── + +use chrono::{DateTime, Local, TimeZone}; + +/// Metadata marker appended after all capture data. ZeeTweak and our parser +/// both ignore this because it doesn't start with `--------CH1--------`. +pub const METADATA_MARKER: &[u8] = b"\n---DSO3D12-META---\n"; + +/// A capture paired with a timestamp indicating when it was received. +#[derive(Debug, Clone)] +pub struct CaptureEntry { + pub capture: Capture, + pub timestamp: DateTime, +} + +/// Parse the metadata section (timestamps) from file bytes. +/// Returns `None` if no metadata marker is found or if parsing fails. +pub fn parse_metadata(data: &[u8]) -> Option>> { + let marker = METADATA_MARKER; + // Find the metadata marker (search from end for efficiency) + let pos = data.windows(marker.len()).rposition(|w| w == marker)?; + let json_start = pos + marker.len(); + let json_bytes = &data[json_start..]; + let json_end = json_bytes.iter().position(|&b| b == b'\n').unwrap_or(json_bytes.len()); + let json_str = std::str::from_utf8(&json_bytes[..json_end]).ok()?; + let ts_strings: Vec = serde_json::from_str(json_str).ok()?; + let timestamps: Vec> = ts_strings + .iter() + .filter_map(|s| { + chrono::DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%:z") + .ok() + .map(|dt| Local.from_utc_datetime(&dt.naive_utc())) + }) + .collect(); + if timestamps.is_empty() { + None + } else { + Some(timestamps) + } +} + +/// Load captures from file bytes with timestamps. +/// Parses both the raw capture data and optional metadata section. +pub fn load_captures_with_metadata(data: &[u8]) -> Result, ParseError> { + let captures = parse_captures(data)?; + let timestamps = parse_metadata(data); + let now = Local::now(); + let entries = captures + .into_iter() + .enumerate() + .map(|(i, capture)| { + let timestamp = timestamps + .as_ref() + .and_then(|ts| ts.get(i).copied()) + .unwrap_or(now); + CaptureEntry { capture, timestamp } + }) + .collect(); + Ok(entries) +} + +/// Serialize captures with metadata (timestamps) into the DSO3D12 file format. +/// The output is compatible with ZeeTweak and other parsers — metadata is appended +/// at the end after a marker they won't recognize as a channel header. +pub fn serialize_captures(entries: &[CaptureEntry]) -> Vec { + use std::io::Write; + let mut buf = Vec::new(); + + for entry in entries { + // Write CH1 + buf.extend_from_slice(b"--------CH1--------\r\n"); + for (i, &sample) in entry.capture.ch1.iter().enumerate() { + if i > 0 { + buf.extend_from_slice(b" \0"); + } + write!(buf, "{sample}").unwrap(); + } + buf.extend_from_slice(b" \0"); + + // Write CH2 if present + if let Some(ref ch2) = entry.capture.ch2 { + buf.extend_from_slice(b"--------CH2--------\r\n"); + for (i, &sample) in ch2.iter().enumerate() { + if i > 0 { + buf.extend_from_slice(b" \0"); + } + write!(buf, "{sample}").unwrap(); + } + buf.extend_from_slice(b" \0"); + } + } + + // Append metadata (timestamps) + buf.extend_from_slice(METADATA_MARKER); + let timestamps: Vec = entries + .iter() + .map(|e| e.timestamp.format("%Y-%m-%dT%H:%M:%S%:z").to_string()) + .collect(); + if let Ok(meta_json) = serde_json::to_string(×tamps) { + buf.extend_from_slice(meta_json.as_bytes()); + } + buf.push(b'\n'); + buf +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_dump(ch1: &[u8], ch2: Option<&[u8]>) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(CH1_MARKER); + out.extend_from_slice(b"\r\n"); + for s in ch1 { + out.extend_from_slice(format!("{} ", s).as_bytes()); + out.push(0); + } + if let Some(c2) = ch2 { + out.extend_from_slice(CH2_MARKER); + out.extend_from_slice(b"\r\n"); + for s in c2 { + out.extend_from_slice(format!("{} ", s).as_bytes()); + out.push(0); + } + } + out + } + + #[test] + fn parses_single_ch1_capture() { + let dump = make_dump(&[137, 138, 137, 139], None); + let cap = parse_first(&dump).unwrap(); + assert_eq!(cap.ch1, vec![137, 138, 137, 139]); + assert!(cap.ch2.is_none()); + } + + #[test] + fn parses_dual_channel_capture() { + let dump = make_dump(&[10, 20, 30], Some(&[40, 50, 60])); + let cap = parse_first(&dump).unwrap(); + assert_eq!(cap.ch1, vec![10, 20, 30]); + assert_eq!(cap.ch2.as_deref(), Some(&[40u8, 50, 60][..])); + } + + #[test] + fn parses_multiple_consecutive_captures() { + let mut data = make_dump(&[1, 2, 3], None); + data.extend_from_slice(&make_dump(&[4, 5, 6], Some(&[7, 8]))); + let caps = parse_captures(&data).unwrap(); + assert_eq!(caps.len(), 2); + assert_eq!(caps[0].ch1, vec![1, 2, 3]); + assert_eq!(caps[1].ch1, vec![4, 5, 6]); + assert_eq!(caps[1].ch2.as_deref(), Some(&[7u8, 8][..])); + } + + #[test] + fn missing_marker_is_error() { + let err = parse_captures(b"no markers here").unwrap_err(); + matches!(err, ParseError::MissingCh1Marker); + } + + #[test] + fn skips_malformed_tokens() { + let mut data = Vec::new(); + data.extend_from_slice(CH1_MARKER); + data.extend_from_slice(b"\r\n"); + data.extend_from_slice(b"137 \x00garbage \x00200 \x00"); + let cap = parse_first(&data).unwrap(); + assert_eq!(cap.ch1, vec![137, 200]); + } + + #[test] + fn clamps_values_above_255() { + let mut data = Vec::new(); + data.extend_from_slice(CH1_MARKER); + data.extend_from_slice(b"\r\n"); + data.extend_from_slice(b"300 \x00128 \x00"); + let cap = parse_first(&data).unwrap(); + // 300 is out-of-range for u8 and gets dropped. + assert_eq!(cap.ch1, vec![128]); + } + + #[test] + fn adc_normalization_centers_at_zero() { + assert!((adc_to_normalized(127) - (0.0_f32)).abs() < 0.01); + assert!(adc_to_normalized(0) > 0.9); + assert!(adc_to_normalized(255) < -0.9); + } + + #[test] + fn looks_like_dump_start_accepts_prefix() { + assert!(looks_like_dump_start(b"--")); + assert!(looks_like_dump_start(b"--------CH1")); + assert!(looks_like_dump_start(b"--------CH1--------")); + assert!(!looks_like_dump_start(b"hello")); + assert!(!looks_like_dump_start(b"")); + } + + #[test] + fn sample_count_is_max_of_channels() { + let c = Capture { + ch1: vec![0; 100], + ch2: Some(vec![0; 250]), + }; + assert_eq!(c.sample_count(), 250); + } + + #[test] + fn parses_real_example_file_first_capture() { + // Smoke-test: if the example file is present, make sure we get + // ~60000 samples in CH1. + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../scripts/captures/dso3d12_raw_115200_20260530_183115.bin"); + if !path.exists() { + return; + } + let bytes = std::fs::read(&path).expect("read example"); + let cap = parse_first(&bytes).expect("parse"); + assert_eq!(cap.ch1.len(), DEFAULT_BUFFER_DEPTH); + assert!(cap.ch2.is_none()); + } +} diff --git a/flasher/Cargo.toml b/flasher/Cargo.toml new file mode 100644 index 0000000..b19c84f --- /dev/null +++ b/flasher/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "wm-tools" +version = "0.1.2" +edition = "2021" +description = "A tool for Winner Micro MCU (including DSO 3D12 oscilloscope firmware)" +repository = "https://github.com/rssdev10/wm-tools" + +[[bin]] +name = "flash" +path = "src/flash.rs" + +[dependencies] +anyhow = "1.0" +clap = { version = "4.0", features = ["derive"] } +colored = "3.0" +crc = "3.0" +indicatif = "0.18" +serialport = "4.9.0" +tabled = "0.20" diff --git a/src/flash.rs b/flasher/src/flash.rs similarity index 100% rename from src/flash.rs rename to flasher/src/flash.rs diff --git a/gui/Cargo.toml b/gui/Cargo.toml new file mode 100644 index 0000000..1384ede --- /dev/null +++ b/gui/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "dso3d12-gui" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "GUI viewer for ZeeWeii DSO3D12 oscilloscope raw debug dumps" + +[[bin]] +name = "dso3d12-gui" +path = "src/main.rs" + +[dependencies] +dso3d12-parser = { path = "../dso3d12-parser" } +anyhow = { workspace = true } +log = { workspace = true, features = ["std"] } +thiserror = { workspace = true } +iced = { version = "0.14", features = ["canvas", "tokio", "advanced"] } +iced_aw = { version = "0.14", features = ["context_menu"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +rfd = "0.15" +dirs = "6.0" +csv = "1.3" +chrono = { version = "0.4", default-features = false, features = ["clock"] } +serialport = "4.6" +tokio = { version = "1", features = ["sync", "time"] } +crc = "3.2" +open = "5" diff --git a/gui/src/app.rs b/gui/src/app.rs new file mode 100644 index 0000000..a48885b --- /dev/null +++ b/gui/src/app.rs @@ -0,0 +1,2084 @@ +//! Application state, messages, update and view (iced 0.14). + +use std::path::PathBuf; + +use chrono::{DateTime, Local}; +use dso3d12_parser::{Capture, DEFAULT_BUFFER_DEPTH}; +use iced::widget::{ + button, canvas, checkbox, column, container, pick_list, progress_bar, row, rule, + scrollable, text, text_input, Space, +}; +use iced::{Element, Length, Subscription, Task, Theme}; + +use crate::canvas::{Scope, Thumbnail}; +use crate::range_slider; +use crate::serial::{self, SerialConfig, SerialEvent, SerialHandle}; +use crate::settings::{Settings, ViewMode}; + +// ── Messages ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub enum Message { + // File actions + LoadClicked, + FilePicked(Option), + Loaded(Result), + SaveCaptureClicked, + SaveCapturePicked(Option), + SavePngClicked, + PngPathPicked(Option), + ExportCsvClicked, + ExportCsvPicked(Option), + // Capture selection + SelectCapture(usize), + // Serial port + PortSelected(String), + StartListening, + StopListening, + SerialEvent(#[allow(dead_code)] SerialEvent), + // Channel & view (below-graph control panel) + ToggleCh1(bool), + ToggleCh2(bool), + ToggleDeviceCursorX(bool), + ToggleDeviceCursorY(bool), + ToggleShowScales(bool), + ToggleSplitPng(bool), + ViewModeSelected(ViewMode), + // Measurement cursors (right panel) + ToggleMeasCursorX(bool), + ToggleMeasCursorY(bool), + MeasCursorXChanged(f32, f32), + MeasCursorYChanged(f32, f32), + // Scale inputs (V/cell, time/cell) + VPerCellChanged(String), + TPerCellChanged(String), + // Graph click — move nearest cursor + GraphClicked(f32, f32), + // Misc + ToggleInstructions(bool), + ToggleAlertOnData(bool), + ToggleAutoListen(bool), + // Settings dialog + OpenSettings, + CloseSettings, + SettingsBaudChanged(String), + SettingsPortChanged(String), + // Firmware dialog + OpenFirmware, + CloseFirmware, + FirmwarePortSelected(String), + FirmwareRefreshPorts, + FirmwareFileClicked, + FirmwareFilePicked(Option), + FirmwareConfirmStart, + FirmwareStart, + FirmwareEvent(#[allow(dead_code)] crate::flash::FlashEvent), + // Port scan tick (auto-refresh) + PortScanTick, + // Open repository URL in browser + OpenRepoUrl, + // Context menu on thumbnails (right-click via iced_aw::ContextMenu) + ContextMenuDeleteAt(usize), + ContextMenuExportPngAt(usize), + ContextMenuExportPngPicked(Option), + ContextMenuExportCsvAt(usize), + ContextMenuExportCsvPicked(Option), +} + +/// A capture paired with the timestamp of when it was received/loaded. +#[derive(Debug, Clone)] +pub struct CaptureEntry { + pub capture: Capture, + pub timestamp: DateTime, +} + +#[derive(Debug, Clone)] +pub struct LoadedDump { + pub path: PathBuf, + pub captures: Vec, +} + +// ── State ────────────────────────────────────────────────────────────────── + +pub struct App { + pub(crate) settings: Settings, + pub(crate) dump: Option, + pub(crate) current: usize, + pub(crate) status: String, + pub(crate) available_ports: Vec, + pub(crate) listening: bool, + serial_handle: Option, + serial_rx: Option>, + /// Progress bytes received so far during a live capture. + capture_progress: usize, + /// Settings dialog open flag. + show_settings: bool, + /// Editable fields in settings dialog. + settings_baud_input: String, + settings_port_input: String, + /// Firmware dialog state. + show_firmware: bool, + firmware_port: String, + firmware_file: Option, + firmware_progress: Option<(u64, u64)>, + firmware_status: String, + firmware_handle: Option, + firmware_rx: Option>, + firmware_confirming: bool, + /// Whether we were listening before firmware update (to auto-reconnect after). + was_listening_before_flash: bool, + /// Target capture index for context menu export operations. + context_export_idx: Option, + /// Text input state for V/cell scale. + v_per_cell_input: String, + /// Text input state for time/cell scale. + t_per_cell_input: String, +} + +impl App { + pub fn new() -> (Self, Task) { + let settings = Settings::load(); + let ports = serial::list_ports(); + log::info!( + "starting GUI; persisted port = {:?}; available ports = {:?}", + settings.serial_port, + ports + ); + let baud_str = settings.baud_rate.to_string(); + let port_str = settings.serial_port.clone().unwrap_or_default(); + let v_per_cell_str = format_float(settings.v_per_cell); + let t_per_cell_str = format_float(settings.t_per_cell_ms); + ( + Self { + settings, + dump: None, + current: 0, + status: "Ready.".to_string(), + available_ports: ports, + listening: false, + serial_handle: None, + serial_rx: None, + capture_progress: 0, + show_settings: false, + settings_baud_input: baud_str, + settings_port_input: port_str, + show_firmware: false, + firmware_port: String::new(), + firmware_file: None, + firmware_progress: None, + firmware_status: String::new(), + firmware_handle: None, + firmware_rx: None, + firmware_confirming: false, + was_listening_before_flash: false, + context_export_idx: None, + v_per_cell_input: v_per_cell_str, + t_per_cell_input: t_per_cell_str, + }, + Task::none(), + ) + } + + pub fn title(&self) -> String { + let ver = env!("CARGO_PKG_VERSION"); + match &self.dump { + Some(d) => format!( + "DSO3D12 Viewer v{ver} — {} ({} capture{})", + d.path.file_name().and_then(|s| s.to_str()).unwrap_or("?"), + d.captures.len(), + if d.captures.len() == 1 { "" } else { "s" } + ), + None => format!("DSO3D12 Viewer v{ver}"), + } + } + + pub fn theme(&self) -> Theme { + Theme::Dark + } + + pub(crate) fn current_capture(&self) -> Option<&Capture> { + self.dump + .as_ref() + .and_then(|d| d.captures.get(self.current)) + .map(|e| &e.capture) + } + + pub(crate) fn current_entry(&self) -> Option<&CaptureEntry> { + self.dump + .as_ref() + .and_then(|d| d.captures.get(self.current)) + } + + pub(crate) fn capture_has_device_cursor_x(&self) -> bool { + false + } + pub(crate) fn capture_has_device_cursor_y(&self) -> bool { + false + } + + // ── Subscription for serial events ───────────────────────────────── + + pub fn subscription(&self) -> Subscription { + let mut subs = vec![]; + + // Poll serial receiver + if self.listening && self.serial_rx.is_some() { + subs.push( + iced::time::every(std::time::Duration::from_millis(50)) + .map(|_| Message::SerialEvent(SerialEvent::Progress(0))), + ); + } + + // Auto port scan every 10s + subs.push( + iced::time::every(std::time::Duration::from_secs(10)) + .map(|_| Message::PortScanTick), + ); + + // Poll firmware events + if self.firmware_rx.is_some() { + subs.push( + iced::time::every(std::time::Duration::from_millis(100)) + .map(|_| Message::FirmwareEvent(crate::flash::FlashEvent::Status(String::new()))), + ); + } + + Subscription::batch(subs) + } + + // ── Update ───────────────────────────────────────────────────────── + + pub fn update(&mut self, msg: Message) -> Task { + match msg { + Message::LoadClicked => Task::perform(pick_file(), Message::FilePicked), + Message::FilePicked(None) => Task::none(), + Message::FilePicked(Some(path)) => Task::perform(load_dump(path), |r| { + Message::Loaded(r.map_err(|e| e.to_string())) + }), + Message::Loaded(Ok(d)) => { + self.status = format!( + "Loaded {} capture{} from {}", + d.captures.len(), + if d.captures.len() == 1 { "" } else { "s" }, + d.path.display() + ); + log::info!("{}", self.status); + self.current = 0; + self.dump = Some(d); + Task::none() + } + Message::Loaded(Err(e)) => { + self.status = format!("Load error: {e}"); + log::error!("load error: {e}"); + Task::none() + } + Message::SaveCaptureClicked => { + Task::perform(pick_save_bin(), Message::SaveCapturePicked) + } + Message::SaveCapturePicked(None) => Task::none(), + Message::SaveCapturePicked(Some(path)) => { + if let Some(dump) = &self.dump { + match save_all_captures(&path, &dump.captures) { + Ok(()) => self.status = format!("Saved {} capture(s) to {}", dump.captures.len(), path.display()), + Err(e) => { + self.status = format!("Save error: {e}"); + log::error!("save error: {e}"); + } + } + } + Task::none() + } + Message::SavePngClicked => { + let ts = self.current_entry().map(|e| e.timestamp); + Task::perform(pick_save_png(ts), Message::PngPathPicked) + } + Message::PngPathPicked(None) => Task::none(), + Message::PngPathPicked(Some(path)) => { + if let Some(cap) = self.current_capture().cloned() { + match export_png(&path, &cap, &self.settings) { + Ok(()) => self.status = format!("PNG saved to {}", path.display()), + Err(e) => { + self.status = format!("PNG error: {e}"); + log::error!("PNG error: {e}"); + } + } + } else { + self.status = "No capture to export.".to_string(); + } + Task::none() + }, + Message::ExportCsvClicked => { + let ts = self.current_entry().map(|e| e.timestamp); + Task::perform(pick_save_csv(ts), Message::ExportCsvPicked) + } + Message::ExportCsvPicked(None) => Task::none(), + Message::ExportCsvPicked(Some(path)) => { + if let Some(cap) = self.current_capture() { + match export_csv(&path, cap) { + Ok(()) => self.status = format!("Wrote {}", path.display()), + Err(e) => { + self.status = format!("CSV error: {e}"); + log::error!("CSV error: {e}"); + } + } + } + Task::none() + } + Message::SelectCapture(i) => { + if let Some(d) = &self.dump { + if i < d.captures.len() { + self.current = i; + } + } + Task::none() + } + Message::PortSelected(p) => { + log::info!("port selected: {p}"); + self.settings.serial_port = Some(p); + self.settings.save(); + // Refresh ports + self.available_ports = serial::list_ports(); + Task::none() + } + Message::StartListening => { + let port = match &self.settings.serial_port { + Some(p) if !p.is_empty() => p.clone(), + _ => { + self.status = "Select a serial port first.".to_string(); + return Task::none(); + } + }; + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let config = SerialConfig { + port, + baud_rate: self.settings.baud_rate, + }; + let handle = serial::start_listening(config, tx); + self.serial_handle = Some(handle); + self.serial_rx = Some(rx); + self.listening = true; + self.capture_progress = 0; + self.status = format!("Listening on {}…", self.settings.serial_port.as_deref().unwrap_or("?")); + Task::none() + } + Message::StopListening => { + if let Some(mut h) = self.serial_handle.take() { + h.stop(); + } + self.serial_rx = None; + self.listening = false; + self.status = "Stopped.".to_string(); + Task::none() + } + Message::SerialEvent(_) => { + // Drain all pending events from the channel. + if let Some(rx) = &mut self.serial_rx { + while let Ok(evt) = rx.try_recv() { + match evt { + SerialEvent::Connected => { + self.status = format!( + "Connected to {}.", + self.settings.serial_port.as_deref().unwrap_or("?") + ); + log::info!("{}", self.status); + } + SerialEvent::Disconnected => { + self.status = "Port disconnected; reconnecting…".to_string(); + log::warn!("{}", self.status); + } + SerialEvent::Progress(n) => { + if n > 0 { + // Beep when data first starts arriving + if self.capture_progress == 0 && self.settings.alert_on_data { + play_beep_start(); + } + self.capture_progress = n; + self.status = format!("Receiving… {n} bytes"); + } + } + SerialEvent::CaptureReceived(captures) => { + let count = captures.len(); + log::info!( + "received {count} capture(s) from serial, ch1 len = {}", + captures.first().map(|c| c.ch1.len()).unwrap_or(0) + ); + // Beep when capture is complete + if self.settings.alert_on_data { + play_beep_end(); + } + // Wrap captures with timestamps + let now = Local::now(); + let entries: Vec = captures + .into_iter() + .map(|capture| CaptureEntry { capture, timestamp: now }) + .collect(); + // Append to existing dump rather than replacing. + if let Some(ref mut dump) = self.dump { + let prev_len = dump.captures.len(); + dump.captures.extend(entries); + // Auto-select the newly received capture. + self.current = prev_len; + } else { + self.dump = Some(LoadedDump { + path: PathBuf::from(""), + captures: entries, + }); + self.current = 0; + } + self.capture_progress = 0; + self.status = format!( + "Received {count} capture(s). Total: {}", + self.dump.as_ref().map(|d| d.captures.len()).unwrap_or(0) + ); + } + SerialEvent::Error(e) => { + self.status = format!("Serial: {e}"); + log::error!("serial error: {e}"); + } + } + } + } + Task::none() + } + Message::ToggleCh1(v) => { + self.settings.show_ch1 = v; + self.settings.save(); + Task::none() + } + Message::ToggleCh2(v) => { + self.settings.show_ch2 = v; + self.settings.save(); + Task::none() + } + Message::ToggleDeviceCursorX(v) => { + self.settings.device_cursor_x_enabled = v; + self.settings.save(); + Task::none() + } + Message::ToggleDeviceCursorY(v) => { + self.settings.device_cursor_y_enabled = v; + self.settings.save(); + Task::none() + } + Message::ViewModeSelected(m) => { + self.settings.view_mode = m; + self.settings.save(); + Task::none() + } + Message::ToggleMeasCursorX(v) => { + self.settings.measurement_cursor_x_enabled = v; + self.settings.save(); + Task::none() + } + Message::ToggleMeasCursorY(v) => { + self.settings.measurement_cursor_y_enabled = v; + self.settings.save(); + Task::none() + } + Message::MeasCursorXChanged(lo, hi) => { + self.settings.cursor_x_range = (lo, hi); + Task::none() + } + Message::MeasCursorYChanged(lo, hi) => { + self.settings.cursor_y_range = (lo, hi); + Task::none() + } + Message::VPerCellChanged(s) => { + self.v_per_cell_input = s.clone(); + // Accept both decimal ("0.001") and scientific notation ("1e-3") + if let Ok(v) = s.parse::() { + if v > 0.0 && v.is_finite() { + self.settings.v_per_cell = v; + self.settings.save(); + } + } + Task::none() + } + Message::TPerCellChanged(s) => { + self.t_per_cell_input = s.clone(); + if let Ok(v) = s.parse::() { + if v > 0.0 && v.is_finite() { + self.settings.t_per_cell_ms = v; + self.settings.save(); + } + } + Task::none() + } + Message::GraphClicked(frac_x, frac_y) => { + // Move nearest cursor to the click position. + // If X cursors enabled, move nearest X cursor knob. + if self.settings.measurement_cursor_x_enabled { + let (lo, hi) = self.settings.cursor_x_range; + let dist_lo = (frac_x - lo).abs(); + let dist_hi = (frac_x - hi).abs(); + if dist_lo <= dist_hi { + self.settings.cursor_x_range.0 = frac_x.clamp(0.0, hi); + } else { + self.settings.cursor_x_range.1 = frac_x.clamp(lo, 1.0); + } + } + // If Y cursors enabled, move nearest Y cursor knob. + if self.settings.measurement_cursor_y_enabled { + let (lo, hi) = self.settings.cursor_y_range; + let dist_lo = (frac_y - lo).abs(); + let dist_hi = (frac_y - hi).abs(); + if dist_lo <= dist_hi { + self.settings.cursor_y_range.0 = frac_y.clamp(0.0, hi); + } else { + self.settings.cursor_y_range.1 = frac_y.clamp(lo, 1.0); + } + } + Task::none() + } + Message::ToggleInstructions(v) => { + self.settings.show_instructions = v; + self.settings.save(); + Task::none() + } + Message::OpenSettings => { + self.show_settings = true; + self.settings_baud_input = self.settings.baud_rate.to_string(); + self.settings_port_input = self.settings.serial_port.clone().unwrap_or_default(); + Task::none() + } + Message::CloseSettings => { + // Apply settings + if let Ok(b) = self.settings_baud_input.parse::() { + if b > 0 { + self.settings.baud_rate = b; + } + } + let p = self.settings_port_input.trim().to_string(); + self.settings.serial_port = if p.is_empty() { None } else { Some(p) }; + self.settings.save(); + self.show_settings = false; + self.status = "Settings saved.".to_string(); + Task::none() + } + Message::SettingsBaudChanged(s) => { + self.settings_baud_input = s; + Task::none() + } + Message::SettingsPortChanged(s) => { + self.settings_port_input = s; + Task::none() + } + Message::ToggleShowScales(v) => { + self.settings.show_scales = v; + self.settings.save(); + Task::none() + } + Message::ToggleSplitPng(v) => { + self.settings.split_png = v; + self.settings.save(); + Task::none() + } + Message::ToggleAlertOnData(v) => { + self.settings.alert_on_data = v; + self.settings.save(); + Task::none() + } + Message::ToggleAutoListen(v) => { + self.settings.auto_listen = v; + self.settings.save(); + Task::none() + } + Message::PortScanTick => { + self.available_ports = serial::list_ports(); + // Auto-listen: if enabled and not currently listening and a port is available + if self.settings.auto_listen && !self.listening { + if let Some(port) = &self.settings.serial_port { + if self.available_ports.contains(port) { + return self.update(Message::StartListening); + } + } + } + Task::none() + } + Message::OpenRepoUrl => { + let url = env!("CARGO_PKG_REPOSITORY"); + let _ = open::that(url); + Task::none() + } + Message::ContextMenuDeleteAt(idx) => { + if let Some(ref mut dump) = self.dump { + if idx < dump.captures.len() { + dump.captures.remove(idx); + if self.current >= dump.captures.len() && self.current > 0 { + self.current -= 1; + } + self.status = format!("Deleted capture #{}", idx + 1); + } + } + Task::none() + } + Message::ContextMenuExportPngAt(idx) => { + let ts = self.dump.as_ref() + .and_then(|d| d.captures.get(idx)) + .map(|e| e.timestamp); + self.context_export_idx = Some(idx); + Task::perform(pick_save_png(ts), Message::ContextMenuExportPngPicked) + } + Message::ContextMenuExportPngPicked(None) => { + self.context_export_idx = None; + Task::none() + } + Message::ContextMenuExportPngPicked(Some(path)) => { + let idx = self.context_export_idx.take().unwrap_or(self.current); + if let Some(cap) = self.dump.as_ref().and_then(|d| d.captures.get(idx)).map(|e| &e.capture).cloned() { + match export_png(&path, &cap, &self.settings) { + Ok(()) => self.status = format!("PNG saved to {}", path.display()), + Err(e) => self.status = format!("PNG error: {e}"), + } + } + Task::none() + } + Message::ContextMenuExportCsvAt(idx) => { + let ts = self.dump.as_ref() + .and_then(|d| d.captures.get(idx)) + .map(|e| e.timestamp); + self.context_export_idx = Some(idx); + Task::perform(pick_save_csv(ts), Message::ContextMenuExportCsvPicked) + } + Message::ContextMenuExportCsvPicked(None) => { + self.context_export_idx = None; + Task::none() + } + Message::ContextMenuExportCsvPicked(Some(path)) => { + let idx = self.context_export_idx.take().unwrap_or(self.current); + if let Some(cap) = self.dump.as_ref().and_then(|d| d.captures.get(idx)).map(|e| &e.capture) { + match export_csv(&path, cap) { + Ok(()) => self.status = format!("CSV saved to {}", path.display()), + Err(e) => self.status = format!("CSV error: {e}"), + } + } + Task::none() + } + Message::OpenFirmware => { + self.show_firmware = true; + self.firmware_port = self.settings.serial_port.clone().unwrap_or_default(); + self.firmware_file = None; + self.firmware_progress = None; + self.firmware_status = "Select firmware file and port, then click Update.".to_string(); + self.firmware_handle = None; + self.firmware_rx = None; + self.firmware_confirming = false; + self.was_listening_before_flash = self.listening; + Task::none() + } + Message::CloseFirmware => { + if let Some(mut h) = self.firmware_handle.take() { + h.cancel(); + } + self.firmware_rx = None; + self.show_firmware = false; + self.firmware_confirming = false; + // Auto-reconnect if we were listening before + if self.was_listening_before_flash && !self.listening { + if let Some(port) = &self.settings.serial_port { + if self.available_ports.contains(port) { + return self.update(Message::StartListening); + } + } + } + Task::none() + } + Message::FirmwarePortSelected(p) => { + self.firmware_port = p; + Task::none() + } + Message::FirmwareRefreshPorts => { + self.available_ports = serial::list_ports(); + Task::none() + } + Message::FirmwareFileClicked => { + Task::perform(pick_firmware_file(), Message::FirmwareFilePicked) + } + Message::FirmwareFilePicked(path) => { + self.firmware_file = path; + Task::none() + } + Message::FirmwareConfirmStart => { + self.firmware_confirming = true; + self.firmware_status = "⚠ Are you sure? This may brick the device. Click Update again to confirm.".to_string(); + Task::none() + } + Message::FirmwareStart => { + self.firmware_confirming = false; + let port = self.firmware_port.clone(); + let file = match &self.firmware_file { + Some(p) => p.to_string_lossy().to_string(), + None => { + self.firmware_status = "No firmware file selected.".to_string(); + return Task::none(); + } + }; + if port.is_empty() { + self.firmware_status = "No port selected.".to_string(); + return Task::none(); + } + // Stop listening if active (can't share port) + if self.listening { + if let Some(mut h) = self.serial_handle.take() { + h.stop(); + } + self.serial_rx = None; + self.listening = false; + } + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let handle = crate::flash::start_flash(port, file, tx); + self.firmware_handle = Some(handle); + self.firmware_rx = Some(rx); + self.firmware_progress = Some((0, 1)); + self.firmware_status = "Starting…".to_string(); + Task::none() + } + Message::FirmwareEvent(_) => { + // Drain firmware channel + let mut finished = false; + if let Some(rx) = &mut self.firmware_rx { + while let Ok(evt) = rx.try_recv() { + match evt { + crate::flash::FlashEvent::Status(s) => { + if !s.is_empty() { + self.firmware_status = s; + } + } + crate::flash::FlashEvent::Progress { current, total } => { + self.firmware_progress = Some((current, total)); + } + crate::flash::FlashEvent::Finished(result) => { + match result { + Ok(()) => { + self.firmware_status = "Flash complete! You may close this dialog.".to_string(); + } + Err(e) => { + self.firmware_status = format!("Flash error: {e}"); + } + } + finished = true; + } + } + } + } + if finished { + self.firmware_handle = None; + self.firmware_rx = None; + } + Task::none() + } + } + } + + // ── View ─────────────────────────────────────────────────────────── + + pub fn view(&self) -> Element<'_, Message> { + if self.show_firmware { + return self.firmware_dialog(); + } + if self.show_settings { + return self.settings_dialog(); + } + + let menu_bar = self.menu_bar(); + + // Capture title with timestamp + let title_bar: Element<'_, Message> = if let Some(entry) = self.current_entry() { + let ts_str = entry.timestamp.format("%Y-%m-%d %H:%M:%S").to_string(); + container(text(ts_str).size(12)) + .width(Length::Fill) + .center_x(Length::Fill) + .padding(2) + .into() + } else { + Space::new().height(Length::Fixed(0.0)).into() + }; + + let scope = self.scope_widget(); + let right = self.measurement_panel(); + let graph_row: Element<'_, Message> = container(row![scope, right].spacing(4)) + .width(Length::Fill) + .height(Length::FillPortion(1)) + .into(); + let controls = self.controls_panel(); + let thumbs = thumbnails(&self.dump, self.current); + let actions = self.action_buttons(); + + let instructions: Element<'_, Message> = if self.settings.show_instructions { + instructions_panel() + } else { + Space::new().height(Length::Fixed(0.0)).into() + }; + + let bottom = column![ + controls, + rule::horizontal(1), + thumbs, + rule::horizontal(1), + actions, + rule::horizontal(1), + checkbox(self.settings.show_instructions) + .label("Show instructions") + .on_toggle(Message::ToggleInstructions), + instructions, + ] + .spacing(4) + .padding(4); + + container(column![menu_bar, title_bar, graph_row, bottom].spacing(2)) + .padding(4) + .into() + } + + fn settings_dialog(&self) -> Element<'_, Message> { + let title = text("Settings").size(22); + + // ── Serial section ── + let serial_section = container( + column![ + text("Serial").size(15), + rule::horizontal(1), + text("Default port:").size(13), + text_input("e.g. /dev/tty.usbserial-1110", &self.settings_port_input) + .on_input(Message::SettingsPortChanged) + .size(13), + if self.available_ports.is_empty() { + text("No serial ports detected.").size(11) + } else { + text(format!("Available: {}", self.available_ports.join(", "))).size(11) + }, + text("Baud rate:").size(13), + text_input("115200", &self.settings_baud_input) + .on_input(Message::SettingsBaudChanged) + .size(13), + checkbox(self.settings.auto_listen) + .label("Auto-listen on startup") + .on_toggle(Message::ToggleAutoListen), + checkbox(self.settings.alert_on_data) + .label("Beep on new capture") + .on_toggle(Message::ToggleAlertOnData), + ] + .spacing(4), + ) + .padding(16) + .style(container::bordered_box); + + // ── Display section ── + let display_section = container( + column![ + text("Display").size(15), + rule::horizontal(1), + checkbox(self.settings.show_instructions) + .label("Show instructions panel") + .on_toggle(Message::ToggleInstructions), + ] + .spacing(4), + ) + .padding(16) + .style(container::bordered_box); + + // ── About section ── + let ver = env!("CARGO_PKG_VERSION"); + let repo = env!("CARGO_PKG_REPOSITORY"); + let config_path = crate::settings::config_dir_path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let about_section = container( + column![ + text("About").size(15), + rule::horizontal(1), + text(format!("DSO3D12 GUI v{ver}")).size(13), + button(text(repo).size(11)) + .on_press(Message::OpenRepoUrl) + .style(button::text), + text(format!("Config: {config_path}")).size(11), + Space::new().height(Length::Fixed(8.0)), + button(text("Firmware Update…").size(13)).on_press(Message::OpenFirmware), + ] + .spacing(4), + ) + .padding(16) + .style(container::bordered_box); + + let close_btn = button(text("Save & Close").size(13)).on_press(Message::CloseSettings); + + container( + column![ + title, + Space::new().height(Length::Fixed(8.0)), + serial_section, + Space::new().height(Length::Fixed(8.0)), + display_section, + Space::new().height(Length::Fixed(8.0)), + about_section, + Space::new().height(Length::Fixed(16.0)), + close_btn, + ] + .spacing(4) + .padding(20) + .max_width(500), + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + } + + fn firmware_dialog(&self) -> Element<'_, Message> { + let title = text("Firmware Update").size(22); + let warning = container( + text("⚠ WARNING: Flashing incorrect firmware may brick your device. Ensure the device is in boot mode and the correct firmware file is selected.") + .size(12), + ) + .padding(8) + .style(container::bordered_box); + + let boot_instructions = container( + column![ + text("Switching to Boot Mode (DSO3D12)").size(13), + Space::new().height(Length::Fixed(4.0)), + text("1. Connect the supplied USB cable to your PC while the oscilloscope is powered off.").size(11), + text("2. Press and hold the power button. The scope may enter a power-cycling loop — keep the button held down continuously until flashing reaches 100%.").size(11), + ] + .spacing(2), + ) + .padding(8) + .style(container::bordered_box); + + let port_picker = pick_list( + self.available_ports.clone(), + if self.firmware_port.is_empty() { + None + } else { + Some(self.firmware_port.clone()) + }, + Message::FirmwarePortSelected, + ) + .placeholder("Select port…") + .text_size(13); + let refresh_btn = button(text("Refresh").size(12)).on_press(Message::FirmwareRefreshPorts); + + let file_label = match &self.firmware_file { + Some(p) => text(p.file_name().and_then(|n| n.to_str()).unwrap_or("?")).size(13), + None => text("No file selected").size(13), + }; + let file_btn = button(text("Browse…").size(13)).on_press(Message::FirmwareFileClicked); + + let progress_bar_el: Element<'_, Message> = if let Some((cur, total)) = self.firmware_progress + { + let pct = if total > 0 { + (cur as f32 / total as f32) * 100.0 + } else { + 0.0 + }; + column![ + text(format!("{pct:.0}% ({cur}/{total} blocks)")).size(12), + progress_bar(0.0..=total as f32, cur as f32), + ] + .spacing(2) + .into() + } else { + Space::new().height(Length::Fixed(0.0)).into() + }; + + let status_text = text(self.firmware_status.clone()).size(12); + + let is_flashing = self.firmware_handle.is_some(); + let update_btn = if is_flashing { + button(text("Updating…").size(13)) + } else if self.firmware_confirming { + button(text("Confirm Update").size(13)).on_press(Message::FirmwareStart) + } else { + button(text("Update").size(13)).on_press(Message::FirmwareConfirmStart) + }; + let close_btn = button(text("Close").size(13)).on_press(Message::CloseFirmware); + + container( + column![ + title, + Space::new().height(Length::Fixed(8.0)), + warning, + Space::new().height(Length::Fixed(8.0)), + boot_instructions, + Space::new().height(Length::Fixed(8.0)), + text("Port:").size(13), + row![port_picker, refresh_btn].spacing(8).align_y(iced::Alignment::Center), + Space::new().height(Length::Fixed(8.0)), + text("Firmware file (.fls):").size(13), + row![file_btn, file_label].spacing(8).align_y(iced::Alignment::Center), + Space::new().height(Length::Fixed(8.0)), + progress_bar_el, + status_text, + Space::new().height(Length::Fixed(16.0)), + row![update_btn, close_btn].spacing(8), + ] + .spacing(4) + .padding(20) + .max_width(500), + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + } + + fn menu_bar(&self) -> Element<'_, Message> { + let port_display = match &self.settings.serial_port { + Some(p) => text(p.clone()).size(12), + None => text("No port").size(12), + }; + + let listen_btn = if self.listening { + button(text("Stop").size(12)).on_press(Message::StopListening) + } else { + button(text("Listen").size(12)).on_press(Message::StartListening) + }; + + let port_picker = pick_list( + self.available_ports.clone(), + self.settings.serial_port.clone(), + Message::PortSelected, + ) + .placeholder("Port…") + .text_size(12); + + container( + row![ + text("DSO3D12").size(14), + Space::new().width(Length::Fixed(12.0)), + port_picker, + port_display, + listen_btn, + Space::new().width(Length::Fill), + text(self.status.clone()).size(12), + ] + .spacing(6) + .align_y(iced::Alignment::Center), + ) + .padding(4) + .width(Length::Fill) + .style(container::rounded_box) + .into() + } + + fn scope_widget(&self) -> Element<'_, Message> { + let cap = self.current_capture(); + log::trace!( + "scope_widget: capture present={}, ch1 len={}", + cap.is_some(), + cap.map(|c| c.ch1.len()).unwrap_or(0) + ); + let n_samples = cap.map(|c| c.ch1.len()).unwrap_or(DEFAULT_BUFFER_DEPTH); + let has_ch2_data = cap.and_then(|c| c.ch2.as_ref()).is_some(); + let scope = Scope { + capture: cap, + show_ch1: self.settings.show_ch1, + show_ch2: self.settings.show_ch2, + view_mode: self.settings.view_mode, + show_scales: self.settings.show_scales, + n_samples, + has_ch1: self.settings.show_ch1, + has_ch2: self.settings.show_ch2 && has_ch2_data, + meas_cursor_x: if self.settings.measurement_cursor_x_enabled { + Some(self.settings.cursor_x_range) + } else { + None + }, + meas_cursor_y: if self.settings.measurement_cursor_y_enabled { + Some(self.settings.cursor_y_range) + } else { + None + }, + device_cursor_x: None, + device_cursor_y: None, + v_per_cell: self.settings.v_per_cell, + t_per_cell_ms: self.settings.t_per_cell_ms, + on_click: Some(Message::GraphClicked), + }; + canvas(scope) + .width(Length::Fill) + .height(Length::FillPortion(1)) + .into() + } + + /// Right-hand measurement panel — signal info + two range sliders with + /// computed voltage/time/frequency measurements. + fn measurement_panel(&self) -> Element<'_, Message> { + let cap = self.current_capture(); + + let (xlo, xhi) = self.settings.cursor_x_range; + let (ylo, yhi) = self.settings.cursor_y_range; + + // Scale parameters + let v_per_cell = self.settings.v_per_cell; + let t_per_cell_ms = self.settings.t_per_cell_ms; + let half_v_range = 4.0 * v_per_cell; // 8 cells / 2 + let total_time_ms = 12.0 * t_per_cell_ms; + + // ── Signal information ── + let mut items: Vec> = vec![ + text("Measurement").size(13).into(), + rule::horizontal(1).into(), + ]; + + // ── Scale inputs ── + items.push( + row![ + text("V/cell:").size(11), + text_input("1.0", &self.v_per_cell_input) + .on_input(Message::VPerCellChanged) + .size(11) + .width(Length::Fixed(50.0)), + text("V").size(11), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .into(), + ); + items.push( + row![ + text("t/cell:").size(11), + text_input("1.0", &self.t_per_cell_input) + .on_input(Message::TPerCellChanged) + .size(11) + .width(Length::Fixed(50.0)), + text("ms").size(11), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .into(), + ); + items.push( + text("AC mode: 8×V, 12×t cells").size(9).into(), + ); + items.push(rule::horizontal(1).into()); + + if let Some(cap) = cap { + let v_min = frac_to_voltage(1.0, half_v_range); // bottom + let v_max = frac_to_voltage(0.0, half_v_range); // top + items.push( + text(format!("CH1 range: {v_min:.2}V … {v_max:.2}V")).size(11).into(), + ); + items.push( + text(format!("Time range: {total_time_ms:.2} ms ({} samp)", cap.ch1.len())) + .size(11) + .into(), + ); + if cap.ch2.is_some() { + items.push( + text(format!("CH2 range: {v_min:.2}V … {v_max:.2}V")) + .size(11) + .into(), + ); + } + items.push(rule::horizontal(1).into()); + } + + // ── X cursor (time) ── + items.push( + checkbox(self.settings.measurement_cursor_x_enabled) + .label("Cursor X (time)") + .on_toggle(Message::ToggleMeasCursorX) + .into(), + ); + + if self.settings.measurement_cursor_x_enabled { + items.push(range_slider::horizontal(xlo, xhi, Message::MeasCursorXChanged)); + let t_lo = xlo as f64 * total_time_ms; + let t_hi = xhi as f64 * total_time_ms; + let dt_ms = t_hi - t_lo; + let freq = if dt_ms > 0.0 { + format!("{:.1} Hz", 1000.0 / dt_ms) + } else { + "—".to_string() + }; + items.push( + column![ + text(format!("Δt = {dt_ms:.3} ms")).size(11), + text(format!("≈ freq: {freq}")).size(11), + ] + .spacing(2) + .into(), + ); + } + + items.push(Space::new().height(Length::Fixed(6.0)).into()); + + // ── Y cursor (voltage) ── + items.push( + checkbox(self.settings.measurement_cursor_y_enabled) + .label("Cursor Y (voltage)") + .on_toggle(Message::ToggleMeasCursorY) + .into(), + ); + + if self.settings.measurement_cursor_y_enabled { + items.push(range_slider::horizontal(ylo, yhi, Message::MeasCursorYChanged)); + let v_lo = frac_to_voltage(ylo as f64, half_v_range); + let v_hi = frac_to_voltage(yhi as f64, half_v_range); + let dv = (v_hi - v_lo).abs(); + items.push( + column![ + text(format!("ΔV = {dv:.3} V")).size(11), + text(format!("({v_lo:.2}V … {v_hi:.2}V)")).size(11), + ] + .spacing(2) + .into(), + ); + } + + items.push(Space::new().height(Length::Fixed(6.0)).into()); + items.push(rule::horizontal(1).into()); + + // ── Per-channel stats ── + if let Some(cap) = cap { + let (mn, mx, av) = stats(&cap.ch1); + items.push( + text(format!( + "CH1: n={} min={mn} max={mx} avg={av:.1}", + cap.ch1.len() + )) + .size(10) + .into(), + ); + if let Some(ch2) = &cap.ch2 { + let (mn, mx, av) = stats(ch2); + items.push( + text(format!("CH2: n={} min={mn} max={mx} avg={av:.1}", ch2.len())) + .size(10) + .into(), + ); + } + } + + container(column(items).spacing(4).padding(6)) + .width(Length::Fixed(220.0)) + .style(container::rounded_box) + .into() + } + + fn controls_panel(&self) -> Element<'_, Message> { + let has_dev_x = self.capture_has_device_cursor_x(); + let has_dev_y = self.capture_has_device_cursor_y(); + + let dev_x = { + let cb = checkbox(self.settings.device_cursor_x_enabled).label("Device X"); + if has_dev_x { + cb.on_toggle(Message::ToggleDeviceCursorX) + } else { + cb + } + }; + let dev_y = { + let cb = checkbox(self.settings.device_cursor_y_enabled).label("Device Y"); + if has_dev_y { + cb.on_toggle(Message::ToggleDeviceCursorY) + } else { + cb + } + }; + + row![ + checkbox(self.settings.show_ch1) + .label("CH1") + .on_toggle(Message::ToggleCh1), + checkbox(self.settings.show_ch2) + .label("CH2") + .on_toggle(Message::ToggleCh2), + Space::new().width(Length::Fixed(16.0)), + dev_x, + dev_y, + Space::new().width(Length::Fixed(16.0)), + checkbox(self.settings.show_scales) + .label("Scales") + .on_toggle(Message::ToggleShowScales), + Space::new().width(Length::Fixed(16.0)), + text("View:").size(13), + pick_list( + ViewMode::ALL.as_slice(), + Some(self.settings.view_mode), + Message::ViewModeSelected, + ) + .text_size(13), + ] + .spacing(8) + .padding(4) + .align_y(iced::Alignment::Center) + .into() + } + + fn action_buttons(&self) -> Element<'_, Message> { + row![ + button(text("Load Capture").size(12)).on_press(Message::LoadClicked), + button(text("Save Capture").size(12)).on_press(Message::SaveCaptureClicked), + Space::new().width(Length::Fixed(12.0)), + text("│").size(12), + Space::new().width(Length::Fixed(12.0)), + button(text("Save PNG").size(12)).on_press(Message::SavePngClicked), + checkbox(self.settings.split_png) + .label("Split CH1/CH2") + .on_toggle(Message::ToggleSplitPng), + Space::new().width(Length::Fixed(12.0)), + text("│").size(12), + Space::new().width(Length::Fixed(12.0)), + button(text("Export Data").size(12)).on_press(Message::ExportCsvClicked), + Space::new().width(Length::Fill), + button(text("Settings").size(12)).on_press(Message::OpenSettings), + ] + .spacing(6) + .padding(4) + .align_y(iced::Alignment::Center) + .into() + } +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +fn instructions_panel<'a>() -> Element<'a, Message> { + let body = "How to capture from a ZeeWeii DSO3D12:\n\ + 1. Feed a signal into the scope.\n\ + 2. Press Stop to freeze the sample buffer.\n\ + 3. Press Menu, then Stop so the Channel 1 Measurements menu is on screen.\n\ + 4. Long-press the Save button to start the debug data transmission.\n\ + 5. Capture the serial stream into a .bin file and load it here."; + container(text(body).size(12)) + .padding(8) + .style(container::rounded_box) + .into() +} + +pub(crate) fn stats(samples: &[u8]) -> (u8, u8, f32) { + if samples.is_empty() { + return (0, 0, 0.0); + } + let mut mn = u8::MAX; + let mut mx = 0u8; + let mut sum: u64 = 0; + for &b in samples { + mn = mn.min(b); + mx = mx.max(b); + sum += b as u64; + } + (mn, mx, sum as f32 / samples.len() as f32) +} + +/// Convert raw ADC count (0-255) to approximate voltage using the default ±5V range. +/// Used in tests. +#[cfg(test)] +fn adc_to_voltage(adc: u8) -> f32 { + (adc as f32 - 128.0) * 5.0 / 128.0 +} + +/// Convert a fractional position (0.0..1.0) on the Y axis to voltage +/// using the user-configured V/cell scale. +/// frac=0 → top → +half_range; frac=1 → bottom → -half_range. +/// Format a float for display in a scale input field. +/// Always shows a decimal point so the field looks like a float. +/// Strips trailing zeros but keeps at least one decimal digit (e.g. "1.0", "0.5", "0.001"). +fn format_float(v: f64) -> String { + // Use enough precision to round-trip small values + let s = format!("{:.9}", v); + let trimmed = s.trim_end_matches('0'); + if trimmed.ends_with('.') { + format!("{}0", trimmed) + } else { + trimmed.to_string() + } +} + +fn frac_to_voltage(frac: f64, half_v_range: f64) -> f64 { + half_v_range * (1.0 - 2.0 * frac) +} + +/// Convert sample count to time in milliseconds at the default 1 MHz sample rate. +/// Used in tests. +#[cfg(test)] +const SAMPLE_RATE_HZ: f64 = 1_000_000.0; + +#[cfg(test)] +fn samples_to_time_ms(n: usize) -> f64 { + (n as f64 / SAMPLE_RATE_HZ) * 1000.0 +} + +/// Thumbnails strip: scrollable row of mini waveform canvases. +/// Each thumbnail shows the signal shape and is clickable. +fn thumbnails<'a>(dump: &'a Option, current: usize) -> Element<'a, Message> { + let Some(dump) = dump else { + return Space::new().height(Length::Fixed(0.0)).into(); + }; + if dump.captures.is_empty() { + return Space::new().height(Length::Fixed(0.0)).into(); + } + let row_items = dump.captures.iter().enumerate().map(|(i, entry)| { + let thumb = Thumbnail { + capture: &entry.capture, + selected: i == current, + }; + let thumb_canvas: Element<'a, Message> = canvas(thumb) + .width(Length::Fixed(100.0)) + .height(Length::Fixed(50.0)) + .into(); + + let btn: Element<'a, Message> = button(thumb_canvas) + .on_press(Message::SelectCapture(i)) + .padding(2) + .style(if i == current { + button::primary + } else { + button::secondary + }) + .into(); + + // Wrap in ContextMenu for right-click popup + let tooltip_ts = entry.timestamp.format("%Y-%m-%d %H:%M:%S").to_string(); + let underlay: Element<'a, Message> = iced::widget::tooltip( + btn, + text(tooltip_ts).size(11), + iced::widget::tooltip::Position::Top, + ) + .into(); + + let ctx_idx = i; + iced_aw::ContextMenu::new(underlay, move || { + iced::widget::column![ + button(text("Export PNG…").size(12)) + .on_press(Message::ContextMenuExportPngAt(ctx_idx)) + .width(Length::Fill), + button(text("Export CSV…").size(12)) + .on_press(Message::ContextMenuExportCsvAt(ctx_idx)) + .width(Length::Fill), + button(text("Delete").size(12)) + .on_press(Message::ContextMenuDeleteAt(ctx_idx)) + .width(Length::Fill), + ] + .spacing(2) + .padding(4) + .width(Length::Fixed(140.0)) + .into() + }) + .into() + }); + container( + scrollable(row(row_items).spacing(4)) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::default(), + )), + ) + .height(Length::Fixed(62.0)) + .into() +} + +// ── Async file pickers ───────────────────────────────────────────────────── + +async fn pick_file() -> Option { + rfd::AsyncFileDialog::new() + .add_filter("DSO3D12 raw dump", &["bin", "dat", "raw"]) + .add_filter("All files", &["*"]) + .pick_file() + .await + .map(|h| h.path().to_path_buf()) +} + +async fn pick_save_bin() -> Option { + let now = chrono::Local::now(); + let name = format!("capture_{}.bin", now.format("%Y%m%d_%H%M%S")); + rfd::AsyncFileDialog::new() + .add_filter("DSO3D12 raw dump", &["bin"]) + .set_file_name(&name) + .save_file() + .await + .map(|h| h.path().to_path_buf()) +} + +async fn pick_save_csv(ts: Option>) -> Option { + let t = ts.unwrap_or_else(Local::now); + let name = format!("capture_{}.csv", t.format("%Y%m%d_%H%M%S")); + rfd::AsyncFileDialog::new() + .add_filter("CSV", &["csv"]) + .set_file_name(&name) + .save_file() + .await + .map(|h| h.path().to_path_buf()) +} + +async fn pick_save_png(ts: Option>) -> Option { + let t = ts.unwrap_or_else(Local::now); + let name = format!("capture_{}.png", t.format("%Y%m%d_%H%M%S")); + rfd::AsyncFileDialog::new() + .add_filter("PNG Image", &["png"]) + .set_file_name(&name) + .save_file() + .await + .map(|h| h.path().to_path_buf()) +} + +async fn pick_firmware_file() -> Option { + rfd::AsyncFileDialog::new() + .add_filter("Firmware", &["fls", "bin"]) + .add_filter("All files", &["*"]) + .pick_file() + .await + .map(|h| h.path().to_path_buf()) +} + +async fn load_dump(path: PathBuf) -> Result { + log::info!("loading {}", path.display()); + let bytes = std::fs::read(&path)?; + let parser_entries = dso3d12_parser::load_captures_with_metadata(&bytes)?; + let entries: Vec = parser_entries + .into_iter() + .map(|e| CaptureEntry { + capture: e.capture, + timestamp: e.timestamp, + }) + .collect(); + Ok(LoadedDump { path, captures: entries }) +} + +// ── Beep helpers ─────────────────────────────────────────────────────────── + +/// Play a short "start receiving" beep sound. +fn play_beep_start() { + #[cfg(target_os = "macos")] + { + std::thread::spawn(|| { + let _ = std::process::Command::new("afplay") + .arg("/System/Library/Sounds/Tink.aiff") + .output(); + }); + } + #[cfg(not(target_os = "macos"))] + { + print!("\x07"); + } +} + +/// Play a "capture complete" beep sound (different from start). +fn play_beep_end() { + #[cfg(target_os = "macos")] + { + std::thread::spawn(|| { + let _ = std::process::Command::new("afplay") + .arg("/System/Library/Sounds/Glass.aiff") + .output(); + }); + } + #[cfg(not(target_os = "macos"))] + { + print!("\x07"); + } +} + +// ── File save/load helpers ───────────────────────────────────────────────── + +/// Save all captures to a file using the parser crate's serializer. +fn save_all_captures(path: &std::path::Path, entries: &[CaptureEntry]) -> anyhow::Result<()> { + // Convert GUI CaptureEntry to parser CaptureEntry + let parser_entries: Vec = entries + .iter() + .map(|e| dso3d12_parser::CaptureEntry { + capture: e.capture.clone(), + timestamp: e.timestamp, + }) + .collect(); + let data = dso3d12_parser::serialize_captures(&parser_entries); + std::fs::write(path, data)?; + Ok(()) +} + +fn export_csv(path: &std::path::Path, cap: &Capture) -> anyhow::Result<()> { + let mut wtr = csv::Writer::from_path(path)?; + if cap.ch2.is_some() { + wtr.write_record(["index", "ch1", "ch2"])?; + } else { + wtr.write_record(["index", "ch1"])?; + } + let n = cap.sample_count(); + for i in 0..n { + let v1 = cap.ch1.get(i).map(|b| b.to_string()).unwrap_or_default(); + if let Some(ch2) = &cap.ch2 { + let v2 = ch2.get(i).map(|b| b.to_string()).unwrap_or_default(); + wtr.write_record([i.to_string(), v1, v2])?; + } else { + wtr.write_record([i.to_string(), v1])?; + } + } + wtr.flush()?; + Ok(()) +} + +/// Export the current capture as a PNG image. +fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> anyhow::Result<()> { + let graph_w: u32 = 1200; + let graph_h: u32 = 600; + let scale_margin_left: u32 = if settings.show_scales { 50 } else { 0 }; + let scale_margin_bottom: u32 = if settings.show_scales { 16 } else { 0 }; + + let has_ch2 = cap.ch2.is_some(); + let do_split = settings.split_png && settings.show_ch1 && settings.show_ch2 && has_ch2; + + let width = graph_w + scale_margin_left; + let height = if do_split { + (graph_h + scale_margin_bottom) * 2 + } else { + graph_h + scale_margin_bottom + }; + + let mut pixels = vec![0u8; (width * height * 3) as usize]; + let bg = [10u8, 15, 26]; + for chunk in pixels.chunks_exact_mut(3) { + chunk.copy_from_slice(&bg); + } + + // Helper to draw a grid into a sub-region of the pixel buffer. + let draw_grid = |pixels: &mut Vec, x_off: u32, y_off: u32, w: u32, h: u32| { + let grid_color = [51u8, 61, 77]; + for col in 1..10u32 { + let x = x_off + col * w / 10; + for y in y_off..y_off + h { + let idx = ((y * width + x) * 3) as usize; + pixels[idx..idx + 3].copy_from_slice(&grid_color); + } + } + for r in 1..8u32 { + let y = y_off + r * h / 8; + for x in x_off..x_off + w { + let idx = ((y * width + x) * 3) as usize; + pixels[idx..idx + 3].copy_from_slice(&grid_color); + } + } + }; + + // Helper to draw a trace into a sub-region. + let draw_ch = + |pixels: &mut Vec, samples: &[u8], color: [u8; 3], x_off: u32, y_off: u32, w: u32, h: u32| { + let n = samples.len(); + if n == 0 { + return; + } + let sample_y = |px: u32| -> u32 { + let i = (px as f64 / w as f64 * (n - 1) as f64) as usize; + let v = samples[i.min(n - 1)]; + // Invert Y: higher ADC value (higher voltage) at top of image. + let py = ((1.0 - v as f64 / 255.0) * (h - 1) as f64) as u32; + py.min(h - 1) + }; + + let mut prev_y = sample_y(0); + let idx = (((y_off + prev_y) * width + x_off) * 3) as usize; + if idx + 2 < pixels.len() { + pixels[idx..idx + 3].copy_from_slice(&color); + } + + for px in 1..w { + let cur_y = sample_y(px); + let y_start = prev_y.min(cur_y); + let y_end = prev_y.max(cur_y); + for y in y_start..=y_end { + let idx = (((y_off + y) * width + x_off + px) * 3) as usize; + if idx + 2 < pixels.len() { + pixels[idx..idx + 3].copy_from_slice(&color); + } + } + if cur_y > 0 { + let idx = (((y_off + cur_y - 1) * width + x_off + px) * 3) as usize; + if idx + 2 < pixels.len() { + pixels[idx..idx + 3].copy_from_slice(&color); + } + } + if cur_y + 1 < h { + let idx = (((y_off + cur_y + 1) * width + x_off + px) * 3) as usize; + if idx + 2 < pixels.len() { + pixels[idx..idx + 3].copy_from_slice(&color); + } + } + prev_y = cur_y; + } + }; + + // Helper to draw scale labels (simple 3x5 digit font). + let v_per_cell = settings.v_per_cell; + let t_per_cell_ms = settings.t_per_cell_ms; + let draw_scales_on_region = + |pixels: &mut Vec, x_off: u32, y_off: u32, w: u32, h: u32, _n_samples: usize, ch1_color: Option<[u8; 3]>, ch2_color: Option<[u8; 3]>| { + // X axis: time labels at bottom + let cols = 10u32; + let total_time_ms = 12.0 * t_per_cell_ms; + for i in 0..=cols { + let frac = i as f64 / cols as f64; + let time_ms = frac * total_time_ms; + let label = if time_ms >= 1.0 { + format!("{time_ms:.1}") + } else { + format!("{:.0}u", time_ms * 1000.0) + }; + let x_pos = x_off + i * w / cols + 2; + let y_pos = y_off + h + 2; + draw_text_tiny(pixels, width, &label, x_pos, y_pos, [153, 166, 179]); + } + + // Y axis: voltage labels on left margin, per-channel + let rows = 8u32; + let half_v_range = 4.0 * v_per_cell; + let x_ch1 = 2u32; + let x_ch2 = if ch1_color.is_some() { 26u32 } else { 2u32 }; + for i in 0..=rows { + let frac = i as f64 / rows as f64; + let voltage = half_v_range * (1.0 - 2.0 * frac); + let label = format!("{voltage:.1}"); + let y_pos = y_off + (i * h / rows); + if let Some(c) = ch1_color { + draw_text_tiny(pixels, width, &label, x_ch1, y_pos, c); + } + if let Some(c) = ch2_color { + draw_text_tiny(pixels, width, &label, x_ch2, y_pos, c); + } + } + }; + + if do_split { + let sub_h = graph_h; + // Upper subimage: CH1 + let y_off_1 = 0u32; + draw_grid(&mut pixels, scale_margin_left, y_off_1, graph_w, sub_h); + draw_ch(&mut pixels, &cap.ch1, [255, 217, 25], scale_margin_left, y_off_1, graph_w, sub_h); + if settings.show_scales { + let ch1_col = Some([255u8, 217, 25]); + draw_scales_on_region(&mut pixels, scale_margin_left, y_off_1, graph_w, sub_h, cap.ch1.len(), ch1_col, None); + } + + // Lower subimage: CH2 + let y_off_2 = graph_h + scale_margin_bottom; + draw_grid(&mut pixels, scale_margin_left, y_off_2, graph_w, sub_h); + if let Some(ch2) = &cap.ch2 { + let flipped: Vec = ch2.iter().map(|&v| 255 - v).collect(); + draw_ch(&mut pixels, &flipped, [38, 217, 255], scale_margin_left, y_off_2, graph_w, sub_h); + } + if settings.show_scales { + let ch2_col = Some([38u8, 217, 255]); + let n = cap.ch2.as_ref().map(|c| c.len()).unwrap_or(cap.ch1.len()); + draw_scales_on_region(&mut pixels, scale_margin_left, y_off_2, graph_w, sub_h, n, None, ch2_col); + } + } else { + // Single combined image + draw_grid(&mut pixels, scale_margin_left, 0, graph_w, graph_h); + if settings.show_ch1 { + draw_ch(&mut pixels, &cap.ch1, [255, 217, 25], scale_margin_left, 0, graph_w, graph_h); + } + if settings.show_ch2 { + if let Some(ch2) = &cap.ch2 { + let flipped: Vec = ch2.iter().map(|&v| 255 - v).collect(); + draw_ch(&mut pixels, &flipped, [38, 217, 255], scale_margin_left, 0, graph_w, graph_h); + } + } + if settings.show_scales { + let ch1_col = if settings.show_ch1 { Some([255u8, 217, 25]) } else { None }; + let ch2_col = if settings.show_ch2 && has_ch2 { Some([38u8, 217, 255]) } else { None }; + draw_scales_on_region(&mut pixels, scale_margin_left, 0, graph_w, graph_h, cap.ch1.len(), ch1_col, ch2_col); + } + } + + write_png(path, width, height, &pixels)?; + Ok(()) +} + +/// Draw a string using a tiny 3x5 pixel font. Each character is 4px wide (3+1 spacing). +fn draw_text_tiny(pixels: &mut [u8], img_width: u32, text: &str, x: u32, y: u32, color: [u8; 3]) { + let mut cx = x; + for ch in text.chars() { + let glyph = tiny_glyph(ch); + for row in 0..5u32 { + for col in 0..3u32 { + if glyph[row as usize] & (1 << (2 - col)) != 0 { + let px = cx + col; + let py = y + row; + let idx = ((py * img_width + px) * 3) as usize; + if idx + 2 < pixels.len() { + pixels[idx..idx + 3].copy_from_slice(&color); + } + } + } + } + cx += 4; + } +} + +/// Returns a 5-row bitmask (3 bits wide) for a character. +fn tiny_glyph(ch: char) -> [u8; 5] { + match ch { + '0' => [0b111, 0b101, 0b101, 0b101, 0b111], + '1' => [0b010, 0b110, 0b010, 0b010, 0b111], + '2' => [0b111, 0b001, 0b111, 0b100, 0b111], + '3' => [0b111, 0b001, 0b111, 0b001, 0b111], + '4' => [0b101, 0b101, 0b111, 0b001, 0b001], + '5' => [0b111, 0b100, 0b111, 0b001, 0b111], + '6' => [0b111, 0b100, 0b111, 0b101, 0b111], + '7' => [0b111, 0b001, 0b001, 0b001, 0b001], + '8' => [0b111, 0b101, 0b111, 0b101, 0b111], + '9' => [0b111, 0b101, 0b111, 0b001, 0b111], + '.' => [0b000, 0b000, 0b000, 0b000, 0b010], + '-' => [0b000, 0b000, 0b111, 0b000, 0b000], + 'V' => [0b101, 0b101, 0b101, 0b101, 0b010], + ' ' => [0b000, 0b000, 0b000, 0b000, 0b000], + _ => [0b000, 0b000, 0b000, 0b000, 0b000], + } +} + +/// Write an RGB pixel buffer as a PNG file. +/// Minimal implementation using uncompressed deflate (store blocks). +fn write_png(path: &std::path::Path, width: u32, height: u32, rgb: &[u8]) -> anyhow::Result<()> { + use std::io::Write; + + let mut file = std::fs::File::create(path)?; + + // PNG signature + file.write_all(&[137, 80, 78, 71, 13, 10, 26, 10])?; + + // IHDR chunk + let mut ihdr = Vec::with_capacity(13); + ihdr.extend_from_slice(&width.to_be_bytes()); + ihdr.extend_from_slice(&height.to_be_bytes()); + ihdr.push(8); // bit depth + ihdr.push(2); // color type: RGB + ihdr.push(0); // compression + ihdr.push(0); // filter + ihdr.push(0); // interlace + write_png_chunk(&mut file, b"IHDR", &ihdr)?; + + // IDAT chunk — raw image data compressed with deflate (store only). + // Each row is: filter_byte(0) + RGB pixels. + let row_len = 1 + width as usize * 3; + let raw_size = row_len * height as usize; + + // Build the unfiltered image data + let mut raw = Vec::with_capacity(raw_size); + for y in 0..height as usize { + raw.push(0); // filter byte: None + let start = y * width as usize * 3; + let end = start + width as usize * 3; + raw.extend_from_slice(&rgb[start..end]); + } + + // Wrap in zlib format (store blocks, no compression) + let deflated = zlib_store(&raw); + write_png_chunk(&mut file, b"IDAT", &deflated)?; + + // IEND chunk + write_png_chunk(&mut file, b"IEND", &[])?; + + Ok(()) +} + +fn write_png_chunk(w: &mut dyn std::io::Write, chunk_type: &[u8; 4], data: &[u8]) -> std::io::Result<()> { + let len = data.len() as u32; + w.write_all(&len.to_be_bytes())?; + w.write_all(chunk_type)?; + w.write_all(data)?; + // CRC32 over type + data + let crc = png_crc32(chunk_type, data); + w.write_all(&crc.to_be_bytes())?; + Ok(()) +} + +fn png_crc32(chunk_type: &[u8; 4], data: &[u8]) -> u32 { + let mut crc: u32 = 0xFFFF_FFFF; + for &b in chunk_type.iter().chain(data.iter()) { + let idx = ((crc ^ b as u32) & 0xFF) as usize; + crc = CRC_TABLE[idx] ^ (crc >> 8); + } + crc ^ 0xFFFF_FFFF +} + +/// Wraps data in zlib format using only store (no compression) deflate blocks. +fn zlib_store(data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(data.len() + 64); + // Zlib header: CM=8 (deflate), CINFO=7 (32K window), FCHECK so header%31==0 + out.push(0x78); // CMF + out.push(0x01); // FLG (no dict, level 0; 0x7801 % 31 == 0) + + // Deflate store blocks — max 65535 bytes per block + let mut remaining = data; + while !remaining.is_empty() { + let block_size = remaining.len().min(65535); + let is_last = block_size == remaining.len(); + out.push(if is_last { 0x01 } else { 0x00 }); // BFINAL + BTYPE=00 + let len = block_size as u16; + let nlen = !len; + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&nlen.to_le_bytes()); + out.extend_from_slice(&remaining[..block_size]); + remaining = &remaining[block_size..]; + } + + // Adler-32 checksum + let adler = adler32(data); + out.extend_from_slice(&adler.to_be_bytes()); + out +} + +fn adler32(data: &[u8]) -> u32 { + let mut a: u32 = 1; + let mut b: u32 = 0; + for &byte in data { + a = (a + byte as u32) % 65521; + b = (b + a) % 65521; + } + (b << 16) | a +} + +// CRC32 lookup table for PNG +const CRC_TABLE: [u32; 256] = { + let mut table = [0u32; 256]; + let mut n = 0usize; + while n < 256 { + let mut c = n as u32; + let mut k = 0; + while k < 8 { + if c & 1 != 0 { + c = 0xEDB8_8320 ^ (c >> 1); + } else { + c >>= 1; + } + k += 1; + } + table[n] = c; + n += 1; + } + table +}; + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use dso3d12_parser::Capture; + + #[test] + fn stats_handles_empty() { + assert_eq!(stats(&[]), (0, 0, 0.0)); + } + + #[test] + fn stats_known_values() { + let (mn, mx, av) = stats(&[10, 20, 30, 40]); + assert_eq!(mn, 10); + assert_eq!(mx, 40); + assert!((av - 25.0).abs() < 1e-3); + } + + #[test] + fn app_default_no_capture() { + let (app, _) = App::new(); + assert!(app.current_capture().is_none()); + assert_eq!(app.current, 0); + assert!(!app.listening); + } + + #[test] + fn app_updates_measurement_range() { + let (mut app, _) = App::new(); + let _ = app.update(Message::MeasCursorXChanged(0.1, 0.9)); + assert_eq!(app.settings.cursor_x_range, (0.1, 0.9)); + } + + #[test] + fn app_select_capture_clamps() { + let (mut app, _) = App::new(); + app.dump = Some(LoadedDump { + path: PathBuf::from("test.bin"), + captures: vec![CaptureEntry { + capture: Capture { + ch1: vec![1, 2, 3], + ch2: None, + }, + timestamp: Local::now(), + }], + }); + let _ = app.update(Message::SelectCapture(99)); + assert_eq!(app.current, 0); + let _ = app.update(Message::SelectCapture(0)); + assert_eq!(app.current, 0); + } + + #[test] + fn toggling_channels_persists_in_settings() { + let (mut app, _) = App::new(); + let before = app.settings.show_ch1; + let _ = app.update(Message::ToggleCh1(!before)); + assert_eq!(app.settings.show_ch1, !before); + } + + #[test] + fn device_cursor_hooks_default_false() { + let (app, _) = App::new(); + assert!(!app.capture_has_device_cursor_x()); + assert!(!app.capture_has_device_cursor_y()); + } + + #[test] + fn view_renders_without_panic_when_empty() { + let (app, _) = App::new(); + let _ = app.view(); + } + + #[test] + fn view_renders_with_capture() { + let (mut app, _) = App::new(); + app.dump = Some(LoadedDump { + path: PathBuf::from("test.bin"), + captures: vec![CaptureEntry { + capture: Capture { + ch1: (0..100).map(|i| i as u8).collect(), + ch2: None, + }, + timestamp: Local::now(), + }], + }); + let _ = app.view(); + } + + #[test] + fn adc_voltage_conversion() { + // 128 → 0V (center), 0 → -5V, 255 → ~+5V + assert!((adc_to_voltage(128) - 0.0).abs() < 0.05); + assert!((adc_to_voltage(0) - (-5.0)).abs() < 0.05); + assert!((adc_to_voltage(255) - 4.96).abs() < 0.1); + } + + #[test] + fn samples_to_time_conversion() { + let ms = samples_to_time_ms(1000); + assert!((ms - 1.0).abs() < 0.01); + } + + #[test] + fn png_crc32_is_deterministic() { + let crc1 = png_crc32(b"IHDR", &[0u8; 13]); + let crc2 = png_crc32(b"IHDR", &[0u8; 13]); + assert_eq!(crc1, crc2); + } + + #[test] + fn adler32_known_value() { + // adler32 of "Wikipedia" = 0x11E60398 + let result = adler32(b"Wikipedia"); + assert_eq!(result, 0x11E6_0398); + } + + #[test] + fn settings_dialog_opens_and_closes() { + let (mut app, _) = App::new(); + let _ = app.update(Message::OpenSettings); + assert!(app.show_settings); + let _ = app.update(Message::CloseSettings); + assert!(!app.show_settings); + } + + #[test] + fn serial_start_without_port_shows_error() { + let (mut app, _) = App::new(); + app.settings.serial_port = None; + let _ = app.update(Message::StartListening); + assert!(!app.listening); + assert!(app.status.contains("Select a serial port")); + } +} diff --git a/gui/src/canvas.rs b/gui/src/canvas.rs new file mode 100644 index 0000000..3e9e480 --- /dev/null +++ b/gui/src/canvas.rs @@ -0,0 +1,553 @@ +//! Oscilloscope-style graph canvas (iced::widget::canvas). + +use crate::settings::ViewMode; +use dso3d12_parser::Capture; +use iced::widget::canvas::{self, Frame, Geometry, Path, Stroke, Text}; +use iced::{mouse, Color, Point, Rectangle, Renderer, Size, Theme}; + +/// Drawable scope view backed by an optional `Capture`. +pub struct Scope<'a, Message = ()> { + pub capture: Option<&'a Capture>, + pub show_ch1: bool, + pub show_ch2: bool, + pub view_mode: ViewMode, + /// User-positioned measurement cursors: X range (low, high) in 0.0..1.0. + pub meas_cursor_x: Option<(f32, f32)>, + /// User-positioned measurement cursors: Y range (low, high) in 0.0..1.0. + pub meas_cursor_y: Option<(f32, f32)>, + /// Device-supplied X cursors (read-only, from capture data). + pub device_cursor_x: Option<(f32, f32)>, + /// Device-supplied Y cursors (read-only, from capture data). + pub device_cursor_y: Option<(f32, f32)>, + /// Show numeric axis scales on the graph. + pub show_scales: bool, + /// Number of samples for X scale calculation. + pub n_samples: usize, + /// Whether CH1 data is present in the capture (for scale labelling). + pub has_ch1: bool, + /// Whether CH2 data is present in the capture (for scale labelling). + pub has_ch2: bool, + /// Volts per cell (vertical division). 8 cells total. + pub v_per_cell: f64, + /// Time per cell (horizontal division) in milliseconds. 12 cells total. + pub t_per_cell_ms: f64, + /// Callback message factory for graph clicks: (frac_x, frac_y). + pub on_click: Option Message>, +} + +/// State for cursor drag interaction on the scope canvas. +#[derive(Default)] +pub struct ScopeState { + dragging: Option, +} + +#[derive(Debug, Clone, Copy)] +enum DragTarget { + XLow, + XHigh, + YLow, + YHigh, +} + +const BG: Color = Color::from_rgb(0.04, 0.06, 0.10); +const GRID: Color = Color::from_rgb(0.20, 0.24, 0.30); +const GRID_AXIS: Color = Color::from_rgb(0.35, 0.40, 0.46); +const CH1_COLOR: Color = Color::from_rgb(1.0, 0.85, 0.10); +const CH2_COLOR: Color = Color::from_rgb(0.15, 0.85, 1.0); +/// User-driven measurement cursors (interactive). +const MEAS_CURSOR: Color = Color::from_rgb(1.0, 0.30, 0.50); +const MEAS_FILL: Color = Color::from_rgba(1.0, 0.30, 0.50, 0.08); +/// Device-supplied cursors (read-only). +const DEV_CURSOR: Color = Color::from_rgb(0.40, 1.0, 0.40); + +impl canvas::Program for Scope<'_, Message> { + type State = ScopeState; + + fn update( + &self, + state: &mut Self::State, + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + let on_click = self.on_click?; + let pos = cursor.position_in(bounds)?; + let frac_x = (pos.x / bounds.width).clamp(0.0, 1.0); + let frac_y = (pos.y / bounds.height).clamp(0.0, 1.0); + + match event { + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + // Determine which cursor line is nearest and start drag + let mut best_dist = f32::MAX; + let mut target: Option = None; + + if let Some((lo, hi)) = self.meas_cursor_x { + let d_lo = (frac_x - lo).abs(); + let d_hi = (frac_x - hi).abs(); + if d_lo < best_dist { + best_dist = d_lo; + target = Some(DragTarget::XLow); + } + if d_hi < best_dist { + best_dist = d_hi; + target = Some(DragTarget::XHigh); + } + } + if let Some((lo, hi)) = self.meas_cursor_y { + let d_lo = (frac_y - lo).abs(); + let d_hi = (frac_y - hi).abs(); + if d_lo < best_dist { + best_dist = d_lo; + target = Some(DragTarget::YLow); + } + if d_hi < best_dist { + // best_dist = d_hi; // not needed, last comparison + target = Some(DragTarget::YHigh); + } + } + + let _ = best_dist; + state.dragging = target; + let msg = on_click(frac_x, frac_y); + Some(canvas::Action::publish(msg).and_capture()) + } + canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if state.dragging.is_some() { + let msg = on_click(frac_x, frac_y); + Some(canvas::Action::publish(msg).and_capture()) + } else { + None + } + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + if state.dragging.take().is_some() { + Some(canvas::Action::request_redraw()) + } else { + None + } + } + _ => None, + } + } + + fn draw( + &self, + _state: &Self::State, + renderer: &Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + let mut frame = Frame::new(renderer, bounds.size()); + + // Background. + let area = Path::rectangle(Point::ORIGIN, frame.size()); + frame.fill(&area, BG); + + draw_grid(&mut frame, bounds.size()); + + if let Some(cap) = self.capture { + if self.show_ch1 { + draw_trace(&mut frame, &cap.ch1, CH1_COLOR, self.view_mode); + } + if self.show_ch2 { + if let Some(ch2) = cap.ch2.as_ref() { + // CH2 is stored inverted in the debug dump; flip to match device display. + let flipped: Vec = ch2.iter().map(|&v| 255 - v).collect(); + draw_trace(&mut frame, &flipped, CH2_COLOR, self.view_mode); + } + } + } else { + let text = Text { + content: "No capture loaded — use Load Capture…".to_string(), + position: Point::new(frame.width() / 2.0, frame.height() / 2.0), + color: Color::from_rgb(0.7, 0.7, 0.7), + size: 16.0.into(), + align_x: iced::alignment::Horizontal::Center.into(), + align_y: iced::alignment::Vertical::Center, + ..Text::default() + }; + frame.fill_text(text); + } + + // Measurement cursors (user-controlled, with shaded range). + if let Some((xl, xr)) = self.meas_cursor_x { + let w = frame.width(); + let h = frame.height(); + let px_l = xl.clamp(0.0, 1.0) * w; + let px_r = xr.clamp(0.0, 1.0) * w; + let region = Path::rectangle( + Point::new(px_l, 0.0), + Size::new((px_r - px_l).max(0.0), h), + ); + frame.fill(®ion, MEAS_FILL); + let stroke = Stroke::default().with_color(MEAS_CURSOR).with_width(1.0); + frame.stroke(&Path::line(Point::new(px_l, 0.0), Point::new(px_l, h)), stroke); + frame.stroke(&Path::line(Point::new(px_r, 0.0), Point::new(px_r, h)), stroke); + } + if let Some((yu, yl)) = self.meas_cursor_y { + let w = frame.width(); + let h = frame.height(); + let py_u = yu.clamp(0.0, 1.0) * h; + let py_l = yl.clamp(0.0, 1.0) * h; + let region = Path::rectangle( + Point::new(0.0, py_u), + Size::new(w, (py_l - py_u).max(0.0)), + ); + frame.fill(®ion, MEAS_FILL); + let stroke = Stroke::default().with_color(MEAS_CURSOR).with_width(1.0); + frame.stroke(&Path::line(Point::new(0.0, py_u), Point::new(w, py_u)), stroke); + frame.stroke(&Path::line(Point::new(0.0, py_l), Point::new(w, py_l)), stroke); + } + + // Device cursors (read-only, dashed-look using thinner line, different colour). + let dev_stroke = Stroke::default().with_color(DEV_CURSOR).with_width(1.0); + if let Some((a, b)) = self.device_cursor_x { + let w = frame.width(); + let h = frame.height(); + for x in [a, b] { + let px = x.clamp(0.0, 1.0) * w; + frame.stroke( + &Path::line(Point::new(px, 0.0), Point::new(px, h)), + dev_stroke, + ); + } + // Show numeric values next to device X cursors + let sample_a = (a * self.n_samples as f32) as usize; + let sample_b = (b * self.n_samples as f32) as usize; + let label_a = Text { + content: format!("{sample_a}"), + position: Point::new(a.clamp(0.0, 1.0) * w + 2.0, 12.0), + color: DEV_CURSOR, + size: 10.0.into(), + ..Text::default() + }; + let label_b = Text { + content: format!("{sample_b}"), + position: Point::new(b.clamp(0.0, 1.0) * w + 2.0, 12.0), + color: DEV_CURSOR, + size: 10.0.into(), + ..Text::default() + }; + frame.fill_text(label_a); + frame.fill_text(label_b); + } + if let Some((a, b)) = self.device_cursor_y { + let w = frame.width(); + let h = frame.height(); + for y in [a, b] { + let py = y.clamp(0.0, 1.0) * h; + frame.stroke( + &Path::line(Point::new(0.0, py), Point::new(w, py)), + dev_stroke, + ); + } + // Show numeric voltage values next to device Y cursors + let v_a = (a * 255.0 - 128.0) * 5.0 / 128.0; + let v_b = (b * 255.0 - 128.0) * 5.0 / 128.0; + let label_a = Text { + content: format!("{v_a:.2}V"), + position: Point::new(4.0, a.clamp(0.0, 1.0) * h + 2.0), + color: DEV_CURSOR, + size: 10.0.into(), + ..Text::default() + }; + let label_b = Text { + content: format!("{v_b:.2}V"), + position: Point::new(4.0, b.clamp(0.0, 1.0) * h + 2.0), + color: DEV_CURSOR, + size: 10.0.into(), + ..Text::default() + }; + frame.fill_text(label_a); + frame.fill_text(label_b); + } + + // Axis scales (sample numbers on X, voltage on Y per-channel) + if self.show_scales { + draw_scales( + &mut frame, + self.n_samples, + self.has_ch1, + self.has_ch2, + self.v_per_cell, + self.t_per_cell_ms, + ); + } + + vec![frame.into_geometry()] + } +} + +fn draw_grid(frame: &mut Frame, size: Size) { + // 12 horizontal x 8 vertical divisions, like a real scope. + let cols = 12; + let rows = 8; + let dx = size.width / cols as f32; + let dy = size.height / rows as f32; + + let stroke = Stroke::default().with_color(GRID).with_width(1.0); + for i in 1..cols { + let x = i as f32 * dx; + let p = Path::line(Point::new(x, 0.0), Point::new(x, size.height)); + frame.stroke(&p, stroke); + } + for i in 1..rows { + let y = i as f32 * dy; + let p = Path::line(Point::new(0.0, y), Point::new(size.width, y)); + frame.stroke(&p, stroke); + } + // Centerlines. + let axis = Stroke::default().with_color(GRID_AXIS).with_width(1.5); + let cx = size.width / 2.0; + let cy = size.height / 2.0; + frame.stroke( + &Path::line(Point::new(cx, 0.0), Point::new(cx, size.height)), + axis, + ); + frame.stroke( + &Path::line(Point::new(0.0, cy), Point::new(size.width, cy)), + axis, + ); +} + +const SCALE_COLOR: Color = Color::from_rgb(0.60, 0.65, 0.70); + +/// Number of vertical divisions (cells) on the oscilloscope screen for voltage. +const V_CELLS: usize = 8; +/// Number of horizontal divisions (cells) on the oscilloscope screen for time. +const T_CELLS: usize = 12; + +fn draw_scales( + frame: &mut Frame, + _n_samples: usize, + has_ch1: bool, + has_ch2: bool, + v_per_cell: f64, + t_per_cell_ms: f64, +) { + let size = frame.size(); + let rows = V_CELLS; + let cols = 10; // grid visual divisions (drawn grid is 10 columns) + + // X axis: time labels at each visual division. + // Total time span = T_CELLS * t_per_cell_ms. We distribute over the sample window. + let total_time_ms = T_CELLS as f64 * t_per_cell_ms; + let dx = size.width / cols as f32; + for i in 0..=cols { + let frac = i as f64 / cols as f64; + let time_ms = frac * total_time_ms; + let x = i as f32 * dx; + let content = if time_ms >= 1.0 { + format!("{time_ms:.1}ms") + } else { + format!("{:.0}µs", time_ms * 1000.0) + }; + let label = Text { + content, + position: Point::new(x + 2.0, size.height - 12.0), + color: SCALE_COLOR, + size: 9.0.into(), + ..Text::default() + }; + frame.fill_text(label); + } + + // Y axis: voltage scale. Total range = V_CELLS * v_per_cell, centered around 0. + // Top = +half_range, bottom = -half_range (AC mode). + let half_range_v = (rows as f64 * v_per_cell) / 2.0; + let ch1_x_offset = 2.0; + let ch2_x_offset = if has_ch1 { 42.0 } else { 2.0 }; + + if has_ch1 { + for i in 0..=rows { + let frac = i as f32 / rows as f32; + // frac=0 → top → +half_range; frac=1 → bottom → -half_range + let voltage = half_range_v * (1.0 - 2.0 * frac as f64); + let y = frac * size.height; + let label = Text { + content: format!("{voltage:.2}V"), + position: Point::new(ch1_x_offset, y + 2.0), + color: CH1_COLOR, + size: 9.0.into(), + ..Text::default() + }; + frame.fill_text(label); + } + } + + if has_ch2 { + for i in 0..=rows { + let frac = i as f32 / rows as f32; + let voltage = half_range_v * (1.0 - 2.0 * frac as f64); + let y = frac * size.height; + let label = Text { + content: format!("{voltage:.2}V"), + position: Point::new(ch2_x_offset, y + 2.0), + color: CH2_COLOR, + size: 9.0.into(), + ..Text::default() + }; + frame.fill_text(label); + } + } +} + +fn draw_trace(frame: &mut Frame, samples: &[u8], color: Color, mode: ViewMode) { + if samples.is_empty() { + return; + } + let size = frame.size(); + let n = samples.len(); + let stroke = Stroke::default().with_color(color).with_width(1.2); + + // x maps sample i -> pixel; downsample so we never draw more vertices than pixels. + let max_pts = (size.width as usize).max(2); + let step = n.div_ceil(max_pts).max(1); + + let value_at = |i: usize| -> f32 { + match mode { + ViewMode::Smooth => { + // Moving average with a small window relative to downsampling step. + let half = (step / 2).max(2); + let lo = i.saturating_sub(half); + let hi = (i + half + 1).min(n); + let sum: u32 = samples[lo..hi].iter().map(|&b| b as u32).sum(); + sum as f32 / (hi - lo) as f32 + } + _ => samples[i] as f32, + } + }; + + let to_xy = |i: usize, v: f32| -> Point { + let x = (i as f32 / (n - 1).max(1) as f32) * size.width; + // Invert Y: higher ADC value (higher voltage) at top of screen. + let y = (1.0 - v / 255.0) * size.height; + Point::new(x, y) + }; + + match mode { + ViewMode::Dot => { + let r = 1.2; + for i in (0..n).step_by(step) { + let p = to_xy(i, value_at(i)); + let dot = Path::circle(p, r); + frame.fill(&dot, color); + } + } + ViewMode::Line | ViewMode::Smooth => { + let path = Path::new(|builder| { + let mut started = false; + for i in (0..n).step_by(step) { + let p = to_xy(i, value_at(i)); + if started { + builder.line_to(p); + } else { + builder.move_to(p); + started = true; + } + } + }); + frame.stroke(&path, stroke); + } + } +} + +// ── Thumbnail mini-scope ─────────────────────────────────────────────────── + +const THUMB_BG: Color = Color::from_rgb(0.06, 0.08, 0.12); +const THUMB_BORDER: Color = Color::from_rgb(0.30, 0.35, 0.42); +const THUMB_SELECTED: Color = Color::from_rgb(1.0, 0.30, 0.50); + +/// A tiny waveform thumbnail for the captures strip. +pub struct Thumbnail<'a> { + pub capture: &'a Capture, + pub selected: bool, +} + +impl canvas::Program for Thumbnail<'_> { + type State = (); + + fn draw( + &self, + _state: &Self::State, + renderer: &Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + let mut frame = Frame::new(renderer, bounds.size()); + let size = frame.size(); + + // Background + let bg = Path::rectangle(Point::ORIGIN, size); + frame.fill(&bg, THUMB_BG); + + // Border (highlight if selected) + let border_color = if self.selected { THUMB_SELECTED } else { THUMB_BORDER }; + let border = Path::rectangle(Point::ORIGIN, size); + frame.stroke(&border, Stroke::default().with_color(border_color).with_width(if self.selected { 2.0 } else { 1.0 })); + + // Draw CH1 trace + let samples = &self.capture.ch1; + if !samples.is_empty() { + let n = samples.len(); + let max_pts = (size.width as usize).max(2); + let step = n.div_ceil(max_pts).max(1); + let path = Path::new(|builder| { + let mut started = false; + for i in (0..n).step_by(step) { + let x = (i as f32 / (n - 1).max(1) as f32) * size.width; + let y = (samples[i] as f32 / 255.0) * size.height; + let p = Point::new(x, y); + if started { + builder.line_to(p); + } else { + builder.move_to(p); + started = true; + } + } + }); + frame.stroke(&path, Stroke::default().with_color(CH1_COLOR).with_width(1.0)); + } + + // Draw CH2 if present + if let Some(ch2) = &self.capture.ch2 { + if !ch2.is_empty() { + let n = ch2.len(); + let max_pts = (size.width as usize).max(2); + let step = n.div_ceil(max_pts).max(1); + let path = Path::new(|builder| { + let mut started = false; + for i in (0..n).step_by(step) { + let x = (i as f32 / (n - 1).max(1) as f32) * size.width; + let y = (ch2[i] as f32 / 255.0) * size.height; + let p = Point::new(x, y); + if started { + builder.line_to(p); + } else { + builder.move_to(p); + started = true; + } + } + }); + frame.stroke(&path, Stroke::default().with_color(CH2_COLOR).with_width(1.0)); + } + } + + vec![frame.into_geometry()] + } + + fn mouse_interaction( + &self, + _state: &Self::State, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if cursor.is_over(bounds) { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } + } +} diff --git a/gui/src/flash.rs b/gui/src/flash.rs new file mode 100644 index 0000000..8c0e683 --- /dev/null +++ b/gui/src/flash.rs @@ -0,0 +1,327 @@ +//! Firmware update module for the GUI. +//! +//! Reuses the XMODEM-1K protocol logic from `flasher/src/flash.rs`. +//! Runs the flash process in a background thread, reporting progress +//! via a channel. + +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crc::{Crc, CRC_16_XMODEM}; +use tokio::sync::mpsc; + +const DEFAULT_BAUDRATE: u32 = 115200; +const SYNC_THRESHOLD: usize = 5; + +const BAUDRATE_LIST: &[(u32, &[u8])] = &[ + ( + 2000000, + b"\x21\x0a\x00\xef\x2a\x31\x00\x00\x00\x80\x84\x1e\x00", + ), + ( + 1000000, + b"\x21\x0a\x00\x5e\x3d\x31\x00\x00\x00\x40\x42\x0f\x00", + ), + ( + 921600, + b"\x21\x0a\x00\x5d\x50\x31\x00\x00\x00\x00\x10\x0e\x00", + ), + ( + 460800, + b"\x21\x0a\x00\x07\x00\x31\x00\x00\x00\x00\x08\x07\x00", + ), + ( + 115200, + b"\x21\x0a\x00\x97\x4b\x31\x00\x00\x00\x00\xc2\x01\x00", + ), +]; + +const REBOOT_CMD: &[u8] = b"\x21\x06\x00\xc7\x7c\x3f\x00\x00\x00"; + +/// Progress events from the flash thread. +#[derive(Debug, Clone)] +pub enum FlashEvent { + Status(String), + Progress { current: u64, total: u64 }, + Finished(Result<(), String>), +} + +/// Handle to a running flash operation. +pub struct FlashHandle { + stop_flag: Arc, + thread: Option>, +} + +impl FlashHandle { + pub fn cancel(&mut self) { + self.stop_flag.store(true, Ordering::Relaxed); + } +} + +impl Drop for FlashHandle { + fn drop(&mut self) { + self.stop_flag.store(true, Ordering::Relaxed); + if let Some(h) = self.thread.take() { + let _ = h.join(); + } + } +} + +/// Start the firmware update process in a background thread. +pub fn start_flash( + port_name: String, + firmware_path: String, + tx: mpsc::UnboundedSender, +) -> FlashHandle { + let stop = Arc::new(AtomicBool::new(false)); + let flag = stop.clone(); + + let thread = std::thread::spawn(move || { + let result = run_flash(&port_name, &firmware_path, &tx, &flag); + let _ = tx.send(FlashEvent::Finished(result.map_err(|e| e.to_string()))); + }); + + FlashHandle { + stop_flag: stop, + thread: Some(thread), + } +} + +fn run_flash( + port_name: &str, + firmware_path: &str, + tx: &mpsc::UnboundedSender, + stop: &AtomicBool, +) -> Result<(), anyhow::Error> { + let _ = tx.send(FlashEvent::Status(format!("Opening {port_name}…"))); + + let mut port = serialport::new(port_name, DEFAULT_BAUDRATE) + .timeout(Duration::from_secs(1)) + .open() + .map_err(|e| anyhow::anyhow!("Cannot open port: {e}"))?; + + if stop.load(Ordering::Relaxed) { + return Err(anyhow::anyhow!("Cancelled")); + } + + // Reset device + let _ = tx.send(FlashEvent::Status("Resetting device…".to_string())); + for _ in 0..10 { + reset_device(&mut *port)?; + send_esc(&mut *port, 20)?; + port.write_all(b"AT+RST\r\n")?; + send_esc(&mut *port, 20)?; + port.write_all(b"reboot\r\n")?; + port.flush()?; + send_esc(&mut *port, 50)?; + } + + if stop.load(Ordering::Relaxed) { + return Err(anyhow::anyhow!("Cancelled")); + } + + // Sync + let _ = tx.send(FlashEvent::Status("Syncing with device…".to_string())); + sync_device(&mut *port)?; + + if stop.load(Ordering::Relaxed) { + return Err(anyhow::anyhow!("Cancelled")); + } + + // Negotiate baud rate + let _ = tx.send(FlashEvent::Status("Negotiating baud rate…".to_string())); + for &(baud, cmd) in BAUDRATE_LIST { + if configure_baudrate(&mut *port, baud, cmd).is_ok() { + let _ = tx.send(FlashEvent::Status(format!("Using {baud} baud."))); + break; + } + } + + if stop.load(Ordering::Relaxed) { + return Err(anyhow::anyhow!("Cancelled")); + } + + // Read firmware file + let file_data = std::fs::read(firmware_path) + .map_err(|e| anyhow::anyhow!("Cannot read firmware: {e}"))?; + let total_blocks = (file_data.len() as u64 / 1024) + 1; + + let _ = tx.send(FlashEvent::Status("Flashing…".to_string())); + + // XMODEM-1K send + xmodem1k_send(&mut *port, &file_data, total_blocks, tx, stop)?; + + // Reboot + let _ = tx.send(FlashEvent::Status("Rebooting device…".to_string())); + port.write_all(REBOOT_CMD)?; + port.flush()?; + + let _ = tx.send(FlashEvent::Status("Flash complete!".to_string())); + Ok(()) +} + +fn reset_device(port: &mut dyn serialport::SerialPort) -> Result<(), anyhow::Error> { + port.write_data_terminal_ready(false)?; + port.write_request_to_send(true)?; + std::thread::sleep(Duration::from_millis(10)); + port.write_data_terminal_ready(true)?; + port.write_request_to_send(false)?; + std::thread::sleep(Duration::from_millis(10)); + port.write_data_terminal_ready(false)?; + Ok(()) +} + +fn send_esc(port: &mut dyn serialport::SerialPort, ms: u64) -> Result<(), anyhow::Error> { + let delay = Duration::from_millis(10); + let iterations = ms / 10; + for _ in 0..iterations { + port.write_all(&[0x1B])?; + std::thread::sleep(delay); + } + Ok(()) +} + +fn sync_device(port: &mut dyn serialport::SerialPort) -> Result<(), anyhow::Error> { + let mut cnt = 0; + let start = Instant::now(); + let mut buf = [0; 1]; + + loop { + match port.read(&mut buf) { + Ok(1) => { + if buf[0] == b'C' { + cnt += 1; + } else { + cnt = 0; + } + if cnt >= SYNC_THRESHOLD { + return Ok(()); + } + } + _ => { + port.write_all(&[0x1B])?; + std::thread::sleep(Duration::from_millis(30)); + } + } + if start.elapsed() > Duration::from_secs(60) { + return Err(anyhow::anyhow!("Sync timeout — ensure device is in boot mode")); + } + } +} + +fn configure_baudrate( + port: &mut dyn serialport::SerialPort, + baud: u32, + cmd: &[u8], +) -> Result<(), anyhow::Error> { + port.set_baud_rate(DEFAULT_BAUDRATE)?; + if baud != DEFAULT_BAUDRATE { + port.set_baud_rate(DEFAULT_BAUDRATE)?; + std::thread::sleep(Duration::from_millis(20)); + } + port.write_all(cmd)?; + port.flush()?; + std::thread::sleep(Duration::from_millis(20)); + + // Drain + let mut drain = [0u8; 64]; + while port.bytes_to_read()? > 0 { + let _ = port.read(&mut drain); + } + + port.set_baud_rate(baud)?; + std::thread::sleep(Duration::from_millis(200)); + Ok(()) +} + +fn xmodem1k_send( + port: &mut dyn serialport::SerialPort, + data: &[u8], + total_blocks: u64, + tx: &mpsc::UnboundedSender, + stop: &AtomicBool, +) -> Result<(), anyhow::Error> { + let crc = Crc::::new(&CRC_16_XMODEM); + let mut block_num = 1u8; + let mut offset = 0; + + while offset < data.len() { + if stop.load(Ordering::Relaxed) { + return Err(anyhow::anyhow!("Cancelled")); + } + + let end = (offset + 1024).min(data.len()); + let mut buffer = [0u8; 1024]; + let chunk_len = end - offset; + buffer[..chunk_len].copy_from_slice(&data[offset..end]); + if chunk_len < 1024 { + buffer[chunk_len..].fill(0x1A); + } + + let checksum = crc.checksum(&buffer); + let mut success = false; + + for _ in 0..10 { + port.write_all(&[0x02, block_num, 255 - block_num])?; + port.write_all(&buffer)?; + port.write_all(&checksum.to_be_bytes())?; + port.flush()?; + + let mut ack = [0; 1]; + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(3) { + if let Ok(1) = port.read(&mut ack) { + if ack[0] == 0x06 { + success = true; + break; + } else if ack[0] == 0x15 { + break; + } + } + } + if success { + break; + } + } + + if !success { + return Err(anyhow::anyhow!("Failed to send block {block_num}")); + } + + let block_idx = (offset / 1024 + 1) as u64; + let _ = tx.send(FlashEvent::Progress { + current: block_idx, + total: total_blocks, + }); + block_num = block_num.wrapping_add(1); + offset += 1024; + } + + // Send EOT + port.write_all(&[0x04])?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn baudrate_list_has_entries() { + assert!(!BAUDRATE_LIST.is_empty()); + } + + #[test] + fn baudrate_commands_are_13_bytes() { + for &(_, cmd) in BAUDRATE_LIST { + assert_eq!(cmd.len(), 13); + } + } + + #[test] + fn reboot_cmd_is_9_bytes() { + assert_eq!(REBOOT_CMD.len(), 9); + } +} diff --git a/gui/src/logging.rs b/gui/src/logging.rs new file mode 100644 index 0000000..02cd61a --- /dev/null +++ b/gui/src/logging.rs @@ -0,0 +1,107 @@ +//! File + stderr logging for the GUI. +//! +//! Logs go to `/dso3d12-gui/dso3d12-gui.log` (rotated naively when +//! it exceeds ~1 MB by being renamed to `.log.1`). Logs are also printed to +//! stderr at the level chosen by `RUST_LOG` (default `info`). + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Mutex; + +use log::{Level, LevelFilter, Metadata, Record}; + +pub fn log_path() -> Option { + let dir = dirs::config_dir()?.join("dso3d12-gui"); + std::fs::create_dir_all(&dir).ok()?; + Some(dir.join("dso3d12-gui.log")) +} + +struct FileLogger { + file: Mutex>, + stderr_level: Level, +} + +impl log::Log for FileLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= Level::Trace + } + + fn log(&self, record: &Record) { + if !self.enabled(record.metadata()) { + return; + } + let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); + let line = format!( + "[{ts}] {:5} {}: {}\n", + record.level(), + record.target(), + record.args() + ); + if record.level() <= self.stderr_level { + eprint!("{line}"); + } + if let Ok(mut guard) = self.file.lock() { + if let Some(f) = guard.as_mut() { + let _ = f.write_all(line.as_bytes()); + } + } + } + + fn flush(&self) { + if let Ok(mut g) = self.file.lock() { + if let Some(f) = g.as_mut() { + let _ = f.flush(); + } + } + } +} + +/// Initialize global logger. Safe to call multiple times; only the first call +/// installs the logger. +pub fn init() { + let stderr_level = std::env::var("RUST_LOG") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(Level::Info); + + let file = log_path().and_then(|p| { + rotate_if_needed(&p); + OpenOptions::new().create(true).append(true).open(&p).ok() + }); + + let logger = FileLogger { + file: Mutex::new(file), + stderr_level, + }; + let _ = log::set_boxed_logger(Box::new(logger)) + .map(|()| log::set_max_level(LevelFilter::Trace)); +} + +fn rotate_if_needed(path: &PathBuf) { + const MAX_BYTES: u64 = 1_000_000; + if let Ok(meta) = std::fs::metadata(path) { + if meta.len() > MAX_BYTES { + let backup = path.with_extension("log.1"); + let _ = std::fs::rename(path, backup); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log_path_returns_some() { + // On typical dev machines a config dir exists; just check the call + // doesn't panic. The result may be None inside sandboxes. + let _ = log_path(); + } + + #[test] + fn init_is_idempotent() { + init(); + init(); + } +} diff --git a/gui/src/main.rs b/gui/src/main.rs new file mode 100644 index 0000000..7f34e47 --- /dev/null +++ b/gui/src/main.rs @@ -0,0 +1,20 @@ +mod app; +mod canvas; +mod flash; +mod logging; +mod range_slider; +mod serial; +mod settings; + +use app::App; + +fn main() -> iced::Result { + logging::init(); + log::info!("starting dso3d12-gui"); + + iced::application(App::new, App::update, App::view) + .title(App::title) + .theme(App::theme) + .subscription(App::subscription) + .run() +} diff --git a/gui/src/range_slider.rs b/gui/src/range_slider.rs new file mode 100644 index 0000000..93630e3 --- /dev/null +++ b/gui/src/range_slider.rs @@ -0,0 +1,282 @@ +//! A two-knob range slider widget for iced 0.14. +//! +//! Implemented as a `canvas::Program` over a 1-line track. The user drags the +//! knob nearest the mouse-down position. The slider emits `(low, high)` value +//! pairs (each in `0.0..=1.0`) via the supplied `on_change` callback. +#![allow(dead_code)] + +use iced::widget::canvas::{self, Frame, Geometry, Path, Stroke}; +use iced::{ + mouse, touch, Color, Element, Length, Point, Rectangle, Renderer, Size, Theme, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Orientation { + Horizontal, + Vertical, +} + +#[derive(Debug, Clone, Copy, Default)] +enum Drag { + #[default] + None, + Low, + High, +} + +/// Mutable per-program state held by iced. +#[derive(Debug, Default)] +pub struct State { + drag: Drag, +} + +/// Range slider program. +pub struct RangeSlider { + pub low: f32, + pub high: f32, + pub orientation: Orientation, + pub on_change: Box Message>, +} + +impl RangeSlider { + pub fn horizontal( + low: f32, + high: f32, + on_change: impl Fn(f32, f32) -> Message + 'static, + ) -> Self { + Self { + low, + high, + orientation: Orientation::Horizontal, + on_change: Box::new(on_change), + } + } + + pub fn vertical( + low: f32, + high: f32, + on_change: impl Fn(f32, f32) -> Message + 'static, + ) -> Self { + Self { + low, + high, + orientation: Orientation::Vertical, + on_change: Box::new(on_change), + } + } +} + +const TRACK_COLOR: Color = Color::from_rgb(0.30, 0.34, 0.42); +const ACTIVE_COLOR: Color = Color::from_rgb(1.0, 0.30, 0.50); +const KNOB_COLOR: Color = Color::from_rgb(0.95, 0.95, 0.95); +const KNOB_RADIUS: f32 = 7.0; +const TRACK_THICKNESS: f32 = 4.0; + +impl canvas::Program for RangeSlider { + type State = State; + + fn update( + &self, + state: &mut Self::State, + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + let cursor_pos = cursor.position_in(bounds); + match event { + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | canvas::Event::Touch(touch::Event::FingerPressed { .. }) => { + let pos = cursor_pos?; + // Pick the knob whose center is nearer along the active axis. + let v = self.value_from_cursor(pos, bounds.size()); + let d_low = (v - self.low).abs(); + let d_high = (v - self.high).abs(); + state.drag = if d_low <= d_high { Drag::Low } else { Drag::High }; + let action = self.publish_value(v, state); + Some(action.and_capture()) + } + canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) + | canvas::Event::Touch(touch::Event::FingerMoved { .. }) => { + if matches!(state.drag, Drag::None) { + return None; + } + let pos = cursor_pos?; + let v = self.value_from_cursor(pos, bounds.size()); + Some(self.publish_value(v, state).and_capture()) + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | canvas::Event::Touch(touch::Event::FingerLifted { .. }) + | canvas::Event::Touch(touch::Event::FingerLost { .. }) => { + state.drag = Drag::None; + None + } + _ => None, + } + } + + fn draw( + &self, + _state: &Self::State, + renderer: &Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + let mut frame = Frame::new(renderer, bounds.size()); + let size = bounds.size(); + + match self.orientation { + Orientation::Horizontal => { + let cy = size.height / 2.0; + let track = Path::rectangle( + Point::new(0.0, cy - TRACK_THICKNESS / 2.0), + Size::new(size.width, TRACK_THICKNESS), + ); + frame.fill(&track, TRACK_COLOR); + + let xl = self.low.clamp(0.0, 1.0) * size.width; + let xh = self.high.clamp(0.0, 1.0) * size.width; + let active = Path::rectangle( + Point::new(xl, cy - TRACK_THICKNESS / 2.0), + Size::new((xh - xl).max(0.0), TRACK_THICKNESS), + ); + frame.fill(&active, ACTIVE_COLOR); + + self.draw_knob(&mut frame, Point::new(xl, cy)); + self.draw_knob(&mut frame, Point::new(xh, cy)); + } + Orientation::Vertical => { + let cx = size.width / 2.0; + let track = Path::rectangle( + Point::new(cx - TRACK_THICKNESS / 2.0, 0.0), + Size::new(TRACK_THICKNESS, size.height), + ); + frame.fill(&track, TRACK_COLOR); + + let yl = self.low.clamp(0.0, 1.0) * size.height; + let yh = self.high.clamp(0.0, 1.0) * size.height; + let (top, bot) = (yl.min(yh), yl.max(yh)); + let active = Path::rectangle( + Point::new(cx - TRACK_THICKNESS / 2.0, top), + Size::new(TRACK_THICKNESS, (bot - top).max(0.0)), + ); + frame.fill(&active, ACTIVE_COLOR); + + self.draw_knob(&mut frame, Point::new(cx, yl)); + self.draw_knob(&mut frame, Point::new(cx, yh)); + } + } + + vec![frame.into_geometry()] + } + + fn mouse_interaction( + &self, + state: &Self::State, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if !matches!(state.drag, Drag::None) { + return mouse::Interaction::Grabbing; + } + if cursor.is_over(bounds) { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } + } +} + +impl RangeSlider { + fn value_from_cursor(&self, pos: Point, size: Size) -> f32 { + match self.orientation { + Orientation::Horizontal => (pos.x / size.width).clamp(0.0, 1.0), + Orientation::Vertical => (pos.y / size.height).clamp(0.0, 1.0), + } + } + + fn publish_value(&self, v: f32, state: &mut State) -> canvas::Action { + let (mut low, mut high) = (self.low, self.high); + match state.drag { + Drag::Low => { + low = v.min(self.high); + } + Drag::High => { + high = v.max(self.low); + } + Drag::None => return canvas::Action::request_redraw(), + } + canvas::Action::publish((self.on_change)(low, high)) + } + + fn draw_knob(&self, frame: &mut Frame, center: Point) { + let circle = Path::circle(center, KNOB_RADIUS); + frame.fill(&circle, KNOB_COLOR); + let outline = Path::circle(center, KNOB_RADIUS); + frame.stroke( + &outline, + Stroke::default().with_color(ACTIVE_COLOR).with_width(1.5), + ); + } +} + +/// Convenience wrapper: build a `canvas` widget of fixed size for this slider. +pub fn horizontal<'a, Message: Clone + 'a>( + low: f32, + high: f32, + on_change: impl Fn(f32, f32) -> Message + 'static, +) -> Element<'a, Message> { + iced::widget::canvas(RangeSlider::horizontal(low, high, on_change)) + .width(Length::Fill) + .height(Length::Fixed(22.0)) + .into() +} + +pub fn vertical<'a, Message: Clone + 'a>( + low: f32, + high: f32, + on_change: impl Fn(f32, f32) -> Message + 'static, +) -> Element<'a, Message> { + iced::widget::canvas(RangeSlider::vertical(low, high, on_change)) + .width(Length::Fixed(22.0)) + .height(Length::Fill) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rs() -> RangeSlider<(f32, f32)> { + RangeSlider::horizontal(0.3, 0.7, |a, b| (a, b)) + } + + #[test] + fn value_from_cursor_is_normalized() { + let s = rs(); + let v = s.value_from_cursor(Point::new(50.0, 11.0), Size::new(100.0, 22.0)); + assert!((v - 0.5).abs() < 1e-5); + } + + #[test] + fn value_from_cursor_clamps() { + let s = rs(); + let v = s.value_from_cursor(Point::new(-10.0, 11.0), Size::new(100.0, 22.0)); + assert_eq!(v, 0.0); + let v = s.value_from_cursor(Point::new(200.0, 11.0), Size::new(100.0, 22.0)); + assert_eq!(v, 1.0); + } + + #[test] + fn low_knob_cannot_exceed_high() { + let mut s = State { drag: Drag::Low }; + let rs = rs(); + let action = rs.publish_value(0.9, &mut s); + // Action carries Message via publish_value's internal callback; we verify + // the contract directly via the slider's clamp logic. + let _ = action; + // Direct check on logic: + let v = 0.9_f32.min(rs.high); + assert!(v <= rs.high); + } +} diff --git a/gui/src/serial.rs b/gui/src/serial.rs new file mode 100644 index 0000000..7b34f5d --- /dev/null +++ b/gui/src/serial.rs @@ -0,0 +1,221 @@ +//! Serial port communication for live capture from the DSO3D12. +//! +//! Runs a background thread that reads from the configured serial port using +//! the `serialport` crate. Received bytes are buffered; when no data arrives +//! for 500 ms we treat the buffered bytes as a complete capture and send it +//! to the GUI via a channel. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use dso3d12_parser::{parse_captures, Capture}; +use tokio::sync::mpsc; + +/// Configuration for serial capture. +#[derive(Debug, Clone)] +pub struct SerialConfig { + pub port: String, + pub baud_rate: u32, +} + +impl Default for SerialConfig { + fn default() -> Self { + Self { + port: String::new(), + baud_rate: 115200, + } + } +} + +/// Handle to a running serial listener. Drop or call `stop()` to end it. +pub struct SerialHandle { + stop_flag: Arc, + thread: Option>, +} + +impl SerialHandle { + pub fn stop(&mut self) { + self.stop_flag.store(true, Ordering::Relaxed); + if let Some(h) = self.thread.take() { + let _ = h.join(); + } + } +} + +impl Drop for SerialHandle { + fn drop(&mut self) { + self.stop(); + } +} + +/// Events emitted by the serial listener. +#[derive(Debug, Clone)] +pub enum SerialEvent { + /// A complete capture was received and parsed. + CaptureReceived(Vec), + /// Bytes received so far (progress indicator: total bytes buffered). + Progress(usize), + /// An error occurred (port disconnected, parse failure, etc.). + Error(String), + /// Port opened successfully. + Connected, + /// Port was disconnected; will attempt to reconnect. + Disconnected, +} + +/// List available serial ports on the system, filtered to relevant ones. +/// On macOS, only `tty.usbserial*` ports are shown (matching the flasher tool). +pub fn list_ports() -> Vec { + let ports = serialport::available_ports().unwrap_or_default(); + ports + .into_iter() + .map(|p| p.port_name) + .filter(|name| { + if cfg!(target_os = "macos") { + // On macOS, only show USB-serial adapters (tty.usbserial-*) + name.contains("tty.usbserial") + } else { + true + } + }) + .collect() +} + +/// Spawn a background thread that reads from the given serial port. +/// +/// Capture detection: when 500 ms passes with no new bytes, the accumulated +/// buffer is treated as a complete dump and parsed. Successfully parsed +/// captures are sent via the returned channel. +/// +/// If the port disconnects, the thread will retry every 2 seconds until +/// stopped. +pub fn start_listening( + config: SerialConfig, + tx: mpsc::UnboundedSender, +) -> SerialHandle { + let stop_flag = Arc::new(AtomicBool::new(false)); + let flag = stop_flag.clone(); + + let thread = std::thread::spawn(move || { + listener_loop(&config, &tx, &flag); + }); + + SerialHandle { + stop_flag, + thread: Some(thread), + } +} + +const CAPTURE_TIMEOUT: Duration = Duration::from_millis(500); +const RECONNECT_DELAY: Duration = Duration::from_secs(2); +const READ_TIMEOUT: Duration = Duration::from_millis(100); + +fn listener_loop( + config: &SerialConfig, + tx: &mpsc::UnboundedSender, + stop: &AtomicBool, +) { + while !stop.load(Ordering::Relaxed) { + match open_port(config) { + Ok(mut port) => { + let _ = tx.send(SerialEvent::Connected); + read_loop(&mut *port, tx, stop); + let _ = tx.send(SerialEvent::Disconnected); + } + Err(e) => { + log::warn!("cannot open {}: {e}", config.port); + let _ = tx.send(SerialEvent::Error(format!( + "Cannot open {}: {e}", + config.port + ))); + } + } + // Wait before reconnecting. + let deadline = Instant::now() + RECONNECT_DELAY; + while Instant::now() < deadline && !stop.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(100)); + } + } +} + +fn open_port(config: &SerialConfig) -> Result, serialport::Error> { + serialport::new(&config.port, config.baud_rate) + .data_bits(serialport::DataBits::Eight) + .stop_bits(serialport::StopBits::One) + .parity(serialport::Parity::None) + .flow_control(serialport::FlowControl::None) + .timeout(READ_TIMEOUT) + .open() +} + +fn read_loop( + port: &mut dyn serialport::SerialPort, + tx: &mpsc::UnboundedSender, + stop: &AtomicBool, +) { + let mut buf = [0u8; 4096]; + let mut accum: Vec = Vec::with_capacity(256_000); + let mut last_data = Instant::now(); + + while !stop.load(Ordering::Relaxed) { + match port.read(&mut buf) { + Ok(n) if n > 0 => { + accum.extend_from_slice(&buf[..n]); + last_data = Instant::now(); + let _ = tx.send(SerialEvent::Progress(accum.len())); + } + Ok(_) => {} + Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => { + // Normal — no data right now. + } + Err(ref e) if e.kind() == std::io::ErrorKind::BrokenPipe + || e.kind() == std::io::ErrorKind::PermissionDenied => + { + log::warn!("serial port disconnected: {e}"); + return; // Will trigger reconnect. + } + Err(e) => { + log::error!("serial read error: {e}"); + return; + } + } + + // Check if we have buffered data and the timeout has elapsed. + if !accum.is_empty() && last_data.elapsed() >= CAPTURE_TIMEOUT { + log::info!("capture timeout; {} bytes buffered, parsing…", accum.len()); + match parse_captures(&accum) { + Ok(captures) if !captures.is_empty() => { + let _ = tx.send(SerialEvent::CaptureReceived(captures)); + } + Ok(_) => { + let _ = tx.send(SerialEvent::Error( + "Received data but no valid captures found.".to_string(), + )); + } + Err(e) => { + let _ = tx.send(SerialEvent::Error(format!("Parse error: {e}"))); + } + } + accum.clear(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_ports_does_not_panic() { + // May return empty on CI, but must not crash. + let _ = list_ports(); + } + + #[test] + fn serial_config_default() { + let cfg = SerialConfig::default(); + assert_eq!(cfg.baud_rate, 115200); + assert!(cfg.port.is_empty()); + } +} diff --git a/gui/src/settings.rs b/gui/src/settings.rs new file mode 100644 index 0000000..cda66c5 --- /dev/null +++ b/gui/src/settings.rs @@ -0,0 +1,123 @@ +//! Persisted user settings, stored as JSON under the OS config dir. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Settings { + /// Last-used serial port (persisted as default for next launch). + pub serial_port: Option, + pub baud_rate: u32, + pub show_instructions: bool, + pub show_ch1: bool, + pub show_ch2: bool, + /// Show user-controlled measurement cursors on the X axis. + pub measurement_cursor_x_enabled: bool, + /// Show user-controlled measurement cursors on the Y axis. + pub measurement_cursor_y_enabled: bool, + /// Show device-supplied cursors (if present in capture data). + pub device_cursor_x_enabled: bool, + pub device_cursor_y_enabled: bool, + pub view_mode: ViewMode, + /// X measurement cursor range (low, high), each 0.0..1.0 of sample window. + pub cursor_x_range: (f32, f32), + /// Y measurement cursor range (low, high), each 0.0..1.0 of vertical axis. + pub cursor_y_range: (f32, f32), + /// Show numeric axis scales on the graph. + pub show_scales: bool, + /// Split CH1/CH2 into separate subimages in PNG export. + pub split_png: bool, + /// Play a beep when serial data arrives. + pub alert_on_data: bool, + /// Automatically start listening when the configured port appears. + pub auto_listen: bool, + /// Volts per cell (division) for the scope grid. Default 1.0 V/cell. + #[serde(default = "default_v_per_cell")] + pub v_per_cell: f64, + /// Time per cell (division) in milliseconds. Default 1.0 ms/cell. + #[serde(default = "default_t_per_cell_ms")] + pub t_per_cell_ms: f64, +} + +fn default_v_per_cell() -> f64 { + 1.0 +} + +fn default_t_per_cell_ms() -> f64 { + 1.0 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ViewMode { + Line, + Dot, + Smooth, +} + +impl ViewMode { + pub const ALL: [ViewMode; 3] = [ViewMode::Line, ViewMode::Dot, ViewMode::Smooth]; +} + +impl std::fmt::Display for ViewMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + ViewMode::Line => "Line", + ViewMode::Dot => "Dot", + ViewMode::Smooth => "Smooth", + }) + } +} + +impl Default for Settings { + fn default() -> Self { + Self { + serial_port: None, + baud_rate: 115200, + show_instructions: true, + show_ch1: true, + show_ch2: true, + measurement_cursor_x_enabled: false, + measurement_cursor_y_enabled: false, + device_cursor_x_enabled: false, + device_cursor_y_enabled: false, + view_mode: ViewMode::Line, + cursor_x_range: (0.3, 0.7), + cursor_y_range: (0.3, 0.7), + show_scales: false, + split_png: false, + alert_on_data: true, + auto_listen: false, + v_per_cell: 1.0, + t_per_cell_ms: 1.0, + } + } +} + +pub fn config_dir_path() -> Option { + let dir = dirs::config_dir()?.join("dso3d12-gui"); + std::fs::create_dir_all(&dir).ok()?; + Some(dir) +} + +fn config_path() -> Option { + config_dir_path().map(|d| d.join("settings.json")) +} + +impl Settings { + pub fn load() -> Self { + let Some(path) = config_path() else { + return Self::default(); + }; + match std::fs::read_to_string(&path) { + Ok(text) => serde_json::from_str(&text).unwrap_or_default(), + Err(_) => Self::default(), + } + } + + pub fn save(&self) { + let Some(path) = config_path() else { return }; + if let Ok(text) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(path, text); + } + } +} From ebd7ea15ecf2c7303b45b229d6def61bfcf7b50b Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Sun, 31 May 2026 18:48:28 +0300 Subject: [PATCH 02/23] GUI/parser: added support of screenshot binary format --- Cargo.toml | 2 +- docs/GUI_DESIGN.md | 66 +- docs/SCREENSHOT_PROTOCOL.md | 299 +++++++ docs/USER_MANUAL.md | 94 ++- {dso3d12-parser => dso-parser}/Cargo.toml | 5 +- dso-parser/src/container.rs | 415 ++++++++++ {dso3d12-parser => dso-parser}/src/lib.rs | 33 +- dso-parser/src/screenshot.rs | 950 ++++++++++++++++++++++ flasher/Cargo.toml | 5 + flasher/src/capture.rs | 298 +++++++ gui/Cargo.toml | 2 +- gui/src/app.rs | 446 +++++++--- gui/src/canvas.rs | 46 +- gui/src/logging.rs | 15 +- gui/src/serial.rs | 34 +- gui/src/settings.rs | 24 +- 16 files changed, 2552 insertions(+), 182 deletions(-) create mode 100644 docs/SCREENSHOT_PROTOCOL.md rename {dso3d12-parser => dso-parser}/Cargo.toml (71%) create mode 100644 dso-parser/src/container.rs rename {dso3d12-parser => dso-parser}/src/lib.rs (90%) create mode 100644 dso-parser/src/screenshot.rs create mode 100644 flasher/src/capture.rs diff --git a/Cargo.toml b/Cargo.toml index c607925..78fea03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ resolver = "2" members = [ "flasher", - "dso3d12-parser", + "dso-parser", "gui", ] diff --git a/docs/GUI_DESIGN.md b/docs/GUI_DESIGN.md index 8bfb73a..c0f01e7 100644 --- a/docs/GUI_DESIGN.md +++ b/docs/GUI_DESIGN.md @@ -59,33 +59,46 @@ yellow CH1, cyan CH2. Renders: * Green thin lines for **device cursors** (data-supplied, read-only) with numeric labels (sample numbers for X, voltage for Y). * Optional **axis scales** toggled via "Show scales" setting. Scale values - are derived from user-configurable V/cell and time/cell parameters. + are derived from user-configurable V/div and time/div parameters. ### 3. Measurement panel (right, 220 px) Vertical column inside a rounded container. **Scale configuration** (always visible): -* **V/cell** text input — volts per division (default 1.0 V). With "V" unit. -* **t/cell** text input — time per division in ms (default 1.0 ms). With "ms" unit. -* Label: "AC mode: 8×V, 12×t cells" — documents the scope grid geometry. +* **V/div** text input — volts per division (default 1.0 V). With "V" unit. +* **t/div** text input — time per division in ms (default 1.0 ms). With "ms" unit. +* **V/off** text input — voltage offset at graph center (default 0.0 V). With "V" unit. +* Label: "AC mode: 8×V, 12×t divs" — documents the scope grid geometry. These values drive all voltage/time calculations throughout the panel and -graph scales. The oscilloscope screen has 8 vertical and 12 horizontal cells. +graph scales. The oscilloscope screen has 8 vertical and 12 horizontal divs. +When a capture arrives via the screenshot protocol, V/div, t/div, and V/offset +are **automatically filled** from the packet metadata. **Signal information** (shown when a capture is loaded): -* Voltage range for CH1/CH2 (computed from V/cell × 8 cells, centered at 0). -* Time range (computed from t/cell × 12 cells) + sample count. +* Voltage range for CH1/CH2 (computed from V/div × 8 divs, centered at 0). +* Time range (computed from t/div × 12 divs) + sample count. **Two range sliders**, one per axis, each a custom widget (`range_slider.rs`) drawn with `canvas::Program`. Each slider has **two knobs** (low, high); the user drags whichever knob is nearer. Below each slider the panel shows cursor measurements: -* X cursors → Δt in ms + frequency in Hz (using real t/cell scale). -* Y cursors → ΔV in volts + absolute voltage pair (using real V/cell scale). +* X cursors → Δt in ms + frequency in Hz (using real t/div scale). +* Y cursors → ΔV in volts + absolute voltage pair (using real V/div scale). A divider, then per-channel basic stats (`n`, `min`, `max`, `avg`). +**Trigger info** (shown when capture comes from screenshot format): +* Mode (Auto/Normal/Single), Edge, Source. +* Level in volts. +* Delay + sample rate. + +**Auto-measurements table** (shown when screenshot measurements available): +* Two-column table (CH1, CH2). +* Rows: Freq, PkPk, Avg, RMS, Amp, +Duty, -Duty, +T, -T, T, Max, Min, Top, Base. +* Values formatted in physical units (V, Hz, s). + ### 4. Data-controls panel (below graph) * CH1 / CH2 visibility toggles. * **Device cursor** toggles — **disabled** when no device-cursor data in capture. @@ -101,7 +114,11 @@ into the main graph. `Application Settings`. ### 7. Instructions panel -Toggleable text block. Visibility persisted in settings. +Toggleable two-column panel. Visibility persisted in settings. +* **Left column** — Standard firmware: how to trigger a raw debug dump + (long-press Save in measurement view). +* **Right column** — Screenshot firmware: how to transmit a screenshot packet + (Gallery → select waveform). ### 8. Settings dialog Full-page replacement view (el15 card-based style). Sections: @@ -147,6 +164,14 @@ Never share state between the two. * Uses `serialport` crate: 115200 baud, 8N1, no flow control. * Background thread (`serial.rs`) reads continuously. +* **Two capture protocols are auto-detected:** + 1. **ASCII debug dump** — triggered by long-press Save on the device. + Variable size (~150–500 kB); detected by `--------CH1--------` marker. + Contains 60 000 raw samples per channel. + 2. **Binary screenshot packet** — triggered by opening a saved waveform in + the device Gallery. Fixed size 2048 bytes; detected by size (2034–2056 bytes) + after a 200 ms of serial silence. + Contains 300 display pixels (min + max ADC per pixel) plus rich settings. * Capture detection: 500 ms timeout with no new bytes → parse accumulated buffer. * Auto-reconnect: on disconnect, retries every 2 s until stopped. * Selected port persisted in settings; app tries to reconnect on startup. @@ -175,17 +200,26 @@ external image crate needed). Filename is auto-generated as "cursor_y_range": [0.3, 0.7], "show_scales": false, "alert_on_data": true, - "auto_listen": false + "auto_listen": false, + "v_per_div": 1.0, + "t_per_div_ms": 1.0, + "v_offset": 0.0, + "split_png": false } ``` ## Logging -`logging.rs` — global file+stderr logger → `/dso3d12-gui/dso3d12-gui.log`. -Rotates at ~1 MB. Default level: info; override with `RUST_LOG`. +`logging.rs` — console (stderr) logger only by default. File logging to +`/dso3d12-gui/dso3d12-gui.log` can be enabled by setting +environment variable `DSO_LOG_FILE=1`. Rotates at ~1 MB. +Default level: info; override with `RUST_LOG`. ## Voltage & time assumptions -* ADC 0..255 mapped to ±5 V (0 = −5 V, 128 = 0 V, 255 ≈ +5 V). -* Sample rate assumed 1 MHz (1 µs/sample) for time calculations. -* These will be refined when device metadata extraction is implemented. +* ADC 0..255 mapped to ±5 V (0 = −5 V, 128 = 0 V, 255 ≈ +5 V) for raw debug dumps. +* Scale labels are derived from V/div and V/offset settings: + - Top of graph = V/offset + 4×V/div + - Bottom of graph = V/offset − 4×V/div +* Time axis = 12 divisions × t/div (ms). +* When receiving screenshot packets, these are auto-filled from device metadata. diff --git a/docs/SCREENSHOT_PROTOCOL.md b/docs/SCREENSHOT_PROTOCOL.md new file mode 100644 index 0000000..42e6407 --- /dev/null +++ b/docs/SCREENSHOT_PROTOCOL.md @@ -0,0 +1,299 @@ +# Screenshot Packet — Data Analysis Report + +## Purpose + +This report documents the ZeeWeii DSO3D12 **screenshot binary protocol**, +the format of the 2048-byte packet, and how the real captured data can be +used for accurate waveform drawing. + +The implementation is in `dso3d12-parser/src/screenshot.rs`. +The capture CLI tool is `flasher/src/capture.rs`. + +--- + +## Protocol Overview + +### Trigger + +Open the device **Gallery** (Screenshots view) and select a saved waveform. +The device automatically transmits a 2048-byte binary packet over the +USB-serial port at 115200 baud, 8N1. + +This is entirely separate from the ASCII debug dump (triggered by long-press +Save in measurement view). The two protocols should **never be confused**: + +| Property | Debug dump (ASCII) | Screenshot packet (binary) | +|-------------------|-------------------------|----------------------------| +| Size | ~150–500 kB variable | **2048 bytes fixed** | +| Encoding | ASCII decimal | Binary little-endian | +| Samples/channel | 60 000 (full buffer) | **300** (12 div × 25 px) | +| Sample type | One sample = one byte | min/max per display pixel | +| Metadata | None | Rich instrument settings | +| Trigger | Long-press Save | Gallery → select waveform | + +--- + +## Packet Layout (2048 bytes) + +``` +Offset Size Field +───────────────────────────────────────────────────────────────── + 0 300 CH1 waveform minimum ADC values (u8 × 300) + 300 300 CH1 waveform maximum ADC values (u8 × 300) + 600 300 CH2 waveform minimum ADC values (u8 × 300) * + 900 300 CH2 waveform maximum ADC values (u8 × 300) * +1200 340 Oscilloscope settings (DATA_DSO3D12 struct) +1540 100 CH1 auto-measurements (12 × i64 + 2 × i16 LE) +1640 100 CH2 auto-measurements * +1740 308 Reserved / padding +───────────────────────────────────────────────────────────────── +Total: 2048 bytes +* CH2 data is meaningless (but present) when CH2 is disabled. +``` + +### Why min + max? + +The device downsamples the capture buffer (30 000–60 000 samples) to fit the +300-pixel display width. Each pixel covers multiple samples; the device stores +both the **minimum** and **maximum** sample value within that pixel's time +window. This is called an **envelope** representation, and it allows the display +to show waveform transitions that would otherwise alias away at lower resolution. + +For a clean single-line trace: average `(min + max) / 2`. +For a "filled" oscilloscope-style trace: fill between `min` and `max`. + +--- + +## ADC-to-Voltage Conversion + +Raw ADC values are **inverted and zero-centred**: the ADC stores `255 - signal`. +The zero-volt reference is not necessarily at ADC code 128; it is shifted by the +device-calibrated `zero_volt_pixels` offset. + +``` +pixel_val = 255 - raw # uninvert +voltage(V) = (pixel_val - 128 - zero_volt_pixels) × (volt_scale_uv / 25) / 1e6 + × probe_multiplier +``` + +Where `volt_scale_uv` is the µV per **division** (from the VDIV table) and +division = 25 pixels. Probe multiplier: ×1=1, ×10=10, ×100=100. + +--- + +## Key Settings Fields (byte offsets) + +| Offset | Type | Field | Notes | +|--------|-------|--------------------------------------|--------------------------| +| 1200 | u8 | run_state | 0=Stop 1=Run 2=Wait | +| 1201 | u8 | system_state | | +| 1202 | u8 | ch2_enabled | 0=off 1=on | +| 1204 | i64 | buffer_depth (samples) | DSO3D12 only | +| 1216 | u8 | timebase_index | 2=5ns … 30=10s | +| 1220 | u64 | timebase_ns_per_div | ns per division | +| 1228 | i64 | horiz_trigger_delay_ps | | +| 1252 | u32 | sample_rate_hw_hz / 100 | DSO3D12 (×100 to get Hz) | +| 1256 | u8 | trigger_source | 0=CH1 1=CH2 | +| 1257 | u8 | trigger_mode | 0=Auto 1=Normal | +| 1258 | u8 | trigger_edge | 0=Rise 1=Fall | +| 1264 | u16 | trigger_level_adc | raw ADC count | +| 1266 | u8 | ch1_vdiv_index | 1–13 | +| 1268 | u32 | ch1_volt_scale_uv | µV per division | +| 1272 | i32 | ch1_zero_volt_pixels | zero-ref pixel offset | +| 1276 | i64 | ch1_zero_volt_uv | zero-ref in µV | +| 1304 | u8 | ch1_coupling | 0=DC 1=AC | +| 1305 | u8 | ch1_probe_mode | 0=×1 1=×10 2=×100 | +| 1328 | u8 | ch2_vdiv_index | | +| 1332 | u32 | ch2_volt_scale_uv | | +| 1336 | i32 | ch2_zero_volt_pixels | | +| 1392 | u64 | sample_rate_meas_hz | measurement sample rate | +| 1400 | u32 | pre_trigger_samples | | +| 1404 | u32 | post_trigger_samples | | +| 1408 | u16 | display_start_col | viewport start | +| 1410 | u16 | display_end_col | viewport end | +| 1453 | u8 | cursors_x_enable | | +| 1454 | u8 | cursors_y_enable | | +| 1456 | i16 | cursor_x1 (pixels, +right of centre) | | +| 1458 | i16 | cursor_x2 | | +| 1460 | i16 | cursor_y1 (pixels, signed) | | +| 1462 | i16 | cursor_y2 | | + +--- + +## Measurement Block Layout (100 bytes, little-endian) + +``` +Bytes 0– 95 : 12 × i64 (8 bytes each) + [0] PkPk amplitude (µV) + [1] Amplitude (µV) + [2] Top (µV) + [3] Base (µV) + [4] Max (µV) + [5] Min (µV) + [6] Average (µV) + [7] RMS (µV) + [8] Positive width (ps) + [9] Negative width (ps) + [10] Period (ps) + [11] Frequency (µHz → divide by 1e6 to get Hz) + +Bytes 96– 97 : i16 Positive duty cycle (units: 0.1%) +Bytes 98– 99 : i16 Negative duty cycle (units: 0.1%) +``` + +--- + +## Real Capture — Data Analysis + +Captured 2025-05-31 from a DSO3D12 via `/dev/tty.usbserial-1110`. + +### Instrument state + +| Field | Value | +|--------------------|------------------------------------| +| Device | DSO3D12 | +| Run state | Run | +| Timebase | 500 µs/div (index 17) | +| Timebase raw | 500 000 ns/div | +| Time/pixel | 20 µs/px (500 µs ÷ 25 px/div) | +| Total time span | 6.00 ms (300 px × 20 µs/px) | +| Buffer depth | 30 000 samples | +| Pre-trigger | 15 000 samples | +| Sample rate (meas) | 5.00 MSa/s | +| Sample rate (HW) | 0 (field at offset 1252 is 0 here) | + +### Channel 1 + +| Field | Value | +|--------------------|----------------------| +| V/div | 1 V/div (index 7) | +| Coupling | AC | +| Probe | ×10 | +| Volt scale | 100 000 µV/div | +| Volt scale/pixel | 4 000 µV/px (4 mV/px)| +| Zero offset (px) | 20 px | +| Zero offset (µV) | 80 mV | +| Display range | ≈ −4.0 V … +4.0 V | + +**Waveform:** min=−2.22 V, max=+0.34 V, avg=−0.94 V + +**Measurements:** 1 kHz frequency, 1.00 ms period, 50% duty cycle, +2.60 V peak-to-peak. This is a ~1 kHz square wave viewed in AC coupling +mode on a ×10 probe, with a small DC offset showing the AC coupling effect +(average is not 0 V because the probe/coupling haven't fully settled). + +### Channel 2 + +| Field | Value | +|--------------------|------------------------| +| V/div | 200 mV/div (index 8) | +| Coupling | DC | +| Probe | ×1 | +| Volt scale/pixel | 8 000 µV/px (8 mV/px) | +| Zero offset (px) | 0 px | +| Display range | ≈ −0.8 V … +0.8 V | + +**Waveform:** min=−0.18 V, max=−0.17 V, avg=−0.176 V + +**Measurements:** Essentially a DC offset of −176 mV. The very small +min/max spread (10 mV) is consistent with noise floor on a DC signal. + +### Raw ADC bytes (CH1 min, first 16) + +``` +A1 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 +``` + +0xA3 = 163 decimal. Converted: `pixel = 255 - 163 = 92`. Voltage: +`(92 - 128 - 20) × 4000 µV/px × 10 (probe) = (-56) × 40 mV = -2.24 V`. ✓ + +--- + +## How This Data Drives Graph Drawing + +### X axis + +``` +t[i] = i × (timebase_ns_per_div / 25) / 1e9 seconds +``` + +For this capture: `t[i] = i × 20 µs`, total span 0 … 5.98 ms. + +### Y axis — voltage (per channel) + +``` +pixel_val = 255 - raw +voltage = (pixel_val - 128 - zero_volt_pixels) × (volt_scale_uv / 25) / 1e6 + × 10^probe_mode +``` + +The `zero_volt_pixels` value calibrates the position of 0 V on the ADC scale +and must be subtracted — it represents the hardware DC offset of the channel. + +### Waveform envelope (min/max shading) + +Each pixel delivers a `(min_v, max_v)` range: +* **Simple trace**: draw the midpoint `(min_v + max_v) / 2`. +* **Envelope fill**: fill the area between `min_v` and `max_v` using a + semi-transparent band — this is what the device's own screen does. +* **High-density mode**: draw both min and max as separate thin lines. + +### Y-axis range for the graph widget + +The full visible range from the device settings is: + +``` +half_range = volt_scale_uv × 4 / 1e6 × probe_multiplier (V) +y_min = zero_volt_uv/1e6 - half_range +y_max = zero_volt_uv/1e6 + half_range +``` + +For this capture's CH1: `half_range = 100 000 × 4 / 1e6 × 10 = 4 V`, +so the display range is −4 V … +4 V. + +### Auto-populating V/div and t/div in the GUI + +After parsing a screenshot packet the GUI can auto-populate its measurement +panel inputs directly from device settings: + +``` +v_per_div = volt_scale_uv as f64 / 1_000_000.0 × probe_multiplier +t_per_div_ms = timebase_ns_per_div as f64 / 1_000_000.0 (ns → ms) +``` + +This eliminates the need for the user to manually enter scale values. + +--- + +## Device Variant Field Differences + +| Field | DSO3D12 offset | DSO2512G V2/V1 offset | +|--------------------|----------------|-----------------------| +| buffer_depth | 1204 (i64) | 1208 (u64) | +| sample_rate_hw | 1252 (u32×100) | 1256 (u64×100) | +| meas CH1 block | 1540 | 1544 | +| meas CH2 block | 1640 | 1648 | + +--- + +## Observations & Known Issues + +1. **`sample_rate_hw` reads 0 on this unit.** The field at offset 1252 + (DSO3D12) contains zeros in this firmware version. The measurement sample + rate at offset 1392 (5.00 MSa/s) is correct and more reliable. + +2. **CH1 avg ≠ 0 in AC mode.** The average of −0.94 V is because the + waveform is captured near a DC transition — AC coupling capacitor has + not fully settled, or this is expected asymmetric clipping. + +3. **CH2 shows no measurements.** The measurement fields (freq, period, etc.) + are zero for CH2. The device only computes auto-measurements for the active + measurement channel. The raw waveform data (min/max ADC) is correct. + +4. **Cursor data absent.** Both `cursors_x_enable` and `cursors_y_enable` are + 0 in this capture. Cursor fields will be non-zero when device cursors are + enabled on the scope before saving. + +5. **Waveform pixel count is always 300.** The 300-sample constraint is the + display width — independent of buffer depth or timebase. Longer captures + are downsampled by the device firmware to 300 pixels. diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 3163dbd..746ffa7 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -27,13 +27,30 @@ and display captures when you trigger a dump from the device. ### Triggering a capture on the device +There are two independent capture methods: + +#### Method 1 — ASCII debug dump (full buffer) + 1. Feed a signal into the scope. 2. Press **Stop** to freeze the sample buffer. 3. Press **Menu**, then **Stop** so the Channel 1 Measurements menu is visible. 4. **Long-press Save** to initiate the debug data transmission. -The app detects end-of-transmission after 500 ms of silence and parses the -received data. +Transfers ~150–500 kB at 115200 baud. Delivers 60 000 raw ADC samples per +channel. Use `V/div` and `t/div` in the Measurement panel to set the scale. + +#### Method 2 — Screenshot binary packet (display pixels) + +1. Save a waveform on the device (short-press Save while viewing a signal). +2. Navigate to **Gallery** (Screenshots view) on the device. +3. Select the saved waveform — the device automatically transmits a **2048-byte + binary packet** containing the 300 display pixels plus all instrument settings. + +The app detects this packet by its size and decodes the embedded calibration +data to set `V/div` and `t/div` automatically. + +Use the `capture` CLI tool to capture and analyze screenshot packets from the +command line (see [CLI tools](#cli-tools) below). ### Auto-reconnect @@ -57,12 +74,16 @@ At the top of the **Measurement** panel you can set the real-world scale: | Field | Default | Meaning | |----------|---------|-------------------------------------------------| -| V/cell | 1.0 V | Volts per vertical division on the scope screen | -| t/cell | 1.0 ms | Time per horizontal division | +| V/div | 1.0 V | Volts per vertical division on the scope screen | +| t/div | 1.0 ms | Time per horizontal division | +| V/off | 0.0 V | Voltage at graph center (offset) | The DSO3D12 screen has **8 vertical** and **12 horizontal** divisions (AC mode). All voltage and time calculations, axis labels, and PNG scales use these values. +When a capture arrives via the **screenshot protocol**, V/div, t/div, and V/offset +are automatically filled from the packet's embedded settings metadata. + ## Measurement cursors In the right-hand **Measurement** panel: @@ -79,18 +100,24 @@ between cursors is drawn on the canvas in pink and updates in real time. ### Signal information When a capture is loaded the panel also shows: -* Voltage range for each channel (derived from V/cell setting). -* Total time range (derived from t/cell setting) + sample count. +* Voltage range for each channel (derived from V/div setting). +* Total time range (derived from t/div setting) + sample count. * Per-channel statistics (sample count, min, max, average ADC). ## Device cursors -If a capture contains device-set cursor data, the **Device X** / **Device Y** -toggles in the controls panel become enabled. These cursors are drawn as green -lines and **cannot be moved** — they reflect the device's own measurement state. +If a capture contains device-set cursor data (from the screenshot protocol), +the **Device X** / **Device Y** toggles in the controls panel become enabled. +These cursors are drawn as green lines and **cannot be moved** — they reflect +the device's own cursor state at the moment of capture. + +## Trigger & auto-measurements -Currently the parser does not extract device-cursor metadata, so these toggles -remain disabled. +When a capture comes from the screenshot protocol, the measurement panel shows: +* **Trigger info**: mode (Auto/Normal/Single), edge, source, level, delay, + sample rate. +* **Auto-measurements table**: two columns (CH1, CH2) with Freq, PkPk, Avg, + RMS, Amp, ±Duty, ±T, T, Max, Min, Top, Base — all in physical units. ## Thumbnails @@ -102,7 +129,7 @@ thumbnail to switch the active capture. | Button | Action | |-----------------|---------------------------------------------------------------| -| Save Capture | Writes the raw dump to a `.bin` file. | +| Save Capture | Writes all captures in `.zwcap` container format (CBOR). | | Save PNG | Exports a 1200×600 px PNG image of the graph with grid+traces.| | Export Data | Writes a CSV file with columns `index, ch1 [, ch2]`. | @@ -121,13 +148,46 @@ Settings are persisted to: ## Logs -Activity is logged to `/dso3d12-gui/dso3d12-gui.log` and stderr. -Rotation at ~1 MB. Increase verbosity: +Activity is logged to stderr (console) by default. File logging to +`/dso3d12-gui/dso3d12-gui.log` can be enabled by setting +`DSO_LOG_FILE=1`. Rotation at ~1 MB. Increase verbosity: ```bash RUST_LOG=debug dso3d12-gui +# Or with file logging: +DSO_LOG_FILE=1 RUST_LOG=debug dso3d12-gui ``` +--- + +## CLI tools + +### `capture` — screenshot packet capture + +```bash +# List available serial ports: +capture -l + +# Wait for a screenshot packet on the default port and print a verbose report: +capture + +# Specify port, save raw binary and timeout: +capture --port /dev/tty.usbserial-1110 --save capture.bin --timeout 60 + +# Parse as a different device variant: +capture --device dso2512g-v2 +``` + +**Steps:** +1. Connect the DSO3D12 via USB. +2. Run `capture` (it will wait for the device). +3. On the scope: **Gallery → select a saved waveform**. +4. The device transmits a 2048-byte packet; `capture` parses and prints the + full verbose report including calibrated waveform points and auto-measurements. + +If `--save path.bin` is specified the raw packet is written to disk for later +re-analysis or use with custom tooling. + ## Troubleshooting | Symptom | Fix | @@ -141,7 +201,7 @@ RUST_LOG=debug dso3d12-gui ## Voltage & time reference -* Voltage range = V/cell × 8 cells (centered at 0 → ±4 × V/cell). -* Time range = t/cell × 12 cells. -* Set V/cell and t/cell in the Measurement panel to match your scope settings. +* Voltage range = V/div × 8 divs (centered at 0 → ±4 × V/div). +* Time range = t/div × 12 divs. +* Set V/div and t/div in the Measurement panel to match your scope settings. * These defaults will be refined when device metadata parsing is extended. diff --git a/dso3d12-parser/Cargo.toml b/dso-parser/Cargo.toml similarity index 71% rename from dso3d12-parser/Cargo.toml rename to dso-parser/Cargo.toml index 49e5d59..288e564 100644 --- a/dso3d12-parser/Cargo.toml +++ b/dso-parser/Cargo.toml @@ -1,15 +1,16 @@ [package] -name = "dso3d12-parser" +name = "dso-parser" version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true -description = "Parser for ZeeWeii DSO3D12 oscilloscope raw debug dumps" +description = "Parser for ZeeWeii oscilloscope data formats (debug dumps and screenshot packets)" [dependencies] thiserror = { workspace = true } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } +ciborium = "0.2" [dev-dependencies] diff --git a/dso-parser/src/container.rs b/dso-parser/src/container.rs new file mode 100644 index 0000000..fb5fa52 --- /dev/null +++ b/dso-parser/src/container.rs @@ -0,0 +1,415 @@ +//! `.zwcap` container format — serializes captures (both debug dumps and +//! screenshot packets) into a single CBOR-encoded binary file. +//! +//! ## File layout +//! ```text +//! Bytes 0–7 : Magic header b"ZWCAP\x00\x01\x00" (version 1) +//! Bytes 8.. : CBOR-encoded Vec +//! ``` + +use chrono::{DateTime, Local}; +use serde::{Deserialize, Serialize}; + +/// Magic header for `.zwcap` files. +pub const ZWCAP_MAGIC: &[u8; 8] = b"ZWCAP\x00\x01\x00"; + +/// Top-level capture record stored in the container. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CaptureRecord { + /// Schema version (currently 1). + pub schema_version: u16, + /// How the capture was originally acquired. + pub source_format: SourceFormat, + /// Device model string (e.g. "DSO3D12"). + pub device_model: Option, + /// ISO 8601 timestamp with timezone when capture was received. + pub captured_at: Option, + /// Raw binary payload (the original bytes as received). + pub raw_payload: Vec, + /// Oscilloscope state at time of capture (from screenshot metadata). + pub scope_state: Option, + /// Decoded waveforms ready for display. + pub waveforms: Vec, +} + +/// How the capture was originally obtained. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SourceFormat { + /// ASCII debug dump (long-press Save, 60000 samples/channel). + HiddenAsciiDump, + /// Binary 2048-byte screenshot packet (Gallery → select waveform). + ScreenshotBinary, +} + +/// Oscilloscope state metadata (from screenshot packet settings section). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScopeState { + pub timebase_label: String, + pub timebase_ns_per_div: u64, + pub sample_rate_hz: f64, + pub buffer_depth: i64, + pub trigger_source: String, + pub trigger_mode: String, + pub trigger_edge: String, + pub trigger_level_v: f64, + pub trigger_delay_s: f64, + pub ch1: Option, + pub ch2: Option, + pub cursors_x_enable: bool, + pub cursors_y_enable: bool, + /// Cursor X1 position as fraction 0.0–1.0 of graph width. + pub cursor_x1_frac: f64, + /// Cursor X2 position as fraction 0.0–1.0 of graph width. + pub cursor_x2_frac: f64, + /// Cursor Y1 position as fraction 0.0–1.0 of graph height. + pub cursor_y1_frac: f64, + /// Cursor Y2 position as fraction 0.0–1.0 of graph height. + pub cursor_y2_frac: f64, +} + +/// Per-channel state. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelState { + pub vdiv_v: f64, + pub coupling: String, + pub probe: String, + pub v_offset_v: f64, + pub measurements: Option, +} + +/// Auto-measurement values in physical units. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelMeasurements { + pub freq_hz: f64, + pub period_s: f64, + pub pk_pk_v: f64, + pub amplitude_v: f64, + pub max_v: f64, + pub min_v: f64, + pub top_v: f64, + pub base_v: f64, + pub avg_v: f64, + pub rms_v: f64, + pub pos_width_s: f64, + pub neg_width_s: f64, + pub pos_duty_pct: f64, + pub neg_duty_pct: f64, +} + +/// A waveform stored in the capture. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Waveform { + /// Full-buffer raw ADC samples (from debug dump). + RawSamples { + channel: u8, + /// ADC values 0–255 (stored as i16 for future headroom). + counts: Vec, + /// Seconds per sample (if known from metadata). + sample_interval_s: Option, + /// Volts per ADC count (if calibrated). + volts_per_count: Option, + }, + /// Display-resolution envelope (from screenshot packet). + DisplayEnvelope { + channel: u8, + /// Minimum ADC value per display pixel. + min_counts: Vec, + /// Maximum ADC value per display pixel. + max_counts: Vec, + /// Seconds per pixel. + time_per_pixel_s: Option, + /// µV per pixel (volt_scale_uv / 25). + volts_per_pixel: Option, + /// Zero-volt reference offset in pixels. + zero_pixel: Option, + }, +} + +// ── Serialization ────────────────────────────────────────────────────────── + +/// Serialize a list of capture records to the `.zwcap` binary format. +pub fn serialize_zwcap(records: &[CaptureRecord]) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(ZWCAP_MAGIC); + ciborium::into_writer(records, &mut buf).expect("CBOR serialize should not fail"); + buf +} + +/// Deserialize a `.zwcap` file. Returns an error if the magic header is wrong +/// or CBOR decoding fails. +pub fn deserialize_zwcap(data: &[u8]) -> Result, String> { + if data.len() < 8 || &data[..8] != ZWCAP_MAGIC { + return Err("Not a .zwcap file (bad magic header)".to_string()); + } + ciborium::from_reader(&data[8..]).map_err(|e| format!("CBOR decode error: {e}")) +} + +/// Returns true if the buffer starts with the `.zwcap` magic header. +pub fn is_zwcap(data: &[u8]) -> bool { + data.len() >= 8 && &data[..8] == ZWCAP_MAGIC +} + +// ── Conversion helpers ───────────────────────────────────────────────────── + +use crate::screenshot::{self, ScreenshotPacket}; +use crate::Capture; + +/// Convert an ASCII debug dump `Capture` into a `CaptureRecord`. +pub fn capture_to_record( + cap: &Capture, + timestamp: DateTime, + raw_bytes: Option<&[u8]>, +) -> CaptureRecord { + let mut waveforms = vec![Waveform::RawSamples { + channel: 1, + counts: cap.ch1.iter().map(|&b| b as i16).collect(), + sample_interval_s: None, + volts_per_count: None, + }]; + if let Some(ch2) = &cap.ch2 { + waveforms.push(Waveform::RawSamples { + channel: 2, + counts: ch2.iter().map(|&b| b as i16).collect(), + sample_interval_s: None, + volts_per_count: None, + }); + } + CaptureRecord { + schema_version: 1, + source_format: SourceFormat::HiddenAsciiDump, + device_model: Some("DSO3D12".to_string()), + captured_at: Some(timestamp.format("%Y-%m-%dT%H:%M:%S%:z").to_string()), + raw_payload: raw_bytes.map(|b| b.to_vec()).unwrap_or_default(), + scope_state: None, + waveforms, + } +} + +/// Convert a parsed screenshot packet into a `CaptureRecord`. +pub fn screenshot_to_record( + pkt: &ScreenshotPacket, + timestamp: DateTime, + raw_bytes: &[u8], +) -> CaptureRecord { + let s = &pkt.settings; + let probe_mult = |p: u8| 10f64.powi(p as i32); + + let ch1_state = ChannelState { + vdiv_v: s.ch1.volt_scale_uv as f64 / 1_000_000.0 * probe_mult(s.ch1.probe_mode), + coupling: match s.ch1.coupling { 0 => "DC", _ => "AC" }.to_string(), + probe: match s.ch1.probe_mode { 0 => "×1", 1 => "×10", _ => "×100" }.to_string(), + v_offset_v: s.ch1.zero_volt_uv as f64 / 1_000_000.0 * probe_mult(s.ch1.probe_mode), + measurements: pkt.ch1_meas.as_ref().map(|m| measurements_to_state(m, s.ch1.probe_mode)), + }; + + let ch2_state = if s.ch2.enabled { + Some(ChannelState { + vdiv_v: s.ch2.volt_scale_uv as f64 / 1_000_000.0 * probe_mult(s.ch2.probe_mode), + coupling: match s.ch2.coupling { 0 => "DC", _ => "AC" }.to_string(), + probe: match s.ch2.probe_mode { 0 => "×1", 1 => "×10", _ => "×100" }.to_string(), + v_offset_v: s.ch2.zero_volt_uv as f64 / 1_000_000.0 * probe_mult(s.ch2.probe_mode), + measurements: pkt.ch2_meas.as_ref().map(|m| measurements_to_state(m, s.ch2.probe_mode)), + }) + } else { + None + }; + + // Cursor fractions: device uses pixel offsets from center (150 for X, 128 for Y) + let cx1 = (s.cursor_x1 as f64 + 150.0) / 300.0; + let cx2 = (s.cursor_x2 as f64 + 150.0) / 300.0; + let cy1 = (s.cursor_y1 as f64 + 128.0) / 256.0; + let cy2 = (s.cursor_y2 as f64 + 128.0) / 256.0; + + let scope_state = ScopeState { + timebase_label: s.timebase_label().to_string(), + timebase_ns_per_div: s.timebase_ns_per_div, + sample_rate_hz: s.sample_rate_meas_hz, + buffer_depth: s.buffer_depth, + trigger_source: match s.trigger_source { 0 => "CH1", _ => "CH2" }.to_string(), + trigger_mode: match s.trigger_mode { 0 => "Auto", _ => "Normal" }.to_string(), + trigger_edge: match s.trigger_edge { 0 => "Rise", _ => "Fall" }.to_string(), + trigger_level_v: if s.trigger_source == 0 { + s.ch1.trigger_level_uv as f64 / 1_000_000.0 * probe_mult(s.ch1.probe_mode) + } else { + s.ch2.trigger_level_uv as f64 / 1_000_000.0 * probe_mult(s.ch2.probe_mode) + }, + trigger_delay_s: s.horiz_trigger_delay_ps as f64 / 1e12, + ch1: Some(ch1_state), + ch2: ch2_state, + cursors_x_enable: s.cursors_x_enable, + cursors_y_enable: s.cursors_y_enable, + cursor_x1_frac: cx1.clamp(0.0, 1.0), + cursor_x2_frac: cx2.clamp(0.0, 1.0), + cursor_y1_frac: cy1.clamp(0.0, 1.0), + cursor_y2_frac: cy2.clamp(0.0, 1.0), + }; + + let mut waveforms = vec![Waveform::DisplayEnvelope { + channel: 1, + min_counts: pkt.ch1_min.to_vec(), + max_counts: pkt.ch1_max.to_vec(), + time_per_pixel_s: Some(s.time_per_pixel_s()), + volts_per_pixel: Some(s.ch1.volt_scale_uv as f64 / 25.0), + zero_pixel: Some(s.ch1.zero_volt_pixels as i16), + }]; + if let (Some(mn), Some(mx)) = (&pkt.ch2_min, &pkt.ch2_max) { + waveforms.push(Waveform::DisplayEnvelope { + channel: 2, + min_counts: mn.to_vec(), + max_counts: mx.to_vec(), + time_per_pixel_s: Some(s.time_per_pixel_s()), + volts_per_pixel: Some(s.ch2.volt_scale_uv as f64 / 25.0), + zero_pixel: Some(s.ch2.zero_volt_pixels as i16), + }); + } + + CaptureRecord { + schema_version: 1, + source_format: SourceFormat::ScreenshotBinary, + device_model: Some(pkt.device.to_string()), + captured_at: Some(timestamp.format("%Y-%m-%dT%H:%M:%S%:z").to_string()), + raw_payload: raw_bytes.to_vec(), + scope_state: Some(scope_state), + waveforms, + } +} + +/// Maximum plausible voltage (100 kV) — device leaves uninitialized fields +/// with garbage that can reach hundreds of megavolts. +const MEAS_MAX_V: f64 = 100_000.0; +/// Maximum plausible pulse/period width in seconds. +/// The DSO3D12's widest timebase is 10 s/div × 12 divs = 120 s. +const MEAS_MAX_T: f64 = 200.0; + +/// Return `v` unchanged, or `NaN` if the value looks like uninitialised data. +#[inline] +fn plausible_v(v: f64) -> f64 { + if v.is_finite() && v.abs() <= MEAS_MAX_V { v } else { f64::NAN } +} + +/// Return `v` unchanged, or `NaN` if outside the device's possible time range. +#[inline] +fn plausible_t(t: f64) -> f64 { + if t.is_finite() && (0.0..=MEAS_MAX_T).contains(&t) { t } else { f64::NAN } +} + +fn measurements_to_state(m: &screenshot::Measurements, probe: u8) -> ChannelMeasurements { + let mult = 10f64.powi(probe as i32); + let uv = |raw: i64| plausible_v(raw as f64 / 1_000_000.0 * mult); + ChannelMeasurements { + freq_hz: { let v = m.freq_hz(); if v.is_finite() && v >= 0.0 { v } else { f64::NAN } }, + period_s: plausible_t(m.period_s()), + pk_pk_v: uv(m.pk_pk_uv), + amplitude_v: uv(m.amplitude_uv), + max_v: uv(m.max_uv), + min_v: uv(m.min_uv), + top_v: uv(m.top_uv), + base_v: uv(m.base_uv), + avg_v: uv(m.avg_uv), + rms_v: uv(m.rms_uv), + pos_width_s: plausible_t(m.pos_width_ps as f64 / 1e12), + neg_width_s: plausible_t(m.neg_width_ps as f64 / 1e12), + pos_duty_pct: { let v = m.pos_duty_pct(); if v.is_finite() && (0.0..=100.0).contains(&v) { v } else { f64::NAN } }, + neg_duty_pct: { let v = m.neg_duty_tenth_pct as f64 / 10.0; if v.is_finite() && (0.0..=100.0).contains(&v) { v } else { f64::NAN } }, + } +} + +/// Convert a `CaptureRecord` back to the legacy `Capture` struct for display. +/// This allows the GUI to use a unified rendering pipeline. +pub fn record_to_capture(rec: &CaptureRecord) -> Capture { + match rec.source_format { + SourceFormat::HiddenAsciiDump => { + let mut ch1 = Vec::new(); + let mut ch2: Option> = None; + for w in &rec.waveforms { + match w { + Waveform::RawSamples { channel: 1, counts, .. } => { + ch1 = counts.iter().map(|&c| c.clamp(0, 255) as u8).collect(); + } + Waveform::RawSamples { channel: 2, counts, .. } => { + ch2 = Some(counts.iter().map(|&c| c.clamp(0, 255) as u8).collect()); + } + _ => {} + } + } + Capture { ch1, ch2 } + } + SourceFormat::ScreenshotBinary => { + // For screenshot packets, construct a Capture from the envelope average. + let mut ch1 = Vec::new(); + let mut ch2: Option> = None; + for w in &rec.waveforms { + match w { + Waveform::DisplayEnvelope { channel: 1, min_counts, max_counts, .. } => { + ch1 = min_counts.iter().zip(max_counts.iter()) + .map(|(&mn, &mx)| ((mn as u16 + mx as u16) / 2) as u8) + .collect(); + } + Waveform::DisplayEnvelope { channel: 2, min_counts, max_counts, .. } => { + ch2 = Some(min_counts.iter().zip(max_counts.iter()) + .map(|(&mn, &mx)| ((mn as u16 + mx as u16) / 2) as u8) + .collect()); + } + _ => {} + } + } + Capture { ch1, ch2 } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_zwcap() { + let rec = CaptureRecord { + schema_version: 1, + source_format: SourceFormat::HiddenAsciiDump, + device_model: Some("DSO3D12".to_string()), + captured_at: Some("2025-05-31T12:00:00+03:00".to_string()), + raw_payload: vec![1, 2, 3], + scope_state: None, + waveforms: vec![Waveform::RawSamples { + channel: 1, + counts: vec![100, 110, 120], + sample_interval_s: None, + volts_per_count: None, + }], + }; + let data = serialize_zwcap(&[rec.clone()]); + assert!(is_zwcap(&data)); + let decoded = deserialize_zwcap(&data).unwrap(); + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0].device_model, rec.device_model); + assert_eq!(decoded[0].raw_payload, rec.raw_payload); + } + + #[test] + fn record_to_capture_roundtrip() { + let rec = CaptureRecord { + schema_version: 1, + source_format: SourceFormat::HiddenAsciiDump, + device_model: None, + captured_at: None, + raw_payload: vec![], + scope_state: None, + waveforms: vec![ + Waveform::RawSamples { channel: 1, counts: vec![10, 20, 30], sample_interval_s: None, volts_per_count: None }, + Waveform::RawSamples { channel: 2, counts: vec![40, 50, 60], sample_interval_s: None, volts_per_count: None }, + ], + }; + let cap = record_to_capture(&rec); + assert_eq!(cap.ch1, vec![10, 20, 30]); + assert_eq!(cap.ch2, Some(vec![40, 50, 60])); + } + + #[test] + fn is_zwcap_detection() { + assert!(!is_zwcap(b"not a zwcap file")); + assert!(!is_zwcap(b"")); + let data = serialize_zwcap(&[]); + assert!(is_zwcap(&data)); + } +} diff --git a/dso3d12-parser/src/lib.rs b/dso-parser/src/lib.rs similarity index 90% rename from dso3d12-parser/src/lib.rs rename to dso-parser/src/lib.rs index f285105..a2928c4 100644 --- a/dso3d12-parser/src/lib.rs +++ b/dso-parser/src/lib.rs @@ -16,6 +16,22 @@ use thiserror::Error; +pub mod screenshot; +pub use screenshot::{ + parse_screenshot, parse_screenshot_as, is_screenshot_packet, + DeviceVariant, ScreenshotPacket, OscSettings, ChannelSettings, + Measurements, PixelPoint, + format_uv, format_ps, format_hz, format_sample_rate, +}; + +pub mod container; +pub use container::{ + CaptureRecord, SourceFormat, ScopeState, ChannelState, ChannelMeasurements, + Waveform, serialize_zwcap, deserialize_zwcap, is_zwcap, + capture_to_record, screenshot_to_record, record_to_capture, + ZWCAP_MAGIC, +}; + /// Default total buffer depth in samples that the DSO3D12 transmits per /// channel for a complete debug dump. pub const DEFAULT_BUFFER_DEPTH: usize = 60_000; @@ -54,6 +70,9 @@ pub enum ParseError { #[error("malformed sample at byte offset {offset}: {reason}")] MalformedSample { offset: usize, reason: &'static str }, + + #[error("buffer too short: got {got} bytes, expected at least {expected}")] + TooShort { got: usize, expected: usize }, } /// Parse every capture contained in `data`. @@ -80,7 +99,12 @@ pub fn parse_captures(data: &[u8]) -> Result, ParseError> { let ch2 = if data[ch1_end..].starts_with(CH2_MARKER) { let ch2_body = skip_marker_eol(data, ch1_end + CH2_MARKER.len()); let ch2_end = find_next_boundary(data, ch2_body); - let samples = decode_ascii_samples(&data[ch2_body..ch2_end]); + // The device sends CH2 ADC values with inverted polarity in the ASCII + // dump (a hardware quirk). Flip here so Capture.ch2 uses the same + // visual-screen-coordinate convention as CH1 and screenshot data: + // low value = near top of screen = positive voltage. + let samples: Vec = decode_ascii_samples(&data[ch2_body..ch2_end]) + .into_iter().map(|v| 255 - v).collect(); next_cursor = ch2_end; Some(samples) } else { @@ -340,7 +364,9 @@ mod tests { let dump = make_dump(&[10, 20, 30], Some(&[40, 50, 60])); let cap = parse_first(&dump).unwrap(); assert_eq!(cap.ch1, vec![10, 20, 30]); - assert_eq!(cap.ch2.as_deref(), Some(&[40u8, 50, 60][..])); + // ASCII dump CH2 is inverted at parse time so it matches the + // screen-coordinate convention used by screenshot data (low value = top). + assert_eq!(cap.ch2.as_deref(), Some(&[215u8, 205, 195][..])); } #[test] @@ -351,7 +377,8 @@ mod tests { assert_eq!(caps.len(), 2); assert_eq!(caps[0].ch1, vec![1, 2, 3]); assert_eq!(caps[1].ch1, vec![4, 5, 6]); - assert_eq!(caps[1].ch2.as_deref(), Some(&[7u8, 8][..])); + // CH2 is inverted at parse time: 255-7=248, 255-8=247 + assert_eq!(caps[1].ch2.as_deref(), Some(&[248u8, 247][..])); } #[test] diff --git a/dso-parser/src/screenshot.rs b/dso-parser/src/screenshot.rs new file mode 100644 index 0000000..e6e1d58 --- /dev/null +++ b/dso-parser/src/screenshot.rs @@ -0,0 +1,950 @@ +//! Parser for the ZeeWeii "screenshot" binary protocol. +//! +//! Based on https://github.com/taligentx/ZeeTweak/blob/main/zeetweak.py +//! +//! When the user opens a saved waveform in the device's Screenshot/Gallery view +//! (or presses Save while live), the device transmits a **2048-byte binary packet** +//! at 115200 baud over the USB-serial port. +//! +//! ## Packet layout +//! ```text +//! Bytes 0 – 299 : CH1 min ADC values (300 × u8, inverted: real = 255 − raw) +//! Bytes 300 – 599 : CH1 max ADC values (300 × u8, inverted) +//! Bytes 600 – 899 : CH2 min ADC values (300 × u8, inverted) +//! Bytes 900 – 1199 : CH2 max ADC values (300 × u8, inverted) +//! Bytes 1200 – 1539 : Settings / state (DATA_DSO3D12 map, 340 bytes) +//! Bytes 1540 – 1639 : CH1 auto-measurements (100 bytes = 12×i64 + 2×i16) +//! Bytes 1640 – 1739 : CH2 auto-measurements (100 bytes) +//! Bytes 1740 – 2047 : Reserved / padding +//! ``` +//! 300 x-pixels = 12 horizontal divisions × 25 pixels/div. +//! +//! DSO2512G firmware uses slightly different field offsets (see [`DeviceVariant`]). + +use crate::{Capture, ParseError}; + +/// Which firmware / hardware variant we're parsing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DeviceVariant { + /// ZeeWeii DSO3D12 (default, firmware v3.x). + #[default] + Dso3d12, + /// ZeeWeii DSO2512G firmware v2.x. + Dso2512gV2, + /// ZeeWeii DSO2512G firmware v1.x. + Dso2512gV1, +} + +impl std::fmt::Display for DeviceVariant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + DeviceVariant::Dso3d12 => "DSO3D12", + DeviceVariant::Dso2512gV2 => "DSO2512G v2", + DeviceVariant::Dso2512gV1 => "DSO2512G v1", + }) + } +} + + +/// Expected total screenshot packet size. +pub const SCREENSHOT_SIZE: usize = 2048; + +/// Number of waveform x-pixels (12 divisions × 25 px/div). +pub const WAVEFORM_PIXELS: usize = 300; + +// ── Low-level read helpers ──────────────────────────────────────────────── + +#[inline] +fn read_u8(buf: &[u8], off: usize) -> u8 { + buf.get(off).copied().unwrap_or(0) +} + +#[inline] +fn read_u16(buf: &[u8], off: usize) -> u16 { + if off + 2 > buf.len() { return 0; } + u16::from_le_bytes([buf[off], buf[off + 1]]) +} + +#[inline] +fn read_i16(buf: &[u8], off: usize) -> i16 { + if off + 2 > buf.len() { return 0; } + i16::from_le_bytes([buf[off], buf[off + 1]]) +} + +#[inline] +fn read_u32(buf: &[u8], off: usize) -> u32 { + if off + 4 > buf.len() { return 0; } + u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]) +} + +#[inline] +fn read_i32(buf: &[u8], off: usize) -> i32 { + if off + 4 > buf.len() { return 0; } + i32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]) +} + +#[inline] +fn read_u64(buf: &[u8], off: usize) -> u64 { + if off + 8 > buf.len() { return 0; } + u64::from_le_bytes(buf[off..off + 8].try_into().unwrap()) +} + +#[inline] +fn read_i64(buf: &[u8], off: usize) -> i64 { + if off + 8 > buf.len() { return 0; } + i64::from_le_bytes(buf[off..off + 8].try_into().unwrap()) +} + +// ── Lookup tables ───────────────────────────────────────────────────────── + +/// Timebase index → label (2=5ns … 30=10s). Index 0/1 reserved. +pub const TIMEBASE_LABELS: &[(u8, &str)] = &[ + (0, "1ns"), (1, "2ns"), (2, "5ns"), (3, "10ns"), (4, "20ns"), (5, "50ns"), + (6, "100ns"), (7, "200ns"), (8, "500ns"), (9, "1µs"), (10, "2µs"), (11, "5µs"), + (12, "10µs"), (13, "20µs"), (14, "50µs"), (15, "100µs"), (16, "200µs"), (17, "500µs"), + (18, "1ms"), (19, "2ms"), (20, "5ms"), (21, "10ms"), (22, "20ms"), (23, "50ms"), + (24, "100ms"), (25, "200ms"), (26, "500ms"), (27, "1s"), (28, "2s"), (29, "5s"), + (30, "10s"), +]; + +/// V/Div index → base scale in µV (x1 probe). Keys: 1–13. +pub const VDIV_BASE_UV: &[(u8, u32)] = &[ + (1, 1_000), (2, 2_000), (3, 5_000), (4, 10_000), (5, 20_000), (6, 50_000), + (7, 100_000), (8, 200_000), (9, 500_000), (10, 1_000_000), (11, 2_000_000), + (12, 5_000_000), (13, 10_000_000), +]; + +fn timebase_label(idx: u8) -> &'static str { + TIMEBASE_LABELS.iter().find(|(i, _)| *i == idx).map(|(_, s)| *s).unwrap_or("?") +} + +fn vdiv_label_uv(idx: u8) -> u32 { + VDIV_BASE_UV.iter().find(|(i, _)| *i == idx).map(|(_, v)| *v).unwrap_or(0) +} + +fn probe_label(mode: u8) -> &'static str { + match mode { 0 => "×1", 1 => "×10", 2 => "×100", _ => "?" } +} + +fn run_state_label(s: u8) -> &'static str { + match s { 0 => "Stop", 1 => "Run", 2 => "Wait", _ => "?" } +} + +fn coupling_label(c: u8) -> &'static str { + match c { 0 => "DC", 1 => "AC", _ => "?" } +} + +fn trigger_source_label(s: u8) -> &'static str { + match s { 0 => "CH1", 1 => "CH2", _ => "?" } +} + +fn trigger_mode_label(m: u8) -> &'static str { + match m { 0 => "Auto", 1 => "Normal", _ => "?" } +} + +fn trigger_edge_label(e: u8) -> &'static str { + match e { 0 => "Rise", 1 => "Fall", _ => "?" } +} + +// ── Formatting helpers (mirror ZeeTweak Python) ─────────────────────────── + +/// Format microvolts → human-readable voltage string. +pub fn format_uv(uv: i64, probe_mode: u8) -> String { + let volts = uv as f64 / 1_000_000.0 * (10f64.powi(probe_mode as i32)); + let abs_v = volts.abs(); + if abs_v == 0.0 { return "0.00V".to_string(); } + if abs_v < 0.99995e-3 { return format!("{:.1}µV", volts * 1e6); } + if abs_v < 0.99995 { return format!("{:.1}mV", volts * 1e3); } + if abs_v < 10.0 { return format!("{:.2}V", volts); } + if abs_v < 100.0 { return format!("{:.1}V", volts); } + format!("{:.0}V", volts) +} + +/// Format picoseconds → human-readable time string. +pub fn format_ps(ps: i64) -> String { + let secs = ps as f64 / 1e12; + let val = secs.abs(); + if val == 0.0 { return "0.00s".to_string(); } + if val < 999.5e-9 { let ns = secs * 1e9; return if ns.abs() >= 100.0 { format!("{:.0}ns", ns) } else if ns.abs() >= 10.0 { format!("{:.1}ns", ns) } else { format!("{:.2}ns", ns) }; } + if val < 999.5e-6 { let us = secs * 1e6; return if us.abs() >= 100.0 { format!("{:.0}µs", us) } else if us.abs() >= 10.0 { format!("{:.1}µs", us) } else { format!("{:.2}µs", us) }; } + if val < 0.9995 { let ms = secs * 1e3; return if ms.abs() >= 100.0 { format!("{:.0}ms", ms) } else if ms.abs() >= 10.0 { format!("{:.1}ms", ms) } else { format!("{:.2}ms", ms) }; } + if val >= 100.0 { return format!("{:.0}s", secs); } + if val >= 10.0 { return format!("{:.1}s", secs); } + format!("{:.2}s", secs) +} + +/// Format Hz → human-readable frequency string. +pub fn format_hz(hz: f64) -> String { + if hz < 0.0 { return format_hz(0.0); } + let (val, unit) = if hz >= 999_500.0 { + (hz / 1_000_000.0, "MHz") + } else if hz >= 999.5 { + (hz / 1_000.0, "kHz") + } else { + (hz, "Hz") + }; + if val >= 100.0 { format!("{:.0}{}", val, unit) } + else if val >= 10.0 { format!("{:.1}{}", val, unit) } + else { format!("{:.2}{}", val, unit) } +} + +/// Format Hz → sample-rate string (Sa/s, kSa/s, MSa/s). +pub fn format_sample_rate(hz: f64) -> String { + if hz < 0.0 { return format_sample_rate(0.0); } + let (val, unit) = if hz >= 999_500.0 { + (hz / 1_000_000.0, "MSa/s") + } else if hz >= 999.5 { + (hz / 1_000.0, "kSa/s") + } else { + (hz, "Sa/s") + }; + if val >= 100.0 { format!("{:.0}{}", val, unit) } + else if val >= 10.0 { format!("{:.1}{}", val, unit) } + else { format!("{:.2}{}", val, unit) } +} + +// ── Settings structures ──────────────────────────────────────────────────── + +/// Parsed per-channel settings. +#[derive(Debug, Clone)] +pub struct ChannelSettings { + pub enabled: bool, + pub vdiv_index: u8, + /// Voltage scale in µV per pixel (scale_uv / 25). + pub volt_scale_uv: u32, + /// Zero-volt reference in screen pixels (signed, relative to ADC midpoint 128). + pub zero_volt_pixels: i32, + /// Zero-volt reference in µV. + pub zero_volt_uv: i64, + /// Trigger level in µV. + pub trigger_level_uv: i64, + /// Trigger level in screen pixels. + pub trigger_level_pixels: i32, + /// 0=DC, 1=AC. + pub coupling: u8, + /// 0=×1, 1=×10, 2=×100. + pub probe_mode: u8, + /// Bitmask of enabled auto-measurements. + pub enabled_measurements: u32, +} + +impl ChannelSettings { + /// Converts a raw (inverted) ADC pixel value to voltage in volts. + pub fn pixel_to_volts(&self, raw: u8) -> f64 { + let pixel_val = (255 - raw) as f64; + let vpp = self.volt_scale_uv as f64 / 25.0 / 1_000_000.0; + let multiplier = 10f64.powi(self.probe_mode as i32); + (pixel_val - 128.0 - self.zero_volt_pixels as f64) * vpp * multiplier + } + + pub fn vdiv_str(&self) -> String { + format_uv(vdiv_label_uv(self.vdiv_index) as i64, self.probe_mode) + } +} + +/// Parsed oscilloscope settings (instrument state). +#[derive(Debug, Clone)] +pub struct OscSettings { + pub run_state: u8, + pub system_state: u8, + pub ch1: ChannelSettings, + pub ch2: ChannelSettings, + /// Timebase index (2=5ns … 30=10s). + pub timebase_index: u8, + /// Timebase value in ns/div. + pub timebase_ns_per_div: u64, + /// Horizontal trigger delay in picoseconds. + pub horiz_trigger_delay_ps: i64, + /// Hardware sample rate in Hz. + pub sample_rate_hw_hz: f64, + /// Measurement / display sample rate in Hz. + pub sample_rate_meas_hz: f64, + /// Pre-trigger sample count. + pub pre_trigger_samples: u32, + /// Post-trigger sample count. + pub post_trigger_samples: u32, + /// Total buffer depth in samples. + pub buffer_depth: i64, + /// Trigger source: 0=CH1, 1=CH2. + pub trigger_source: u8, + /// Trigger mode: 0=Auto, 1=Normal. + pub trigger_mode: u8, + /// Trigger edge: 0=Rise, 1=Fall. + pub trigger_edge: u8, + /// Trigger level in ADC counts. + pub trigger_level_adc: u16, + /// Cursor X enable. + pub cursors_x_enable: bool, + /// Cursor Y enable. + pub cursors_y_enable: bool, + /// Cursor positions in pixels (signed, relative to centre pixel 150). + pub cursor_x1: i16, + pub cursor_x2: i16, + /// Cursor Y positions in pixels (signed, relative to ADC zero). + pub cursor_y1: i16, + pub cursor_y2: i16, + /// Currently active channel. + pub active_channel: u8, + /// Display start/end column (viewport into the buffer). + pub display_start_col: u16, + pub display_end_col: u16, +} + +impl OscSettings { + /// Time per pixel in seconds (timebase_ns / 25 / 1e9). + pub fn time_per_pixel_s(&self) -> f64 { + self.timebase_ns_per_div as f64 / 25.0 / 1e9 + } + + pub fn timebase_label(&self) -> &'static str { + timebase_label(self.timebase_index) + } +} + +// ── Measurement structures ───────────────────────────────────────────────── + +/// Auto-measurement values decoded from a 100-byte measurement block. +/// Layout: 12 × i64 + 2 × i16 (little-endian). +#[derive(Debug, Clone)] +pub struct Measurements { + /// Peak-to-peak voltage (µV). + pub pk_pk_uv: i64, + /// Amplitude (µV). + pub amplitude_uv: i64, + /// Top voltage (µV). + pub top_uv: i64, + /// Base voltage (µV). + pub base_uv: i64, + /// Max voltage (µV). + pub max_uv: i64, + /// Min voltage (µV). + pub min_uv: i64, + /// Average voltage (µV). + pub avg_uv: i64, + /// RMS voltage (µV). + pub rms_uv: i64, + /// Positive pulse width (ps). + pub pos_width_ps: i64, + /// Negative pulse width (ps). + pub neg_width_ps: i64, + /// Period (ps). + pub period_ps: i64, + /// Frequency (µHz — divide by 1e6 to get Hz). + pub freq_uhz: i64, + /// Positive duty cycle (0.1%). + pub pos_duty_tenth_pct: i16, + /// Negative duty cycle (0.1%). + pub neg_duty_tenth_pct: i16, +} + +impl Measurements { + fn parse(buf: &[u8], offset: usize, probe_mode: u8) -> Option { + let chunk = buf.get(offset..offset + 100)?; + if chunk.len() < 100 { return None; } + let v: Vec = (0..12).map(|i| read_i64(chunk, i * 8)).collect(); + let d0 = read_i16(chunk, 96); + let d1 = read_i16(chunk, 98); + let _ = probe_mode; // available for probe-adjusted display + Some(Measurements { + pk_pk_uv: v[0], + amplitude_uv: v[1], + top_uv: v[2], + base_uv: v[3], + max_uv: v[4], + min_uv: v[5], + avg_uv: v[6], + rms_uv: v[7], + pos_width_ps: v[8], + neg_width_ps: v[9], + period_ps: v[10], + freq_uhz: v[11], + pos_duty_tenth_pct: d0, + neg_duty_tenth_pct: d1, + }) + } + + /// Frequency in Hz. + pub fn freq_hz(&self) -> f64 { + self.freq_uhz as f64 / 1_000_000.0 + } + + /// Period in seconds. + pub fn period_s(&self) -> f64 { + self.period_ps as f64 / 1e12 + } + + /// Positive duty cycle (0–100%). + pub fn pos_duty_pct(&self) -> f64 { + self.pos_duty_tenth_pct as f64 / 10.0 + } +} + +// ── Main packet structure ───────────────────────────────────────────────── + +/// A fully-parsed ZeeWeii screenshot packet. +#[derive(Debug, Clone)] +pub struct ScreenshotPacket { + pub device: DeviceVariant, + /// CH1 minimum ADC values per pixel (raw, inverted: real = 255 - raw). + pub ch1_min: [u8; WAVEFORM_PIXELS], + /// CH1 maximum ADC values per pixel. + pub ch1_max: [u8; WAVEFORM_PIXELS], + /// CH2 minimum ADC values (present only when CH2 was enabled on capture). + pub ch2_min: Option<[u8; WAVEFORM_PIXELS]>, + /// CH2 maximum ADC values. + pub ch2_max: Option<[u8; WAVEFORM_PIXELS]>, + /// Oscilloscope settings extracted from the packet. + pub settings: OscSettings, + /// CH1 auto-measurements. + pub ch1_meas: Option, + /// CH2 auto-measurements (only when CH2 enabled). + pub ch2_meas: Option, + /// Length of the original buffer (should be 2048). + pub raw_len: usize, +} + +impl ScreenshotPacket { + /// Convert the min ADC values to a `Capture` compatible with the rest of + /// the GUI pipeline. We use the per-pixel average of min/max for best fidelity. + /// Values are re-mapped from the ZeeTweak pixel coordinate system + /// (128 = zero-volt, inverted) back into the 0–255 range the GUI expects. + pub fn to_capture(&self) -> Capture { + let ch1: Vec = (0..WAVEFORM_PIXELS) + .map(|i| { + let avg = (self.ch1_min[i] as u16 + self.ch1_max[i] as u16) / 2; + avg as u8 + }) + .collect(); + + let ch2: Option> = self.ch2_min.as_ref().map(|mn| { + let mx = self.ch2_max.as_ref().unwrap(); + (0..WAVEFORM_PIXELS) + .map(|i| { + let avg = (mn[i] as u16 + mx[i] as u16) / 2; + avg as u8 + }) + .collect() + }); + + Capture { ch1, ch2 } + } + + /// Convert to physical waveform: returns (time_s, ch1_volts, ch2_volts_opt) + /// for every x pixel, using the calibrated scale from settings. + pub fn to_physical_waveform(&self) -> Vec { + let dt = self.settings.time_per_pixel_s(); + (0..WAVEFORM_PIXELS).map(|i| { + let t = i as f64 * dt; + let ch1_min_v = self.settings.ch1.pixel_to_volts(self.ch1_min[i]); + let ch1_max_v = self.settings.ch1.pixel_to_volts(self.ch1_max[i]); + let ch2 = if self.settings.ch2.enabled { + let mn = self.ch2_min.as_ref().map(|a| a[i]).unwrap_or(128); + let mx = self.ch2_max.as_ref().map(|a| a[i]).unwrap_or(128); + Some(( + self.settings.ch2.pixel_to_volts(mn), + self.settings.ch2.pixel_to_volts(mx), + )) + } else { + None + }; + PixelPoint { time_s: t, ch1_min_v, ch1_max_v, ch2 } + }).collect() + } + + /// Build a verbose, human-readable report of the entire packet. + pub fn report_verbose(&self) -> String { + let s = &self.settings; + let mut out = String::new(); + + let bar = "═".repeat(64); + out.push_str(&format!("╔{bar}╗\n")); + out.push_str(&format!("║ ZeeWeii Screenshot Packet — {} \n", self.device)); + out.push_str(&format!("║ Raw buffer: {} bytes\n", self.raw_len)); + out.push_str(&format!("╚{bar}╝\n\n")); + + // Instrument state + out.push_str("── Instrument State ──────────────────────────────────────\n"); + out.push_str(&format!(" Run state : {}\n", run_state_label(s.run_state))); + out.push_str(&format!(" System state : {}\n", s.system_state)); + out.push_str(&format!(" Timebase : {}/div (index {})\n", + timebase_label(s.timebase_index), s.timebase_index)); + out.push_str(&format!(" Timebase raw : {} ns/div\n", s.timebase_ns_per_div)); + out.push_str(&format!(" H trigger delay: {}\n", + format_ps(s.horiz_trigger_delay_ps))); + out.push_str(&format!(" Sample rate HW : {}\n", + format_sample_rate(s.sample_rate_hw_hz))); + out.push_str(&format!(" Sample rate M : {}\n", + format_sample_rate(s.sample_rate_meas_hz))); + out.push_str(&format!(" Buffer depth : {} samples\n", s.buffer_depth)); + out.push_str(&format!(" Pre-trigger : {} samples\n", s.pre_trigger_samples)); + out.push_str(&format!(" Post-trigger : {} samples\n", s.post_trigger_samples)); + out.push_str(&format!(" Display cols : {} – {}\n", + s.display_start_col, s.display_end_col)); + out.push_str(&format!(" Time/pixel : {:.3e} s ({} total for 300 pixels)\n", + s.time_per_pixel_s(), + format_ps((s.time_per_pixel_s() * 300.0 * 1e12) as i64))); + + // Trigger + out.push_str("\n── Trigger ────────────────────────────────────────────────\n"); + out.push_str(&format!(" Source : {}\n", + trigger_source_label(s.trigger_source))); + out.push_str(&format!(" Mode : {}\n", + trigger_mode_label(s.trigger_mode))); + out.push_str(&format!(" Edge : {}\n", + trigger_edge_label(s.trigger_edge))); + out.push_str(&format!(" Level ADC : {} (raw count)\n", s.trigger_level_adc)); + let trig_ch = if s.trigger_source == 0 { &s.ch1 } else { &s.ch2 }; + out.push_str(&format!(" Level voltage : {}\n", + format_uv(trig_ch.trigger_level_uv, trig_ch.probe_mode))); + + // CH1 + out.push_str("\n── Channel 1 ──────────────────────────────────────────────\n"); + out.push_str(&format!(" Enabled : {}\n", s.ch1.enabled)); + out.push_str(&format!(" V/Div : {}/div (index {})\n", + s.ch1.vdiv_str(), s.ch1.vdiv_index)); + out.push_str(&format!(" Volt scale : {} µV/px ({}/pixel)\n", + s.ch1.volt_scale_uv as f64 / 25.0, + format_uv((s.ch1.volt_scale_uv as f64 / 25.0) as i64, 0))); + out.push_str(&format!(" Zero V (pixels): {} px\n", s.ch1.zero_volt_pixels)); + out.push_str(&format!(" Zero V (µV) : {}\n", + format_uv(s.ch1.zero_volt_uv, 0))); + out.push_str(&format!(" Coupling : {}\n", coupling_label(s.ch1.coupling))); + out.push_str(&format!(" Probe : {}\n", probe_label(s.ch1.probe_mode))); + out.push_str(&format!(" Meas flags : 0x{:04X}\n", s.ch1.enabled_measurements)); + + // CH2 + out.push_str("\n── Channel 2 ──────────────────────────────────────────────\n"); + out.push_str(&format!(" Enabled : {}\n", s.ch2.enabled)); + if s.ch2.enabled { + out.push_str(&format!(" V/Div : {}/div (index {})\n", + s.ch2.vdiv_str(), s.ch2.vdiv_index)); + out.push_str(&format!(" Volt scale : {} µV/px\n", + s.ch2.volt_scale_uv as f64 / 25.0)); + out.push_str(&format!(" Zero V (pixels): {} px\n", s.ch2.zero_volt_pixels)); + out.push_str(&format!(" Zero V (µV) : {}\n", + format_uv(s.ch2.zero_volt_uv, 0))); + out.push_str(&format!(" Coupling : {}\n", coupling_label(s.ch2.coupling))); + out.push_str(&format!(" Probe : {}\n", probe_label(s.ch2.probe_mode))); + } + + // Cursors + if s.cursors_x_enable || s.cursors_y_enable { + out.push_str("\n── Cursors ────────────────────────────────────────────────\n"); + } + if s.cursors_x_enable { + let dx_px = (s.cursor_x2 - s.cursor_x1).abs() as f64; + let dt_s = dx_px * s.time_per_pixel_s(); + let dt_ps = (dt_s * 1e12) as i64; + let freq_hz = if dt_s > 0.0 { 1.0 / dt_s } else { 0.0 }; + out.push_str(&format!(" Cursor X1 : {} px (from centre)\n", s.cursor_x1)); + out.push_str(&format!(" Cursor X2 : {} px\n", s.cursor_x2)); + out.push_str(&format!(" Δt : {}\n", format_ps(dt_ps))); + out.push_str(&format!(" Freq (1/Δt) : {}\n", format_hz(freq_hz))); + } + if s.cursors_y_enable { + let act_ch = if s.active_channel == 0 { &s.ch1 } else { &s.ch2 }; + let uv_per_px = act_ch.volt_scale_uv as f64 / 25.0; + let dy_px = (s.cursor_y2 - s.cursor_y1).abs() as f64; + let dy_uv = (dy_px * uv_per_px) as i64; + let y1_uv = (s.cursor_y1 as f64 * uv_per_px) as i64; + let y2_uv = (s.cursor_y2 as f64 * uv_per_px) as i64; + out.push_str(&format!(" Cursor Y1 : {} ({} px)\n", + format_uv(y1_uv, act_ch.probe_mode), s.cursor_y1)); + out.push_str(&format!(" Cursor Y2 : {} ({} px)\n", + format_uv(y2_uv, act_ch.probe_mode), s.cursor_y2)); + out.push_str(&format!(" ΔV : {}\n", + format_uv(dy_uv, act_ch.probe_mode))); + } + + // Waveform summary + out.push_str("\n── Waveform Data Summary ───────────────────────────────────\n"); + let points = self.to_physical_waveform(); + let ch1_v: Vec = points.iter().map(|p| (p.ch1_min_v + p.ch1_max_v) / 2.0).collect(); + let ch1_min_v = ch1_v.iter().cloned().fold(f64::INFINITY, f64::min); + let ch1_max_v = ch1_v.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let ch1_avg_v = ch1_v.iter().sum::() / ch1_v.len() as f64; + out.push_str(&format!(" CH1 300 pixels, min={:.4}V max={:.4}V avg={:.4}V\n", + ch1_min_v, ch1_max_v, ch1_avg_v)); + if s.ch2.enabled { + let ch2_v: Vec = points.iter().filter_map(|p| p.ch2.map(|(mn, mx)| (mn + mx) / 2.0)).collect(); + if !ch2_v.is_empty() { + let ch2_min_v = ch2_v.iter().cloned().fold(f64::INFINITY, f64::min); + let ch2_max_v = ch2_v.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let ch2_avg_v = ch2_v.iter().sum::() / ch2_v.len() as f64; + out.push_str(&format!(" CH2 300 pixels, min={:.4}V max={:.4}V avg={:.4}V\n", + ch2_min_v, ch2_max_v, ch2_avg_v)); + } + } + out.push_str(&format!(" Total time span: {}\n", + format_ps((s.time_per_pixel_s() * WAVEFORM_PIXELS as f64 * 1e12) as i64))); + + // Auto-measurements + if let Some(ref m) = self.ch1_meas { + out.push_str("\n── CH1 Auto-Measurements ───────────────────────────────────\n"); + report_measurements(&mut out, m, s.ch1.probe_mode); + } + if let Some(ref m) = self.ch2_meas { + out.push_str("\n── CH2 Auto-Measurements ───────────────────────────────────\n"); + report_measurements(&mut out, m, s.ch2.probe_mode); + } + + // Raw waveform data (first 16 bytes each channel, hex) + out.push_str("\n── Raw Waveform Bytes (first 16, hex) ──────────────────────\n"); + let hex_row = |label: &str, data: &[u8]| { + let hex: Vec = data.iter().take(16).map(|b| format!("{:02X}", b)).collect(); + format!(" {:<8}: {}\n", label, hex.join(" ")) + }; + out.push_str(&hex_row("CH1 min", &self.ch1_min)); + out.push_str(&hex_row("CH1 max", &self.ch1_max)); + if let (Some(mn), Some(mx)) = (&self.ch2_min, &self.ch2_max) { + out.push_str(&hex_row("CH2 min", mn)); + out.push_str(&hex_row("CH2 max", mx)); + } + + out + } +} + +/// Max plausible measurement voltage (same threshold as container.rs). +const REPORT_MAX_V_UV: i64 = 100_000_000_000; // 100 kV in µV +/// Max plausible pulse width / period (ps). +const REPORT_MAX_T_PS: i64 = 200_000_000_000_000; // 200 s in ps + +fn report_measurements(out: &mut String, m: &Measurements, probe: u8) { + // Format a voltage field, showing "—" for uninitialized garbage. + let fv = |uv: i64| { + let mult = 10i64.pow(probe as u32); + if uv.saturating_mul(mult).abs() > REPORT_MAX_V_UV { + "—".to_string() + } else { + format_uv(uv, probe) + } + }; + // Format a time field (ps), showing "—" for uninitialized garbage. + let ft = |ps: i64| { + if !(0..=REPORT_MAX_T_PS).contains(&ps) { "—".to_string() } else { format_ps(ps) } + }; + + out.push_str(&format!(" Freq : {}\n", format_hz(m.freq_hz()))); + out.push_str(&format!(" Period : {}\n", ft(m.period_ps))); + out.push_str(&format!(" +Width : {}\n", ft(m.pos_width_ps))); + out.push_str(&format!(" -Width : {}\n", ft(m.neg_width_ps))); + out.push_str(&format!(" +Duty : {:.1}%\n", m.pos_duty_pct())); + out.push_str(&format!(" PkPk : {}\n", fv(m.pk_pk_uv))); + out.push_str(&format!(" Amplitude : {}\n", fv(m.amplitude_uv))); + out.push_str(&format!(" Max : {}\n", fv(m.max_uv))); + out.push_str(&format!(" Min : {}\n", fv(m.min_uv))); + out.push_str(&format!(" Top : {}\n", fv(m.top_uv))); + out.push_str(&format!(" Base : {}\n", fv(m.base_uv))); + out.push_str(&format!(" Avg : {}\n", fv(m.avg_uv))); + out.push_str(&format!(" RMS : {}\n", fv(m.rms_uv))); +} + +/// A single calibrated point in the waveform. +#[derive(Debug, Clone)] +pub struct PixelPoint { + pub time_s: f64, + pub ch1_min_v: f64, + pub ch1_max_v: f64, + /// (min_v, max_v) for CH2, or None if CH2 disabled. + pub ch2: Option<(f64, f64)>, +} + +// ── Parsing functions ────────────────────────────────────────────────────── + +/// Parse a 2048-byte screenshot buffer for a DSO3D12. +pub fn parse_screenshot(buf: &[u8]) -> Result { + parse_screenshot_as(buf, DeviceVariant::Dso3d12) +} + +/// Parse a screenshot buffer with an explicit device variant. +pub fn parse_screenshot_as(buf: &[u8], device: DeviceVariant) -> Result { + if buf.len() < 1740 { + return Err(ParseError::TooShort { got: buf.len(), expected: SCREENSHOT_SIZE }); + } + + let mut ch1_min = [0u8; WAVEFORM_PIXELS]; + let mut ch1_max = [0u8; WAVEFORM_PIXELS]; + ch1_min.copy_from_slice(&buf[0..WAVEFORM_PIXELS]); + ch1_max.copy_from_slice(&buf[WAVEFORM_PIXELS..2 * WAVEFORM_PIXELS]); + + let settings = match device { + DeviceVariant::Dso3d12 => parse_settings_dso3d12(buf), + DeviceVariant::Dso2512gV2 => parse_settings_dso2512g_v2(buf), + DeviceVariant::Dso2512gV1 => parse_settings_dso2512g_v1(buf), + }; + + let (ch2_min, ch2_max) = if settings.ch2.enabled { + let mut mn = [0u8; WAVEFORM_PIXELS]; + let mut mx = [0u8; WAVEFORM_PIXELS]; + mn.copy_from_slice(&buf[2 * WAVEFORM_PIXELS..3 * WAVEFORM_PIXELS]); + mx.copy_from_slice(&buf[3 * WAVEFORM_PIXELS..4 * WAVEFORM_PIXELS]); + (Some(mn), Some(mx)) + } else { + (None, None) + }; + + let (meas_ch1_off, meas_ch2_off) = match device { + DeviceVariant::Dso3d12 => (1540, 1640), + DeviceVariant::Dso2512gV2 => (1544, 1648), + DeviceVariant::Dso2512gV1 => (1520, 1624), + }; + + let ch1_meas = Measurements::parse(buf, meas_ch1_off, settings.ch1.probe_mode); + let ch2_meas = if settings.ch2.enabled { + Measurements::parse(buf, meas_ch2_off, settings.ch2.probe_mode) + } else { + None + }; + + Ok(ScreenshotPacket { + device, + ch1_min, + ch1_max, + ch2_min, + ch2_max, + settings, + ch1_meas, + ch2_meas, + raw_len: buf.len(), + }) +} + +fn parse_settings_dso3d12(buf: &[u8]) -> OscSettings { + let ch2_enabled = read_u8(buf, 1202) != 0; + OscSettings { + run_state: read_u8(buf, 1200), + system_state: read_u8(buf, 1201), + buffer_depth: read_i64(buf, 1204), + timebase_index: read_u8(buf, 1216), + timebase_ns_per_div: read_u64(buf, 1220), + horiz_trigger_delay_ps: read_i64(buf, 1228), + sample_rate_hw_hz: read_u32(buf, 1252) as f64 * 100.0, + trigger_source: read_u8(buf, 1256), + trigger_mode: read_u8(buf, 1257), + trigger_edge: read_u8(buf, 1258), + trigger_level_adc: read_u16(buf, 1264), + ch1: ChannelSettings { + enabled: true, + vdiv_index: read_u8(buf, 1266), + volt_scale_uv: read_u32(buf, 1268), + zero_volt_pixels: read_i32(buf, 1272), + zero_volt_uv: read_i64(buf, 1276), + trigger_level_uv: read_i64(buf, 1292), + trigger_level_pixels: read_i32(buf, 1300), + coupling: read_u8(buf, 1304), + probe_mode: read_u8(buf, 1305), + enabled_measurements: read_u32(buf, 1308), + }, + ch2: ChannelSettings { + enabled: ch2_enabled, + vdiv_index: read_u8(buf, 1328), + volt_scale_uv: read_u32(buf, 1332), + zero_volt_pixels: read_i32(buf, 1336), + zero_volt_uv: read_i64(buf, 1340), + trigger_level_uv: read_i64(buf, 1356), + trigger_level_pixels: read_i32(buf, 1364), + coupling: read_u8(buf, 1368), + probe_mode: read_u8(buf, 1369), + enabled_measurements: read_u32(buf, 1372), + }, + sample_rate_meas_hz: read_u64(buf, 1392) as f64, + pre_trigger_samples: read_u32(buf, 1400), + post_trigger_samples: read_u32(buf, 1404), + display_start_col: read_u16(buf, 1408), + display_end_col: read_u16(buf, 1410), + cursors_x_enable: read_u8(buf, 1453) != 0, + cursors_y_enable: read_u8(buf, 1454) != 0, + cursor_x1: read_i16(buf, 1456), + cursor_x2: read_i16(buf, 1458), + cursor_y1: read_i16(buf, 1460), + cursor_y2: read_i16(buf, 1462), + active_channel: read_u8(buf, 1486), + } +} + +fn parse_settings_dso2512g_v2(buf: &[u8]) -> OscSettings { + let ch2_enabled = read_u8(buf, 1202) != 0; + OscSettings { + run_state: read_u8(buf, 1200), + system_state: read_u8(buf, 1201), + buffer_depth: read_u64(buf, 1208) as i64, + timebase_index: read_u8(buf, 1220), + timebase_ns_per_div: read_u64(buf, 1224), + horiz_trigger_delay_ps: read_i64(buf, 1232), + sample_rate_hw_hz: read_u64(buf, 1256) as f64 * 100.0, + trigger_source: read_u8(buf, 1264), + trigger_mode: read_u8(buf, 1265), + trigger_edge: read_u8(buf, 1266), + trigger_level_adc: read_u16(buf, 1272), + ch1: ChannelSettings { + enabled: true, + vdiv_index: read_u8(buf, 1274), + volt_scale_uv: read_u32(buf, 1276), + zero_volt_pixels: read_i32(buf, 1280), + zero_volt_uv: read_i64(buf, 1288), + trigger_level_uv: read_i64(buf, 1304), + trigger_level_pixels: read_i32(buf, 1312), + coupling: read_u8(buf, 1316), + probe_mode: read_u8(buf, 1317), + enabled_measurements: read_u32(buf, 1320), + }, + ch2: ChannelSettings { + enabled: ch2_enabled, + vdiv_index: read_u8(buf, 1340), + volt_scale_uv: read_u32(buf, 1344), + zero_volt_pixels: read_i32(buf, 1348), + zero_volt_uv: read_i64(buf, 1352), + trigger_level_uv: read_i64(buf, 1368), + trigger_level_pixels: read_i32(buf, 1376), + coupling: read_u8(buf, 1380), + probe_mode: read_u8(buf, 1381), + enabled_measurements: read_u32(buf, 1384), + }, + sample_rate_meas_hz: read_u64(buf, 1408) as f64, + pre_trigger_samples: read_u32(buf, 1416), + post_trigger_samples: read_u32(buf, 1420), + display_start_col: read_u16(buf, 1424), + display_end_col: read_u16(buf, 1426), + cursors_x_enable: read_u8(buf, 1469) != 0, + cursors_y_enable: read_u8(buf, 1470) != 0, + cursor_x1: read_i16(buf, 1472), + cursor_x2: read_i16(buf, 1474), + cursor_y1: read_i16(buf, 1476), + cursor_y2: read_i16(buf, 1478), + active_channel: read_u8(buf, 1502), + } +} + +fn parse_settings_dso2512g_v1(buf: &[u8]) -> OscSettings { + // V1 has the same layout as V2 except some minor offsets. + let ch2_enabled = read_u8(buf, 1202) != 0; + OscSettings { + run_state: read_u8(buf, 1200), + system_state: read_u8(buf, 1201), + buffer_depth: read_u64(buf, 1208) as i64, + timebase_index: read_u8(buf, 1220), + timebase_ns_per_div: read_u64(buf, 1224), + horiz_trigger_delay_ps: read_i64(buf, 1232), + sample_rate_hw_hz: read_u64(buf, 1256) as f64 * 100.0, + trigger_source: read_u8(buf, 1264), + trigger_mode: read_u8(buf, 1265), + trigger_edge: read_u8(buf, 1266), + trigger_level_adc: read_u16(buf, 1272), + ch1: ChannelSettings { + enabled: true, + vdiv_index: read_u8(buf, 1274), + volt_scale_uv: read_u32(buf, 1276), + zero_volt_pixels: read_i32(buf, 1280), + zero_volt_uv: read_i64(buf, 1288), + trigger_level_uv: read_i64(buf, 1304), + trigger_level_pixels: read_i32(buf, 1312), + coupling: read_u8(buf, 1316), + probe_mode: read_u8(buf, 1317), + enabled_measurements: read_u32(buf, 1320), + }, + ch2: ChannelSettings { + enabled: ch2_enabled, + vdiv_index: read_u8(buf, 1340), + volt_scale_uv: read_u32(buf, 1344), + zero_volt_pixels: read_i32(buf, 1348), + zero_volt_uv: read_i64(buf, 1352), + trigger_level_uv: read_i64(buf, 1368), + trigger_level_pixels: read_i32(buf, 1376), + coupling: read_u8(buf, 1380), + probe_mode: read_u8(buf, 1381), + enabled_measurements: read_u32(buf, 1384), + }, + sample_rate_meas_hz: read_u64(buf, 1408) as f64 * 10.0, // V1: ×10 + pre_trigger_samples: read_u32(buf, 1416), + post_trigger_samples: read_u32(buf, 1420), + display_start_col: read_u16(buf, 1424), + display_end_col: read_u16(buf, 1426), + cursors_x_enable: read_u8(buf, 1465) != 0, + cursors_y_enable: read_u8(buf, 1466) != 0, + cursor_x1: read_i16(buf, 1468), + cursor_x2: read_i16(buf, 1470), + cursor_y1: read_i16(buf, 1472), + cursor_y2: read_i16(buf, 1474), + active_channel: read_u8(buf, 1484), + } +} + +/// Returns true if the buffer looks like a screenshot packet (size + rough sanity check). +pub fn is_screenshot_packet(buf: &[u8]) -> bool { + // ZeeTweak accepts 2040–2056 bytes as valid screenshot packets. + (2034..=2056).contains(&buf.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_uv_cases() { + assert_eq!(format_uv(0, 0), "0.00V"); + assert_eq!(format_uv(1_000_000, 0), "1.00V"); + assert_eq!(format_uv(500_000, 0), "500.0mV"); + assert_eq!(format_uv(1_000, 0), "1.0mV"); + assert_eq!(format_uv(500, 0), "500.0µV"); + } + + #[test] + fn format_ps_cases() { + assert_eq!(format_ps(0), "0.00s"); + assert_eq!(format_ps(1_000_000_000), "1.00ms"); + assert_eq!(format_ps(1_000_000_000_000), "1.00s"); + } + + #[test] + fn format_hz_cases() { + assert_eq!(format_hz(1000.0), "1.00kHz"); + assert_eq!(format_hz(1_000_000.0), "1.00MHz"); + assert_eq!(format_hz(50.0), "50.0Hz"); + } + + #[test] + fn screenshot_too_short_error() { + let buf = vec![0u8; 100]; + assert!(parse_screenshot(&buf).is_err()); + } + + #[test] + fn is_screenshot_packet_size_check() { + assert!(is_screenshot_packet(&vec![0u8; 2048])); + assert!(is_screenshot_packet(&vec![0u8; 2040])); + assert!(!is_screenshot_packet(&vec![0u8; 1200])); + assert!(!is_screenshot_packet(&[0u8; 100])); + } + + #[test] + fn parse_zeroed_packet() { + // A zeroed 2048-byte buffer should parse without panic. + let buf = vec![0u8; 2048]; + let pkt = parse_screenshot(&buf).unwrap(); + assert!(!pkt.settings.ch2.enabled); + assert_eq!(pkt.ch2_min, None); + let report = pkt.report_verbose(); + assert!(report.contains("DSO3D12")); + } + + #[test] + fn pixel_to_volts_identity() { + // A channel with 100000 µV scale, zero at pixel 0, ×1 probe. + let ch = ChannelSettings { + enabled: true, + vdiv_index: 7, + volt_scale_uv: 100_000, // 100mV/div + zero_volt_pixels: 0, + zero_volt_uv: 0, + trigger_level_uv: 0, + trigger_level_pixels: 0, + coupling: 0, + probe_mode: 0, + enabled_measurements: 0, + }; + // raw=128 → pixel_val=127 → (127-128-0)*scale = -1 × (100000/25/1e6) ≈ -4mV + let v = ch.pixel_to_volts(128); + assert!((v - -0.004).abs() < 1e-6, "v={v}"); + } +} diff --git a/flasher/Cargo.toml b/flasher/Cargo.toml index b19c84f..8f4477d 100644 --- a/flasher/Cargo.toml +++ b/flasher/Cargo.toml @@ -9,11 +9,16 @@ repository = "https://github.com/rssdev10/wm-tools" name = "flash" path = "src/flash.rs" +[[bin]] +name = "capture" +path = "src/capture.rs" + [dependencies] anyhow = "1.0" clap = { version = "4.0", features = ["derive"] } colored = "3.0" crc = "3.0" +dso-parser = { path = "../dso-parser" } indicatif = "0.18" serialport = "4.9.0" tabled = "0.20" diff --git a/flasher/src/capture.rs b/flasher/src/capture.rs new file mode 100644 index 0000000..207db36 --- /dev/null +++ b/flasher/src/capture.rs @@ -0,0 +1,298 @@ +//! Serial capture tool for ZeeWeii DSO3D12 screenshot packets. +//! +//! # Usage +//! capture [--port /dev/ttyXXX] [--device dso3d12|dso2512g-v2|dso2512g-v1] +//! [--save path.bin] [--timeout 30] +//! capture --load path.bin [--device dso3d12] +//! +//! On the oscilloscope: open *Gallery* → select a saved waveform. +//! The device transmits a 2048-byte binary screenshot packet automatically. + +use anyhow::{Context, Result}; +use clap::Parser; +use dso_parser::{ + is_screenshot_packet, parse_screenshot_as, DeviceVariant, +}; +use serialport::SerialPortType; +use std::{ + io::{Read, Write}, + path::PathBuf, + time::{Duration, Instant}, +}; + +const DEFAULT_PORT: &str = "/dev/tty.usbserial-1110"; +const BAUD: u32 = 115200; + +#[derive(Parser, Debug)] +#[clap( + name = "capture", + about = "Listen for a ZeeWeii DSO3D12 screenshot packet over serial", + after_help = "On the oscilloscope: open Gallery ▸ select a saved waveform to trigger upload." +)] +struct Args { + /// Serial port to open (use -l to list available ports). + #[clap(short, long, default_value = DEFAULT_PORT)] + port: String, + + /// List available serial ports and exit. + #[clap(short, long)] + list: bool, + + /// Device firmware variant: dso3d12 | dso2512g-v2 | dso2512g-v1. + #[clap(short, long, default_value = "dso3d12")] + device: String, + + /// Save the raw binary packet to this file. + #[clap(short, long)] + save: Option, + + /// Load and re-parse a previously saved raw binary file (skips serial capture). + #[clap(long)] + load: Option, + + /// Receive timeout in seconds (0 = wait forever). + #[clap(short, long, default_value = "60")] + timeout: u64, +} + +fn main() -> Result<()> { + let args = Args::parse(); + + if args.list { + list_ports(); + return Ok(()); + } + + let device = parse_device_variant(&args.device)?; + + // Re-parse mode: load from file instead of serial. + let buf = if let Some(ref path) = args.load { + println!("Loading {} …", path.display()); + std::fs::read(path) + .with_context(|| format!("Cannot read '{}'", path.display()))? + } else { + println!("Opening {} at {} baud …", args.port, BAUD); + let mut port = serialport::new(&args.port, BAUD) + .timeout(Duration::from_millis(200)) + .open() + .with_context(|| format!("Cannot open serial port '{}'", args.port))?; + + println!("Waiting for screenshot packet."); + println!(); + println!(" ► On the oscilloscope: open Gallery ▸ select a saved waveform."); + println!(" ► The device will transmit a 2048-byte binary packet automatically."); + println!(); + if args.timeout > 0 { + println!(" (Timeout: {} s)", args.timeout); + } else { + println!(" (Waiting indefinitely — Ctrl-C to abort)"); + } + println!(); + + let buf = receive_packet(&mut *port, args.timeout)?; + + println!(" Received {} bytes.", buf.len()); + + if let Some(ref path) = args.save { + std::fs::write(path, &buf) + .with_context(|| format!("Cannot save to {}", path.display()))?; + println!(" Saved raw packet → {}", path.display()); + } + + buf + }; + + println!(); + println!("{}", "═".repeat(66)); + println!(" Parsing as {} …", device); + println!("{}", "═".repeat(66)); + + match parse_screenshot_as(&buf, device) { + Ok(pkt) => { + println!("{}", pkt.report_verbose()); + + // Additional graph-usage notes + println!("{}", "═".repeat(66)); + println!(" GRAPH USAGE SUMMARY"); + println!("{}", "═".repeat(66)); + let s = &pkt.settings; + let points = pkt.to_physical_waveform(); + println!(" Samples : {} (300 pixels = 12 div × 25 px/div)", points.len()); + println!( + " X axis : 0 … {} (each step = {:.3e} s)", + dso_parser::format_ps( + (s.time_per_pixel_s() * 299.0 * 1e12) as i64 + ), + s.time_per_pixel_s() + ); + let half_range_v = s.ch1.volt_scale_uv as f64 * 4.0 / 1_000_000.0 + * 10f64.powi(s.ch1.probe_mode as i32); + println!( + " Y axis (CH1) : ≈ {:.4}V … {:.4}V ({}/div)", + -half_range_v, + half_range_v, + s.ch1.vdiv_str() + ); + if s.ch2.enabled { + let half_range_v2 = s.ch2.volt_scale_uv as f64 * 4.0 / 1_000_000.0 + * 10f64.powi(s.ch2.probe_mode as i32); + println!( + " Y axis (CH2) : ≈ {:.4}V … {:.4}V ({}/div)", + -half_range_v2, + half_range_v2, + s.ch2.vdiv_str() + ); + } + + println!(); + println!(" Data structure for plotting:"); + println!(" Each PixelPoint {{ time_s, ch1_min_v, ch1_max_v, ch2: Option<(min_v, max_v)> }}"); + println!(" Fill-between ch1_min_v and ch1_max_v for waveform envelope."); + println!(" Average (min+max)/2 per pixel for a clean single trace."); + println!(); + println!(" First 8 calibrated points:"); + for (i, p) in points.iter().take(8).enumerate() { + if let Some((c2min, c2max)) = p.ch2 { + println!( + " [{:3}] t={:.3e}s CH1 {:.4}…{:.4}V CH2 {:.4}…{:.4}V", + i, p.time_s, p.ch1_min_v, p.ch1_max_v, c2min, c2max + ); + } else { + println!( + " [{:3}] t={:.3e}s CH1 {:.4}…{:.4}V", + i, p.time_s, p.ch1_min_v, p.ch1_max_v + ); + } + } + } + Err(e) => { + eprintln!("Parse error: {e}"); + eprintln!("Raw hex dump (first 64 bytes):"); + for (i, b) in buf.iter().take(64).enumerate() { + if i % 16 == 0 { eprint!("\n {:04X} ", i); } + eprint!("{:02X} ", b); + } + eprintln!(); + std::process::exit(1); + } + } + + Ok(()) +} + +/// Read bytes from the serial port until we have a complete screenshot packet +/// or the timeout expires. Returns the raw buffer. +fn receive_packet(port: &mut dyn Read, timeout_secs: u64) -> Result> { + let mut buf: Vec = Vec::with_capacity(2048); + let mut read_buf = [0u8; 512]; + let deadline = if timeout_secs > 0 { + Some(Instant::now() + Duration::from_secs(timeout_secs)) + } else { + None + }; + let mut last_recv = Instant::now(); + + loop { + // Timeout check + if let Some(d) = deadline { + if Instant::now() > d { + anyhow::bail!("Timeout: no packet received within {} s", timeout_secs); + } + } + + match port.read(&mut read_buf) { + Ok(0) => {} + Ok(n) => { + buf.extend_from_slice(&read_buf[..n]); + last_recv = Instant::now(); + + // Flush progress indicator + print!("\r Buffered {} bytes … ", buf.len()); + let _ = std::io::stdout().flush(); + + // Once we have at least 2034 bytes and 200 ms have elapsed + // with no new data, consider the packet complete. + // ZeeTweak accepts 2034-2056 bytes. + if buf.len() >= 2034 + && last_recv.elapsed() >= Duration::from_millis(200) + { + println!(); + break; + } + + // Hard upper bound — if we received more than 2200 bytes, + // something went wrong (debug dump mixed in?). + if buf.len() > 2200 { + println!(); + eprintln!("Warning: buffer overflow ({} bytes); truncating to 2048", buf.len()); + buf.truncate(2048); + break; + } + } + Err(ref e) + if e.kind() == std::io::ErrorKind::TimedOut + || e.kind() == std::io::ErrorKind::WouldBlock => + { + // 200 ms read timeout — if we have enough bytes and no recent + // activity, declare the packet complete. + if buf.len() >= 2034 + && last_recv.elapsed() >= Duration::from_millis(200) + { + println!(); + break; + } + } + Err(e) => { + return Err(e).context("Serial read error"); + } + } + } + + if !is_screenshot_packet(&buf) { + anyhow::bail!( + "Received {} bytes — does not match screenshot packet size (2034–2056).\n\ + This may be a debug dump or noise. Re-trigger on the device.", + buf.len() + ); + } + + Ok(buf) +} + +fn parse_device_variant(s: &str) -> Result { + match s.to_lowercase().as_str() { + "dso3d12" => Ok(DeviceVariant::Dso3d12), + "dso2512g-v2" | "dso2512gv2" | "v2" => Ok(DeviceVariant::Dso2512gV2), + "dso2512g-v1" | "dso2512gv1" | "v1" => Ok(DeviceVariant::Dso2512gV1), + other => anyhow::bail!( + "Unknown device variant '{}'. Use: dso3d12, dso2512g-v2, dso2512g-v1", + other + ), + } +} + +fn list_ports() { + println!("Available serial ports:"); + match serialport::available_ports() { + Ok(ports) => { + if ports.is_empty() { + println!(" (none found)"); + return; + } + for p in &ports { + let info = match &p.port_type { + SerialPortType::UsbPort(i) => { + let mfr = i.manufacturer.as_deref().unwrap_or("?"); + let prod = i.product.as_deref().unwrap_or("?"); + format!(" USB {mfr} / {prod}") + } + SerialPortType::BluetoothPort => " Bluetooth".to_string(), + SerialPortType::PciPort => " PCI".to_string(), + SerialPortType::Unknown => " (unknown)".to_string(), + }; + println!(" {} {}", p.port_name, info); + } + } + Err(e) => println!(" Error listing ports: {e}"), + } +} diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 1384ede..084746b 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -11,7 +11,7 @@ name = "dso3d12-gui" path = "src/main.rs" [dependencies] -dso3d12-parser = { path = "../dso3d12-parser" } +dso-parser = { path = "../dso-parser" } anyhow = { workspace = true } log = { workspace = true, features = ["std"] } thiserror = { workspace = true } diff --git a/gui/src/app.rs b/gui/src/app.rs index a48885b..30fd2b2 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use chrono::{DateTime, Local}; -use dso3d12_parser::{Capture, DEFAULT_BUFFER_DEPTH}; +use dso_parser::{Capture, DEFAULT_BUFFER_DEPTH}; use iced::widget::{ button, canvas, checkbox, column, container, pick_list, progress_bar, row, rule, scrollable, text, text_input, Space, @@ -49,9 +49,10 @@ pub enum Message { ToggleMeasCursorY(bool), MeasCursorXChanged(f32, f32), MeasCursorYChanged(f32, f32), - // Scale inputs (V/cell, time/cell) + // Scale inputs (V/div, time/div, V offset) VPerCellChanged(String), TPerCellChanged(String), + VOffsetChanged(String), // Graph click — move nearest cursor GraphClicked(f32, f32), // Misc @@ -75,8 +76,8 @@ pub enum Message { FirmwareEvent(#[allow(dead_code)] crate::flash::FlashEvent), // Port scan tick (auto-refresh) PortScanTick, - // Open repository URL in browser - OpenRepoUrl, + // Open a URL in the default browser + OpenUrl(String), // Context menu on thumbnails (right-click via iced_aw::ContextMenu) ContextMenuDeleteAt(usize), ContextMenuExportPngAt(usize), @@ -86,10 +87,15 @@ pub enum Message { } /// A capture paired with the timestamp of when it was received/loaded. +/// Holds both the legacy `Capture` (for rendering) and the rich `CaptureRecord` +/// (for metadata, measurements, and serialization). #[derive(Debug, Clone)] pub struct CaptureEntry { pub capture: Capture, pub timestamp: DateTime, + /// Full record with metadata (always present for new captures; may be + /// synthesized from legacy data when loading old files). + pub record: dso_parser::CaptureRecord, } #[derive(Debug, Clone)] @@ -129,10 +135,12 @@ pub struct App { was_listening_before_flash: bool, /// Target capture index for context menu export operations. context_export_idx: Option, - /// Text input state for V/cell scale. - v_per_cell_input: String, - /// Text input state for time/cell scale. - t_per_cell_input: String, + /// Text input state for V/div scale. + v_per_div_input: String, + /// Text input state for t/div scale. + t_per_div_input: String, + /// Text input state for V/offset. + v_offset_input: String, } impl App { @@ -146,8 +154,9 @@ impl App { ); let baud_str = settings.baud_rate.to_string(); let port_str = settings.serial_port.clone().unwrap_or_default(); - let v_per_cell_str = format_float(settings.v_per_cell); - let t_per_cell_str = format_float(settings.t_per_cell_ms); + let v_per_div_str = format_float(settings.v_per_div); + let t_per_div_str = format_float(settings.t_per_div_ms); + let v_offset_str = format_float(settings.v_offset); ( Self { settings, @@ -172,8 +181,9 @@ impl App { firmware_confirming: false, was_listening_before_flash: false, context_export_idx: None, - v_per_cell_input: v_per_cell_str, - t_per_cell_input: t_per_cell_str, + v_per_div_input: v_per_div_str, + t_per_div_input: t_per_div_str, + v_offset_input: v_offset_str, }, Task::none(), ) @@ -210,10 +220,16 @@ impl App { } pub(crate) fn capture_has_device_cursor_x(&self) -> bool { - false + self.current_entry() + .and_then(|e| e.record.scope_state.as_ref()) + .map(|s| s.cursors_x_enable) + .unwrap_or(false) } pub(crate) fn capture_has_device_cursor_y(&self) -> bool { - false + self.current_entry() + .and_then(|e| e.record.scope_state.as_ref()) + .map(|s| s.cursors_y_enable) + .unwrap_or(false) } // ── Subscription for serial events ───────────────────────────────── @@ -410,7 +426,10 @@ impl App { let now = Local::now(); let entries: Vec = captures .into_iter() - .map(|capture| CaptureEntry { capture, timestamp: now }) + .map(|capture| { + let record = dso_parser::capture_to_record(&capture, now, None); + CaptureEntry { capture, timestamp: now, record } + }) .collect(); // Append to existing dump rather than replacing. if let Some(ref mut dump) = self.dump { @@ -431,6 +450,55 @@ impl App { self.dump.as_ref().map(|d| d.captures.len()).unwrap_or(0) ); } + SerialEvent::ScreenshotReceived(raw_bytes) => { + log::info!("received screenshot packet ({} bytes)", raw_bytes.len()); + if self.settings.alert_on_data { + play_beep_end(); + } + let now = Local::now(); + match dso_parser::parse_screenshot(&raw_bytes) { + Ok(pkt) => { + let record = dso_parser::screenshot_to_record(&pkt, now, &raw_bytes); + let capture = pkt.to_capture(); + // Auto-fill scale from screenshot metadata + let s = &pkt.settings; + let probe1 = 10f64.powi(s.ch1.probe_mode as i32); + self.settings.v_per_div = s.ch1.volt_scale_uv as f64 / 1_000_000.0 * probe1; + self.settings.t_per_div_ms = s.timebase_ns_per_div as f64 / 1_000_000.0; + self.settings.v_offset = s.ch1.zero_volt_uv as f64 / 1_000_000.0 * probe1; + self.v_per_div_input = format_float(self.settings.v_per_div); + self.t_per_div_input = format_float(self.settings.t_per_div_ms); + self.v_offset_input = format_float(self.settings.v_offset); + self.settings.save(); + + let entry = CaptureEntry { capture, timestamp: now, record }; + if let Some(ref mut dump) = self.dump { + let prev_len = dump.captures.len(); + dump.captures.push(entry); + self.current = prev_len; + } else { + self.dump = Some(LoadedDump { + path: PathBuf::from(""), + captures: vec![entry], + }); + self.current = 0; + } + self.capture_progress = 0; + self.status = format!( + "Screenshot received ({}/div, {}/div). Total: {}", + dso_parser::format_uv( + (self.settings.v_per_div * 1_000_000.0) as i64, 0 + ), + s.timebase_label(), + self.dump.as_ref().map(|d| d.captures.len()).unwrap_or(0) + ); + } + Err(e) => { + self.status = format!("Screenshot parse error: {e}"); + log::error!("screenshot parse: {e}"); + } + } + } SerialEvent::Error(e) => { self.status = format!("Serial: {e}"); log::error!("serial error: {e}"); @@ -484,21 +552,31 @@ impl App { Task::none() } Message::VPerCellChanged(s) => { - self.v_per_cell_input = s.clone(); + self.v_per_div_input = s.clone(); // Accept both decimal ("0.001") and scientific notation ("1e-3") if let Ok(v) = s.parse::() { if v > 0.0 && v.is_finite() { - self.settings.v_per_cell = v; + self.settings.v_per_div = v; self.settings.save(); } } Task::none() } Message::TPerCellChanged(s) => { - self.t_per_cell_input = s.clone(); + self.t_per_div_input = s.clone(); if let Ok(v) = s.parse::() { if v > 0.0 && v.is_finite() { - self.settings.t_per_cell_ms = v; + self.settings.t_per_div_ms = v; + self.settings.save(); + } + } + Task::none() + } + Message::VOffsetChanged(s) => { + self.v_offset_input = s.clone(); + if let Ok(v) = s.parse::() { + if v.is_finite() { + self.settings.v_offset = v; self.settings.save(); } } @@ -595,8 +673,7 @@ impl App { } Task::none() } - Message::OpenRepoUrl => { - let url = env!("CARGO_PKG_REPOSITORY"); + Message::OpenUrl(url) => { let _ = open::that(url); Task::none() } @@ -889,7 +966,7 @@ impl App { rule::horizontal(1), text(format!("DSO3D12 GUI v{ver}")).size(13), button(text(repo).size(11)) - .on_press(Message::OpenRepoUrl) + .on_press(Message::OpenUrl(repo.to_string())) .style(button::text), text(format!("Config: {config_path}")).size(11), Space::new().height(Length::Fixed(8.0)), @@ -1087,10 +1164,25 @@ impl App { } else { None }, - device_cursor_x: None, - device_cursor_y: None, - v_per_cell: self.settings.v_per_cell, - t_per_cell_ms: self.settings.t_per_cell_ms, + device_cursor_x: if self.settings.device_cursor_x_enabled { + self.current_entry() + .and_then(|e| e.record.scope_state.as_ref()) + .filter(|s| s.cursors_x_enable) + .map(|s| (s.cursor_x1_frac as f32, s.cursor_x2_frac as f32)) + } else { + None + }, + device_cursor_y: if self.settings.device_cursor_y_enabled { + self.current_entry() + .and_then(|e| e.record.scope_state.as_ref()) + .filter(|s| s.cursors_y_enable) + .map(|s| (s.cursor_y1_frac as f32, s.cursor_y2_frac as f32)) + } else { + None + }, + v_per_div: self.settings.v_per_div, + t_per_div_ms: self.settings.t_per_div_ms, + v_offset: self.settings.v_offset, on_click: Some(Message::GraphClicked), }; canvas(scope) @@ -1108,10 +1200,11 @@ impl App { let (ylo, yhi) = self.settings.cursor_y_range; // Scale parameters - let v_per_cell = self.settings.v_per_cell; - let t_per_cell_ms = self.settings.t_per_cell_ms; - let half_v_range = 4.0 * v_per_cell; // 8 cells / 2 - let total_time_ms = 12.0 * t_per_cell_ms; + let v_per_div = self.settings.v_per_div; + let t_per_div_ms = self.settings.t_per_div_ms; + let v_offset = self.settings.v_offset; + let half_v_range = 4.0 * v_per_div; // 8 div / 2 + let total_time_ms = 12.0 * t_per_div_ms; // ── Signal information ── let mut items: Vec> = vec![ @@ -1122,8 +1215,8 @@ impl App { // ── Scale inputs ── items.push( row![ - text("V/cell:").size(11), - text_input("1.0", &self.v_per_cell_input) + text("V/div:").size(11), + text_input("1.0", &self.v_per_div_input) .on_input(Message::VPerCellChanged) .size(11) .width(Length::Fixed(50.0)), @@ -1135,8 +1228,8 @@ impl App { ); items.push( row![ - text("t/cell:").size(11), - text_input("1.0", &self.t_per_cell_input) + text("t/div:").size(11), + text_input("1.0", &self.t_per_div_input) .on_input(Message::TPerCellChanged) .size(11) .width(Length::Fixed(50.0)), @@ -1147,13 +1240,26 @@ impl App { .into(), ); items.push( - text("AC mode: 8×V, 12×t cells").size(9).into(), + row![ + text("V/off:").size(11), + text_input("0.0", &self.v_offset_input) + .on_input(Message::VOffsetChanged) + .size(11) + .width(Length::Fixed(50.0)), + text("V").size(11), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .into(), + ); + items.push( + text("AC mode: 8×V, 12×t div").size(9).into(), ); items.push(rule::horizontal(1).into()); if let Some(cap) = cap { - let v_min = frac_to_voltage(1.0, half_v_range); // bottom - let v_max = frac_to_voltage(0.0, half_v_range); // top + let v_min = frac_to_voltage(1.0, half_v_range, v_offset); // bottom + let v_max = frac_to_voltage(0.0, half_v_range, v_offset); // top items.push( text(format!("CH1 range: {v_min:.2}V … {v_max:.2}V")).size(11).into(), ); @@ -1212,8 +1318,8 @@ impl App { if self.settings.measurement_cursor_y_enabled { items.push(range_slider::horizontal(ylo, yhi, Message::MeasCursorYChanged)); - let v_lo = frac_to_voltage(ylo as f64, half_v_range); - let v_hi = frac_to_voltage(yhi as f64, half_v_range); + let v_lo = frac_to_voltage(ylo as f64, half_v_range, v_offset); + let v_hi = frac_to_voltage(yhi as f64, half_v_range, v_offset); let dv = (v_hi - v_lo).abs(); items.push( column![ @@ -1249,6 +1355,115 @@ impl App { } } + // ── Trigger + measurements from screenshot metadata ── + if let Some(scope_state) = self.current_entry().and_then(|e| e.record.scope_state.as_ref()) { + items.push(rule::horizontal(1).into()); + items.push(text("Trigger").size(11).into()); + items.push( + text(format!( + "Mode: {} | Edge: {} | Src: {}", + scope_state.trigger_mode, scope_state.trigger_edge, scope_state.trigger_source + )) + .size(10) + .into(), + ); + items.push( + text(format!("Level: {:.3} V", scope_state.trigger_level_v)) + .size(10) + .into(), + ); + items.push( + text(format!( + "Delay: {:.6} s | Rate: {:.0} Sa/s", + scope_state.trigger_delay_s, scope_state.sample_rate_hz + )) + .size(10) + .into(), + ); + + // ── Auto-measurements table ── + let has_m1 = scope_state.ch1.as_ref().and_then(|c| c.measurements.as_ref()); + let has_m2 = scope_state.ch2.as_ref().and_then(|c| c.measurements.as_ref()); + if has_m1.is_some() || has_m2.is_some() { + items.push(rule::horizontal(1).into()); + items.push(text("Auto Measurements").size(11).into()); + + let fmt_v = |v: f64| -> String { + if v.abs() >= 1.0 { format!("{v:.3} V") } + else { format!("{:.2} mV", v * 1000.0) } + }; + let fmt_t = |s: f64| -> String { + if s >= 1.0 { format!("{s:.3} s") } + else if s >= 0.001 { format!("{:.3} ms", s * 1000.0) } + else { format!("{:.2} µs", s * 1_000_000.0) } + }; + let fmt_hz = |hz: f64| -> String { + if hz >= 1_000_000.0 { format!("{:.3} MHz", hz / 1_000_000.0) } + else if hz >= 1000.0 { format!("{:.3} kHz", hz / 1000.0) } + else { format!("{hz:.2} Hz") } + }; + + // Build measurement rows: label | CH1 | CH2 + let labels = [ + "Freq", "PkPk", "Avg", "RMS", "Amp", "+Duty", "-Duty", + "+T", "-T", "T", "Max", "Min", "Top", "Base", + ]; + let ch_val = |m: Option<&dso_parser::ChannelMeasurements>, idx: usize| -> String { + match m { + None => "—".to_string(), + Some(m) => { + let fv = |v: f64| if v.is_nan() { "—".to_string() } else { fmt_v(v) }; + let ft = |v: f64| if v.is_nan() { "—".to_string() } else { fmt_t(v) }; + let fd = |v: f64| if v.is_nan() { "—".to_string() } else { format!("{:.1}%", v) }; + let fh = |v: f64| if v.is_nan() { "—".to_string() } else { fmt_hz(v) }; + match idx { + 0 => fh(m.freq_hz), + 1 => fv(m.pk_pk_v), + 2 => fv(m.avg_v), + 3 => fv(m.rms_v), + 4 => fv(m.amplitude_v), + 5 => fd(m.pos_duty_pct), + 6 => fd(m.neg_duty_pct), + 7 => ft(m.pos_width_s), + 8 => ft(m.neg_width_s), + 9 => ft(m.period_s), + 10 => fv(m.max_v), + 11 => fv(m.min_v), + 12 => fv(m.top_v), + 13 => fv(m.base_v), + _ => "—".to_string(), + } + } + } + }; + + // Header row + items.push( + row![ + text("").size(9).width(Length::Fixed(36.0)), + text("CH1").size(9).width(Length::Fixed(72.0)), + text("CH2").size(9).width(Length::Fixed(72.0)), + ] + .spacing(2) + .into(), + ); + + for (idx, lbl) in labels.iter().enumerate() { + let v1 = ch_val(has_m1, idx); + let v2 = ch_val(has_m2, idx); + items.push( + row![ + text(*lbl).size(9).width(Length::Fixed(36.0)), + text(v1).size(9).width(Length::Fixed(72.0)), + text(v2).size(9).width(Length::Fixed(72.0)), + ] + .spacing(2) + .into(), + ); + } + } + } + container(column(items).spacing(4).padding(6)) .width(Length::Fixed(220.0)) .style(container::rounded_box) @@ -1333,16 +1548,42 @@ impl App { // ── Helpers ──────────────────────────────────────────────────────────────── fn instructions_panel<'a>() -> Element<'a, Message> { - let body = "How to capture from a ZeeWeii DSO3D12:\n\ + let standard_fw = "Standard firmware (raw debug dump):\n\n\ 1. Feed a signal into the scope.\n\ 2. Press Stop to freeze the sample buffer.\n\ - 3. Press Menu, then Stop so the Channel 1 Measurements menu is on screen.\n\ - 4. Long-press the Save button to start the debug data transmission.\n\ - 5. Capture the serial stream into a .bin file and load it here."; - container(text(body).size(12)) - .padding(8) - .style(container::rounded_box) - .into() + 3. Press Menu → Stop so Channel 1\n Measurements menu is on screen.\n\ + 4. Long-press the Save button to start\n the debug data transmission.\n\ + 5. Wait until the transfer completes\n (~300 kB at 115200 baud)."; + + let screenshot_fw = "Customized firmware:\n\ + 1. Capture a waveform on the device\n (press Save while running or stopped).\n\ + 2. Press Menu → Gallery to open\n the saved screenshots list.\n\ + 3. Select the waveform to transmit.\n\ + 4. The device sends a 2048-byte binary\n packet automatically over USB-serial.\n\ + 5. Includes full metadata: V/div, timebase,\n trigger, cursors, measurements."; + + let left = container(text(standard_fw).size(11)) + .padding(6) + .width(Length::FillPortion(1)); + let right = container( + column![ + button(text("ZeeTweak (GitHub)").size(11)) + .on_press(Message::OpenUrl("https://github.com/taligentx/ZeeTweak".to_string())) + .style(button::text), + text(screenshot_fw).size(11), + ] + .spacing(2), + ) + .padding(6) + .width(Length::FillPortion(1)); + + container( + row![left, rule::vertical(1), right] + .spacing(4) + ) + .padding(4) + .style(container::rounded_box) + .into() } pub(crate) fn stats(samples: &[u8]) -> (u8, u8, f32) { @@ -1368,7 +1609,7 @@ fn adc_to_voltage(adc: u8) -> f32 { } /// Convert a fractional position (0.0..1.0) on the Y axis to voltage -/// using the user-configured V/cell scale. +/// using the user-configured V/div scale. /// frac=0 → top → +half_range; frac=1 → bottom → -half_range. /// Format a float for display in a scale input field. /// Always shows a decimal point so the field looks like a float. @@ -1384,8 +1625,8 @@ fn format_float(v: f64) -> String { } } -fn frac_to_voltage(frac: f64, half_v_range: f64) -> f64 { - half_v_range * (1.0 - 2.0 * frac) +fn frac_to_voltage(frac: f64, half_v_range: f64, v_offset: f64) -> f64 { + v_offset + half_v_range * (1.0 - 2.0 * frac) } /// Convert sample count to time in milliseconds at the default 1 MHz sample rate. @@ -1470,7 +1711,7 @@ fn thumbnails<'a>(dump: &'a Option, current: usize) -> Element<'a, M async fn pick_file() -> Option { rfd::AsyncFileDialog::new() - .add_filter("DSO3D12 raw dump", &["bin", "dat", "raw"]) + .add_filter("ZeeWeii Capture", &["zwcap", "bin", "dat", "raw"]) .add_filter("All files", &["*"]) .pick_file() .await @@ -1479,9 +1720,10 @@ async fn pick_file() -> Option { async fn pick_save_bin() -> Option { let now = chrono::Local::now(); - let name = format!("capture_{}.bin", now.format("%Y%m%d_%H%M%S")); + let name = format!("capture_{}.zwcap", now.format("%Y%m%d_%H%M%S")); rfd::AsyncFileDialog::new() - .add_filter("DSO3D12 raw dump", &["bin"]) + .add_filter("ZeeWeii Capture", &["zwcap"]) + .add_filter("All files", &["*"]) .set_file_name(&name) .save_file() .await @@ -1522,14 +1764,36 @@ async fn pick_firmware_file() -> Option { async fn load_dump(path: PathBuf) -> Result { log::info!("loading {}", path.display()); let bytes = std::fs::read(&path)?; - let parser_entries = dso3d12_parser::load_captures_with_metadata(&bytes)?; - let entries: Vec = parser_entries - .into_iter() - .map(|e| CaptureEntry { - capture: e.capture, - timestamp: e.timestamp, - }) - .collect(); + + // Detect format: .zwcap container, binary screenshot, or ASCII debug dump + let entries = if dso_parser::is_zwcap(&bytes) { + // New container format + let records = dso_parser::deserialize_zwcap(&bytes) + .map_err(|e| anyhow::anyhow!("{e}"))?; + records.into_iter().map(|rec| { + let capture = dso_parser::record_to_capture(&rec); + let timestamp = rec.captured_at.as_ref() + .and_then(|s| chrono::DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%:z").ok()) + .map(|dt| dt.with_timezone(&Local)) + .unwrap_or_else(Local::now); + CaptureEntry { capture, timestamp, record: rec } + }).collect() + } else if dso_parser::is_screenshot_packet(&bytes) { + // Single binary screenshot packet + let pkt = dso_parser::parse_screenshot(&bytes)?; + let now = Local::now(); + let record = dso_parser::screenshot_to_record(&pkt, now, &bytes); + let capture = pkt.to_capture(); + vec![CaptureEntry { capture, timestamp: now, record }] + } else { + // Try ASCII debug dump (legacy format) + let parser_entries = dso_parser::load_captures_with_metadata(&bytes)?; + parser_entries.into_iter().map(|e| { + let record = dso_parser::capture_to_record(&e.capture, e.timestamp, None); + CaptureEntry { capture: e.capture, timestamp: e.timestamp, record } + }).collect() + }; + Ok(LoadedDump { path, captures: entries }) } @@ -1569,17 +1833,13 @@ fn play_beep_end() { // ── File save/load helpers ───────────────────────────────────────────────── -/// Save all captures to a file using the parser crate's serializer. +/// Save all captures to a `.zwcap` container file. fn save_all_captures(path: &std::path::Path, entries: &[CaptureEntry]) -> anyhow::Result<()> { - // Convert GUI CaptureEntry to parser CaptureEntry - let parser_entries: Vec = entries + let records: Vec = entries .iter() - .map(|e| dso3d12_parser::CaptureEntry { - capture: e.capture.clone(), - timestamp: e.timestamp, - }) + .map(|e| e.record.clone()) .collect(); - let data = dso3d12_parser::serialize_captures(&parser_entries); + let data = dso_parser::serialize_zwcap(&records); std::fs::write(path, data)?; Ok(()) } @@ -1695,13 +1955,14 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any }; // Helper to draw scale labels (simple 3x5 digit font). - let v_per_cell = settings.v_per_cell; - let t_per_cell_ms = settings.t_per_cell_ms; + let v_per_div = settings.v_per_div; + let t_per_div_ms = settings.t_per_div_ms; + let v_offset = settings.v_offset; let draw_scales_on_region = |pixels: &mut Vec, x_off: u32, y_off: u32, w: u32, h: u32, _n_samples: usize, ch1_color: Option<[u8; 3]>, ch2_color: Option<[u8; 3]>| { // X axis: time labels at bottom let cols = 10u32; - let total_time_ms = 12.0 * t_per_cell_ms; + let total_time_ms = 12.0 * t_per_div_ms; for i in 0..=cols { let frac = i as f64 / cols as f64; let time_ms = frac * total_time_ms; @@ -1717,12 +1978,12 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any // Y axis: voltage labels on left margin, per-channel let rows = 8u32; - let half_v_range = 4.0 * v_per_cell; + let half_v_range = 4.0 * v_per_div; let x_ch1 = 2u32; let x_ch2 = if ch1_color.is_some() { 26u32 } else { 2u32 }; for i in 0..=rows { let frac = i as f64 / rows as f64; - let voltage = half_v_range * (1.0 - 2.0 * frac); + let voltage = v_offset + half_v_range * (1.0 - 2.0 * frac); let label = format!("{voltage:.1}"); let y_pos = y_off + (i * h / rows); if let Some(c) = ch1_color { @@ -1749,8 +2010,7 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any let y_off_2 = graph_h + scale_margin_bottom; draw_grid(&mut pixels, scale_margin_left, y_off_2, graph_w, sub_h); if let Some(ch2) = &cap.ch2 { - let flipped: Vec = ch2.iter().map(|&v| 255 - v).collect(); - draw_ch(&mut pixels, &flipped, [38, 217, 255], scale_margin_left, y_off_2, graph_w, sub_h); + draw_ch(&mut pixels, ch2, [38, 217, 255], scale_margin_left, y_off_2, graph_w, sub_h); } if settings.show_scales { let ch2_col = Some([38u8, 217, 255]); @@ -1765,8 +2025,7 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any } if settings.show_ch2 { if let Some(ch2) = &cap.ch2 { - let flipped: Vec = ch2.iter().map(|&v| 255 - v).collect(); - draw_ch(&mut pixels, &flipped, [38, 217, 255], scale_margin_left, 0, graph_w, graph_h); + draw_ch(&mut pixels, ch2, [38, 217, 255], scale_margin_left, 0, graph_w, graph_h); } } if settings.show_scales { @@ -1950,7 +2209,14 @@ const CRC_TABLE: [u32; 256] = { #[cfg(test)] mod tests { use super::*; - use dso3d12_parser::Capture; + use dso_parser::Capture; + + /// Helper to construct a test CaptureEntry from a Capture. + fn test_entry(capture: Capture) -> CaptureEntry { + let now = Local::now(); + let record = dso_parser::capture_to_record(&capture, now, None); + CaptureEntry { capture, timestamp: now, record } + } #[test] fn stats_handles_empty() { @@ -1985,13 +2251,10 @@ mod tests { let (mut app, _) = App::new(); app.dump = Some(LoadedDump { path: PathBuf::from("test.bin"), - captures: vec![CaptureEntry { - capture: Capture { - ch1: vec![1, 2, 3], - ch2: None, - }, - timestamp: Local::now(), - }], + captures: vec![test_entry(Capture { + ch1: vec![1, 2, 3], + ch2: None, + })], }); let _ = app.update(Message::SelectCapture(99)); assert_eq!(app.current, 0); @@ -2025,13 +2288,10 @@ mod tests { let (mut app, _) = App::new(); app.dump = Some(LoadedDump { path: PathBuf::from("test.bin"), - captures: vec![CaptureEntry { - capture: Capture { - ch1: (0..100).map(|i| i as u8).collect(), - ch2: None, - }, - timestamp: Local::now(), - }], + captures: vec![test_entry(Capture { + ch1: (0..100).map(|i| i as u8).collect(), + ch2: None, + })], }); let _ = app.view(); } diff --git a/gui/src/canvas.rs b/gui/src/canvas.rs index 3e9e480..996fd58 100644 --- a/gui/src/canvas.rs +++ b/gui/src/canvas.rs @@ -1,7 +1,7 @@ //! Oscilloscope-style graph canvas (iced::widget::canvas). use crate::settings::ViewMode; -use dso3d12_parser::Capture; +use dso_parser::Capture; use iced::widget::canvas::{self, Frame, Geometry, Path, Stroke, Text}; use iced::{mouse, Color, Point, Rectangle, Renderer, Size, Theme}; @@ -27,10 +27,12 @@ pub struct Scope<'a, Message = ()> { pub has_ch1: bool, /// Whether CH2 data is present in the capture (for scale labelling). pub has_ch2: bool, - /// Volts per cell (vertical division). 8 cells total. - pub v_per_cell: f64, - /// Time per cell (horizontal division) in milliseconds. 12 cells total. - pub t_per_cell_ms: f64, + /// Volts per division. 8 divisions total. + pub v_per_div: f64, + /// Time per division in milliseconds. 12 divisions total. + pub t_per_div_ms: f64, + /// Voltage offset (voltage at graph center). + pub v_offset: f64, /// Callback message factory for graph clicks: (frac_x, frac_y). pub on_click: Option Message>, } @@ -152,9 +154,7 @@ impl canvas::Program for Scope<'_, Message> { } if self.show_ch2 { if let Some(ch2) = cap.ch2.as_ref() { - // CH2 is stored inverted in the debug dump; flip to match device display. - let flipped: Vec = ch2.iter().map(|&v| 255 - v).collect(); - draw_trace(&mut frame, &flipped, CH2_COLOR, self.view_mode); + draw_trace(&mut frame, ch2, CH2_COLOR, self.view_mode); } } } else { @@ -270,8 +270,9 @@ impl canvas::Program for Scope<'_, Message> { self.n_samples, self.has_ch1, self.has_ch2, - self.v_per_cell, - self.t_per_cell_ms, + self.v_per_div, + self.t_per_div_ms, + self.v_offset, ); } @@ -313,9 +314,9 @@ fn draw_grid(frame: &mut Frame, size: Size) { const SCALE_COLOR: Color = Color::from_rgb(0.60, 0.65, 0.70); -/// Number of vertical divisions (cells) on the oscilloscope screen for voltage. +/// Number of vertical divisions on the oscilloscope screen for voltage. const V_CELLS: usize = 8; -/// Number of horizontal divisions (cells) on the oscilloscope screen for time. +/// Number of horizontal divisions on the oscilloscope screen for time. const T_CELLS: usize = 12; fn draw_scales( @@ -323,16 +324,17 @@ fn draw_scales( _n_samples: usize, has_ch1: bool, has_ch2: bool, - v_per_cell: f64, - t_per_cell_ms: f64, + v_per_div: f64, + t_per_div_ms: f64, + v_offset: f64, ) { let size = frame.size(); let rows = V_CELLS; let cols = 10; // grid visual divisions (drawn grid is 10 columns) // X axis: time labels at each visual division. - // Total time span = T_CELLS * t_per_cell_ms. We distribute over the sample window. - let total_time_ms = T_CELLS as f64 * t_per_cell_ms; + // Total time span = T_CELLS * t_per_div_ms. We distribute over the sample window. + let total_time_ms = T_CELLS as f64 * t_per_div_ms; let dx = size.width / cols as f32; for i in 0..=cols { let frac = i as f64 / cols as f64; @@ -353,17 +355,17 @@ fn draw_scales( frame.fill_text(label); } - // Y axis: voltage scale. Total range = V_CELLS * v_per_cell, centered around 0. - // Top = +half_range, bottom = -half_range (AC mode). - let half_range_v = (rows as f64 * v_per_cell) / 2.0; + // Y axis: voltage scale. Total range = V_CELLS * v_per_div, centered around v_offset. + // Top = v_offset + half_range, bottom = v_offset - half_range (AC mode). + let half_range_v = (rows as f64 * v_per_div) / 2.0; let ch1_x_offset = 2.0; let ch2_x_offset = if has_ch1 { 42.0 } else { 2.0 }; if has_ch1 { for i in 0..=rows { let frac = i as f32 / rows as f32; - // frac=0 → top → +half_range; frac=1 → bottom → -half_range - let voltage = half_range_v * (1.0 - 2.0 * frac as f64); + // frac=0 → top → v_offset+half_range; frac=1 → bottom → v_offset-half_range + let voltage = v_offset + half_range_v * (1.0 - 2.0 * frac as f64); let y = frac * size.height; let label = Text { content: format!("{voltage:.2}V"), @@ -379,7 +381,7 @@ fn draw_scales( if has_ch2 { for i in 0..=rows { let frac = i as f32 / rows as f32; - let voltage = half_range_v * (1.0 - 2.0 * frac as f64); + let voltage = v_offset + half_range_v * (1.0 - 2.0 * frac as f64); let y = frac * size.height; let label = Text { content: format!("{voltage:.2}V"), diff --git a/gui/src/logging.rs b/gui/src/logging.rs index 02cd61a..9815c98 100644 --- a/gui/src/logging.rs +++ b/gui/src/logging.rs @@ -59,16 +59,23 @@ impl log::Log for FileLogger { /// Initialize global logger. Safe to call multiple times; only the first call /// installs the logger. +/// +/// By default only stderr logging is active (no file). Set `DSO_LOG_FILE=1` +/// to enable the file logger. pub fn init() { let stderr_level = std::env::var("RUST_LOG") .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(Level::Info); - let file = log_path().and_then(|p| { - rotate_if_needed(&p); - OpenOptions::new().create(true).append(true).open(&p).ok() - }); + let file = if std::env::var("DSO_LOG_FILE").is_ok() { + log_path().and_then(|p| { + rotate_if_needed(&p); + OpenOptions::new().create(true).append(true).open(&p).ok() + }) + } else { + None + }; let logger = FileLogger { file: Mutex::new(file), diff --git a/gui/src/serial.rs b/gui/src/serial.rs index 7b34f5d..1b79b18 100644 --- a/gui/src/serial.rs +++ b/gui/src/serial.rs @@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; -use dso3d12_parser::{parse_captures, Capture}; +use dso_parser::{parse_captures, is_screenshot_packet, Capture}; use tokio::sync::mpsc; /// Configuration for serial capture. @@ -52,8 +52,10 @@ impl Drop for SerialHandle { /// Events emitted by the serial listener. #[derive(Debug, Clone)] pub enum SerialEvent { - /// A complete capture was received and parsed. + /// A complete capture was received and parsed (ASCII debug dump). CaptureReceived(Vec), + /// A binary screenshot packet was received (2048 bytes). + ScreenshotReceived(Vec), /// Bytes received so far (progress indicator: total bytes buffered). Progress(usize), /// An error occurred (port disconnected, parse failure, etc.). @@ -184,17 +186,23 @@ fn read_loop( // Check if we have buffered data and the timeout has elapsed. if !accum.is_empty() && last_data.elapsed() >= CAPTURE_TIMEOUT { log::info!("capture timeout; {} bytes buffered, parsing…", accum.len()); - match parse_captures(&accum) { - Ok(captures) if !captures.is_empty() => { - let _ = tx.send(SerialEvent::CaptureReceived(captures)); - } - Ok(_) => { - let _ = tx.send(SerialEvent::Error( - "Received data but no valid captures found.".to_string(), - )); - } - Err(e) => { - let _ = tx.send(SerialEvent::Error(format!("Parse error: {e}"))); + // Detect format: screenshot binary (2034–2056 bytes) vs ASCII debug dump + if is_screenshot_packet(&accum) { + log::info!("detected screenshot packet ({} bytes)", accum.len()); + let _ = tx.send(SerialEvent::ScreenshotReceived(accum.clone())); + } else { + match parse_captures(&accum) { + Ok(captures) if !captures.is_empty() => { + let _ = tx.send(SerialEvent::CaptureReceived(captures)); + } + Ok(_) => { + let _ = tx.send(SerialEvent::Error( + "Received data but no valid captures found.".to_string(), + )); + } + Err(e) => { + let _ = tx.send(SerialEvent::Error(format!("Parse error: {e}"))); + } } } accum.clear(); diff --git a/gui/src/settings.rs b/gui/src/settings.rs index cda66c5..7015ebb 100644 --- a/gui/src/settings.rs +++ b/gui/src/settings.rs @@ -31,19 +31,22 @@ pub struct Settings { pub alert_on_data: bool, /// Automatically start listening when the configured port appears. pub auto_listen: bool, - /// Volts per cell (division) for the scope grid. Default 1.0 V/cell. - #[serde(default = "default_v_per_cell")] - pub v_per_cell: f64, - /// Time per cell (division) in milliseconds. Default 1.0 ms/cell. - #[serde(default = "default_t_per_cell_ms")] - pub t_per_cell_ms: f64, + /// Volts per division for the scope grid. Default 1.0 V/div. + #[serde(default = "default_v_per_div")] + pub v_per_div: f64, + /// Time per division in milliseconds. Default 1.0 ms/div. + #[serde(default = "default_t_per_div_ms")] + pub t_per_div_ms: f64, + /// Voltage offset (zero-level of graph) in volts. Default 0.0 V. + #[serde(default)] + pub v_offset: f64, } -fn default_v_per_cell() -> f64 { +fn default_v_per_div() -> f64 { 1.0 } -fn default_t_per_cell_ms() -> f64 { +fn default_t_per_div_ms() -> f64 { 1.0 } @@ -87,8 +90,9 @@ impl Default for Settings { split_png: false, alert_on_data: true, auto_listen: false, - v_per_cell: 1.0, - t_per_cell_ms: 1.0, + v_per_div: 1.0, + t_per_div_ms: 1.0, + v_offset: 0.0, } } } From c39c0f07e5b2857e851d0be955cc47232e1e996d Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Sun, 31 May 2026 19:05:17 +0300 Subject: [PATCH 03/23] cicd and packaging --- .github/workflows/ci.yaml | 99 - .github/workflows/release.yml | 236 ++ .github/workflows/tests.yml | 57 + Cargo.lock | 5190 ++++++++++++++++++++++++-- gui/img/dso3d12_ico.png | Bin 0 -> 49420 bytes packaging/README.md | 10 + packaging/common/README-cli.txt | 12 + packaging/common/README-gui.txt | 10 + packaging/linux/README.txt | 5 + packaging/macos/Info.plist.template | 32 + packaging/macos/README-unsigned.txt | 48 + packaging/macos/create_app_bundle.sh | 137 + packaging/windows/README.txt | 5 + 13 files changed, 5406 insertions(+), 435 deletions(-) delete mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/tests.yml create mode 100644 gui/img/dso3d12_ico.png create mode 100644 packaging/README.md create mode 100644 packaging/common/README-cli.txt create mode 100644 packaging/common/README-gui.txt create mode 100644 packaging/linux/README.txt create mode 100644 packaging/macos/Info.plist.template create mode 100644 packaging/macos/README-unsigned.txt create mode 100755 packaging/macos/create_app_bundle.sh create mode 100644 packaging/windows/README.txt diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml deleted file mode 100644 index 0c5e005..0000000 --- a/.github/workflows/ci.yaml +++ /dev/null @@ -1,99 +0,0 @@ -name: build and release - -on: - release: - types: [ created ] - - workflow_dispatch: - -permissions: - contents: write - -jobs: - build: - name: ${{ matrix.platform.os_name }} with rust ${{ matrix.toolchain }} - runs-on: ${{ matrix.platform.os }} - strategy: - fail-fast: false - matrix: - platform: - - os_name: Linux-aarch64 - os: ubuntu-latest - target: aarch64-unknown-linux-musl - bin: flash-linux-arm64 - - - os_name: Linux-x86_64 - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - bin: flash-linux-amd64 - - - os_name: Windows-x86_64 - os: windows-latest - target: x86_64-pc-windows-msvc - bin: flash-windows-amd64.exe - - - os_name: macOS-x86_64 - os: macOS-latest - target: x86_64-apple-darwin - bin: flash-darwin-amd64 - - - os_name: macOS-aarch64 - os: macOS-latest - target: aarch64-apple-darwin - bin: flash-darwin-arm64 - toolchain: - - stable - steps: - - uses: actions/checkout@v4 - - # Install dependencies for Linux platforms - - name: Install dependencies (Linux) - if: startsWith(matrix.platform.os_name, 'Linux') - run: sudo apt-get update && sudo apt-get install -y libudev-dev - - - name: Build binary - uses: houseabsolute/actions-rust-cross@v1 - with: - command: "build" - target: ${{ matrix.platform.target }} - toolchain: ${{ matrix.toolchain }} - args: "--locked --release" - strip: true - - - name: Rename binary (linux and macos) - run: mv target/${{ matrix.platform.target }}/release/flash target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }} - if: matrix.platform.os_name != 'Windows-x86_64' - - name: Rename binary (windows) - run: mv target/${{ matrix.platform.target }}/release/flash.exe target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }} - if: matrix.platform.os_name == 'Windows-x86_64' - - - name: Create release zip archive (Linux and macOS) - run: | - zip -j target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}.zip target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }} - shell: bash - if: matrix.platform.os_name != 'Windows-x86_64' - - name: Create release zip archive (Windows) - run: | - powershell Compress-Archive -Path target\${{ matrix.platform.target }}\release\${{ matrix.platform.bin }} -DestinationPath target\${{ matrix.platform.target }}\release\${{ matrix.platform.bin }}.zip - if: matrix.platform.os_name == 'Windows-x86_64' - - - name: Generate SHA-256 - run: shasum -a 256 target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}.zip | cut -d ' ' -f 1 > target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}.zip.sha256 - - # Only upload as GitHub Release asset if this is a release event - - name: Release zip archive and SHA-256 checksum to GitHub - if: github.event_name == 'release' - uses: softprops/action-gh-release@v2 - with: - files: | - target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}.zip - target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}.zip.sha256 - - # For manual runs, upload as workflow artifact for download - - name: Upload artifact for manual download - if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.platform.bin }}.zip - path: target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}.zip - retention-days: 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b16d4e8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,236 @@ +name: build and release + +on: + release: + types: [created] + workflow_dispatch: + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: ${{ matrix.platform.label }}-${{ matrix.platform.arch }} + runs-on: ${{ matrix.platform.runner }} + strategy: + fail-fast: false + matrix: + platform: + - label: linux + arch: x86_64 + runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + cli_archive: wm-tools-cli-linux-x86_64.zip + gui_archive: dso3d12-gui-linux-x86_64.zip + gui_binary: dso3d12-gui + flash_binary: flash + capture_binary: capture + - label: windows + arch: x86_64 + runner: windows-latest + target: x86_64-pc-windows-msvc + cli_archive: wm-tools-cli-windows-x86_64.zip + gui_archive: dso3d12-gui-windows-x86_64.zip + gui_binary: dso3d12-gui.exe + flash_binary: flash.exe + capture_binary: capture.exe + - label: macos + arch: x86_64 + runner: macos-13 + target: x86_64-apple-darwin + cli_archive: wm-tools-cli-macos-x86_64.zip + gui_archive: dso3d12-gui-macos-x86_64.zip + gui_binary: dso3d12-gui + flash_binary: flash + capture_binary: capture + - label: macos + arch: arm64 + runner: macos-latest + target: aarch64-apple-darwin + cli_archive: wm-tools-cli-macos-arm64.zip + gui_archive: dso3d12-gui-macos-arm64.zip + gui_binary: dso3d12-gui + flash_binary: flash + capture_binary: capture + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.platform.target }} + + - uses: Swatinem/rust-cache@v2 + + - name: Install Linux dependencies + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + sudo apt-get update -y + sudo apt-get install -y \ + libasound2-dev \ + libfontconfig1-dev \ + libfreetype6-dev \ + libgl1-mesa-dev \ + libudev-dev \ + libwayland-dev \ + libx11-dev \ + libxcursor-dev \ + libxi-dev \ + libxinerama-dev \ + libxkbcommon-dev \ + libxrandr-dev \ + pkg-config + + - name: Build CLI (flasher + capture) + run: cargo build --locked --release -p wm-tools --target ${{ matrix.platform.target }} + + - name: Build GUI + run: cargo build --locked --release -p dso3d12-gui --target ${{ matrix.platform.target }} + + # --- CLI package --- + + - name: Stage Linux CLI package + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + rm -rf dist + mkdir -p dist/cli + cp "target/${{ matrix.platform.target }}/release/${{ matrix.platform.flash_binary }}" dist/cli/ + cp "target/${{ matrix.platform.target }}/release/${{ matrix.platform.capture_binary }}" dist/cli/ + cp LICENSE.txt README.md dist/cli/ + cp packaging/common/README-cli.txt dist/cli/PACKAGE.txt + (cd dist && zip -r "${{ matrix.platform.cli_archive }}" cli) + + - name: Stage macOS CLI package + if: runner.os == 'macOS' + shell: bash + run: | + set -euo pipefail + rm -rf dist + mkdir -p dist/cli + cp "target/${{ matrix.platform.target }}/release/${{ matrix.platform.flash_binary }}" dist/cli/ + cp "target/${{ matrix.platform.target }}/release/${{ matrix.platform.capture_binary }}" dist/cli/ + cp LICENSE.txt README.md dist/cli/ + cp packaging/common/README-cli.txt dist/cli/PACKAGE.txt + ditto -c -k --keepParent dist/cli "dist/${{ matrix.platform.cli_archive }}" + + - name: Stage Windows CLI package + if: runner.os == 'Windows' + shell: pwsh + run: | + Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path dist/cli | Out-Null + Copy-Item "target/${{ matrix.platform.target }}/release/${{ matrix.platform.flash_binary }}" dist/cli/ + Copy-Item "target/${{ matrix.platform.target }}/release/${{ matrix.platform.capture_binary }}" dist/cli/ + Copy-Item LICENSE.txt, README.md dist/cli/ + Copy-Item packaging/common/README-cli.txt dist/cli/PACKAGE.txt + Compress-Archive -Path dist/cli/* -DestinationPath "dist/${{ matrix.platform.cli_archive }}" -Force + + # --- GUI package --- + + - name: Stage Linux GUI package + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + mkdir -p dist/gui + cp "target/${{ matrix.platform.target }}/release/${{ matrix.platform.gui_binary }}" dist/gui/ + cp LICENSE.txt README.md dist/gui/ + cp packaging/common/README-gui.txt dist/gui/PACKAGE.txt + cp packaging/linux/README.txt dist/gui/PLATFORM.txt + (cd dist && zip -r "${{ matrix.platform.gui_archive }}" gui) + + - name: Stage Windows GUI package + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Path dist/gui -Force | Out-Null + Copy-Item "target/${{ matrix.platform.target }}/release/${{ matrix.platform.gui_binary }}" dist/gui/ + Copy-Item LICENSE.txt, README.md dist/gui/ + Copy-Item packaging/common/README-gui.txt dist/gui/PACKAGE.txt + Copy-Item packaging/windows/README.txt dist/gui/PLATFORM.txt + Compress-Archive -Path dist/gui/* -DestinationPath "dist/${{ matrix.platform.gui_archive }}" -Force + + - name: Stage macOS GUI package + if: runner.os == 'macOS' + shell: bash + env: + APP_VERSION: ${{ github.event.release.tag_name || github.ref_name }} + run: | + set -euo pipefail + mkdir -p dist + ./packaging/macos/create_app_bundle.sh \ + --dmg \ + --output-dir dist \ + --binary-path "target/${{ matrix.platform.target }}/release/dso3d12-gui" \ + --version "${APP_VERSION:-0.0.0}" + cp packaging/common/README-gui.txt dist/README-gui.txt + cp packaging/macos/README-unsigned.txt dist/README-unsigned.txt + ditto -c -k --sequesterRsrc --keepParent dist/DSO3D12.app "dist/${{ matrix.platform.gui_archive }}" + + # --- Checksums --- + + - name: Generate checksums + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + shasum -a 256 "dist/${{ matrix.platform.cli_archive }}" | awk '{print $1}' > "dist/${{ matrix.platform.cli_archive }}.sha256" + shasum -a 256 "dist/${{ matrix.platform.gui_archive }}" | awk '{print $1}' > "dist/${{ matrix.platform.gui_archive }}.sha256" + if [[ "${{ runner.os }}" == "macOS" ]]; then + shasum -a 256 dist/DSO3D12.dmg | awk '{print $1}' > dist/DSO3D12.dmg.sha256 + fi + + - name: Generate checksums (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + (Get-FileHash "dist/${{ matrix.platform.cli_archive }}" -Algorithm SHA256).Hash.ToLower() | Set-Content "dist/${{ matrix.platform.cli_archive }}.sha256" + (Get-FileHash "dist/${{ matrix.platform.gui_archive }}" -Algorithm SHA256).Hash.ToLower() | Set-Content "dist/${{ matrix.platform.gui_archive }}.sha256" + + # --- Upload --- + + - name: Release artifacts + if: github.event_name == 'release' && runner.os != 'macOS' + uses: softprops/action-gh-release@v2 + with: + files: | + dist/${{ matrix.platform.cli_archive }} + dist/${{ matrix.platform.cli_archive }}.sha256 + dist/${{ matrix.platform.gui_archive }} + dist/${{ matrix.platform.gui_archive }}.sha256 + + - name: Release macOS artifacts + if: github.event_name == 'release' && runner.os == 'macOS' + uses: softprops/action-gh-release@v2 + with: + files: | + dist/${{ matrix.platform.cli_archive }} + dist/${{ matrix.platform.cli_archive }}.sha256 + dist/${{ matrix.platform.gui_archive }} + dist/${{ matrix.platform.gui_archive }}.sha256 + dist/DSO3D12.dmg + dist/DSO3D12.dmg.sha256 + dist/README-unsigned.txt + + - name: Upload artifacts (manual run) + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: packages-${{ matrix.platform.label }}-${{ matrix.platform.arch }} + path: | + dist/${{ matrix.platform.cli_archive }} + dist/${{ matrix.platform.cli_archive }}.sha256 + dist/${{ matrix.platform.gui_archive }} + dist/${{ matrix.platform.gui_archive }}.sha256 + dist/DSO3D12.dmg + dist/DSO3D12.dmg.sha256 + dist/README-unsigned.txt + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..0cace22 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,57 @@ +name: Pre-merge testing + +on: + pull_request: + branches: ["**"] + push: + branches: [main, master] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + build-and-test: + name: Build & Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update -y + sudo apt-get install -y \ + libasound2-dev \ + libfontconfig1-dev \ + libfreetype6-dev \ + libgl1-mesa-dev \ + libudev-dev \ + libwayland-dev \ + libx11-dev \ + libxcursor-dev \ + libxi-dev \ + libxinerama-dev \ + libxkbcommon-dev \ + libxrandr-dev \ + pkg-config + + - name: Build workspace + run: cargo build --workspace + + - name: Run tests + run: cargo test --workspace + + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings diff --git a/Cargo.lock b/Cargo.lock index 9416edd..42b5309 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,78 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.11.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-build" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] [[package]] name = "anstream" @@ -59,635 +131,5081 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.11.1" +name = "arrayref" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] -name = "bumpalo" -version = "3.20.3" +name = "arrayvec" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "bytecount" -version = "0.6.9" +name = "as-raw-xcb-connection" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" [[package]] -name = "cfg-if" -version = "1.0.4" +name = "ash" +version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] [[package]] -name = "clap" -version = "4.6.1" +name = "ashpd" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" dependencies = [ - "clap_builder", - "clap_derive", + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", ] [[package]] -name = "clap_builder" -version = "4.6.0" +name = "async-broadcast" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", ] [[package]] -name = "clap_derive" -version = "4.6.1" +name = "async-channel" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", ] [[package]] -name = "clap_lex" -version = "1.1.0" +name = "async-executor" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] [[package]] -name = "colorchoice" -version = "1.0.5" +name = "async-fs" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] [[package]] -name = "colored" -version = "3.1.1" +name = "async-io" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", "windows-sys 0.61.2", ] [[package]] -name = "console" -version = "0.16.3" +name = "async-lock" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "encode_unicode", - "libc", - "unicode-width", - "windows-sys 0.61.2", + "event-listener", + "event-listener-strategy", + "pin-project-lite", ] [[package]] -name = "core-foundation" -version = "0.10.1" +name = "async-net" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" dependencies = [ - "core-foundation-sys", - "libc", + "async-io", + "blocking", + "futures-lite", ] [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "async-process" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] [[package]] -name = "crc" -version = "3.4.0" +name = "async-recursion" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ - "crc-catalog", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "crc-catalog" -version = "2.5.0" +name = "async-signal" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] [[package]] -name = "encode_unicode" -version = "1.0.0" +name = "async-task" +version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] -name = "fnv" -version = "1.0.7" +name = "async-trait" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "futures-core" -version = "0.3.32" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "futures-task" -version = "0.3.32" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] -name = "futures-util" -version = "0.3.32" +name = "bit-set" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", + "bit-vec", ] [[package]] -name = "heck" -version = "0.5.0" +name = "bit-vec" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] -name = "indicatif" -version = "0.18.4" +name = "bitflags" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" -dependencies = [ - "console", - "portable-atomic", - "unicode-width", - "unit-prefix", - "web-time", -] +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] -name = "io-kit-sys" -version = "0.4.1" +name = "bitflags" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" -dependencies = [ - "core-foundation-sys", - "mach2", -] +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "block" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" [[package]] -name = "js-sys" -version = "0.3.99" +name = "block2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", + "objc2 0.5.2", ] [[package]] -name = "libc" -version = "0.2.186" +name = "block2" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] [[package]] -name = "libudev" -version = "0.3.0" +name = "blocking" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "libc", - "libudev-sys", + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", ] [[package]] -name = "libudev-sys" -version = "0.1.4" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" -dependencies = [ - "libc", - "pkg-config", -] +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "mach2" -version = "0.4.3" +name = "bytecount" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" dependencies = [ - "libc", + "bytemuck_derive", ] [[package]] -name = "nix" -version = "0.26.4" +name = "bytemuck_derive" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "bytes" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "calloop" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.11.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] [[package]] -name = "papergrid" -version = "0.17.0" +name = "calloop" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "bytecount", - "fnv", - "unicode-width", + "bitflags 2.11.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "clipboard_macos" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" +dependencies = [ + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "clipboard_wayland" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" +dependencies = [ + "smithay-clipboard", +] + +[[package]] +name = "clipboard_x11" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd63e33452ffdafd39924c4f05a5dd1e94db646c779c6bd59148a3d95fff5ad4" +dependencies = [ + "thiserror 2.0.18", + "x11rb", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173852283a9a57a3cbe365d86e74dc428a09c50421477d5ad6fe9d9509e37737" +dependencies = [ + "bitflags 2.11.1", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 1.1.0", + "self_cell", + "skrifa 0.37.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cryoglyph" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc795bdbccdbd461736fb163930a009da6597b226d6f6fce33e7a8eb6ec519" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "rustc-hash 2.1.2", + "wgpu", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dso-parser" +version = "0.2.0" +dependencies = [ + "chrono", + "ciborium", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "dso3d12-gui" +version = "0.2.0" +dependencies = [ + "anyhow", + "chrono", + "crc", + "csv", + "dirs", + "dso-parser", + "iced", + "iced_aw", + "log", + "open", + "rfd", + "serde", + "serde_json", + "serialport", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.11.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.11.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "core_maths", + "read-fonts 0.35.0", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "iced" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000e01026c93ba643f8357a3db3ada0e6555265a377f6f9291c472f6dd701fb3" +dependencies = [ + "iced_core", + "iced_debug", + "iced_futures", + "iced_renderer", + "iced_runtime", + "iced_widget", + "iced_winit", + "thiserror 2.0.18", +] + +[[package]] +name = "iced_aw" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b7f923642b024c415150b70ef84ee5c11626e3b0af73b954d817d573bcd0a4" +dependencies = [ + "cfg-if", + "chrono", + "iced_core", + "iced_fonts", + "iced_widget", + "num-format", + "num-traits", + "web-time", +] + +[[package]] +name = "iced_core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ab1937d699403e7e69252ae743a902bcee9f4ab2052cc4c9a46fcf34729d85" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "glam", + "lilt", + "log", + "num-traits", + "rustc-hash 2.1.2", + "smol_str", + "thiserror 2.0.18", + "web-time", +] + +[[package]] +name = "iced_debug" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25035ab0215a620e53f4103e36fc4e59a1fb2817e4bfc38a30ad27b4202ea0be" +dependencies = [ + "iced_core", + "iced_futures", + "log", +] + +[[package]] +name = "iced_fonts" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214cff7c8499e328774216690e58e315a1a5f8f6fdd1035aed6298e62ffc4c1d" +dependencies = [ + "iced_core", + "iced_fonts_macros", + "iced_widget", +] + +[[package]] +name = "iced_fonts_macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef5125e110cb19cd1910a28298661c98c5d9ab02eef43594968352940e8752e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "ttf-parser", +] + +[[package]] +name = "iced_futures" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0c85ccad42dfbec7293c36c018af0ea0dbcc52d137a4a9a0b0f6822a3fdf0a" +dependencies = [ + "futures", + "iced_core", + "log", + "rustc-hash 2.1.2", + "tokio", + "wasm-bindgen-futures", + "wasmtimer", +] + +[[package]] +name = "iced_graphics" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234ca1c2cec4155055f68fa5fad1b5242c496ac8238d80a259bca382fb44a102" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "log", + "lyon_path", + "raw-window-handle", + "rustc-hash 2.1.2", + "thiserror 2.0.18", + "unicode-segmentation", +] + +[[package]] +name = "iced_program" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfafec2947cda688d8eb00dac337ba11aa60f9ef6335aed343e189d26e4a673" +dependencies = [ + "iced_graphics", + "iced_runtime", +] + +[[package]] +name = "iced_renderer" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250cc0802408e8c077986ec56c7d07c65f423ee658a4b9fd795a1f2aae5dac05" +dependencies = [ + "iced_graphics", + "iced_tiny_skia", + "iced_wgpu", + "log", + "thiserror 2.0.18", +] + +[[package]] +name = "iced_runtime" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1889b819ce4c06674183242e336c8d49465665441396914dc07cc86f44fa8d4" +dependencies = [ + "bytes", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 2.0.18", +] + +[[package]] +name = "iced_tiny_skia" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0acf8b75a3bc914aff5f2329fdffc1b36eeaea29dda0e4bd232f1c62e9cc3d" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_debug", + "iced_graphics", + "kurbo", + "log", + "rustc-hash 2.1.2", + "softbuffer", + "tiny-skia", +] + +[[package]] +name = "iced_wgpu" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff144a999b0ca0f8a10257934500060240825c42e950ec0ebee9c8ae30561c13" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "cryoglyph", + "futures", + "glam", + "guillotiere", + "iced_debug", + "iced_graphics", + "log", + "lyon", + "rustc-hash 2.1.2", + "thiserror 2.0.18", + "wgpu", +] + +[[package]] +name = "iced_widget" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab" +dependencies = [ + "iced_renderer", + "log", + "num-traits", + "rustc-hash 2.1.2", + "thiserror 2.0.18", + "unicode-segmentation", +] + +[[package]] +name = "iced_winit" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b7dbedc47562d1de3b9707d939f678b88c382004b7ab5a18f7a7dd723162d75" +dependencies = [ + "iced_debug", + "iced_program", + "log", + "mundy", + "rustc-hash 2.1.2", + "thiserror 2.0.18", + "tracing", + "wasm-bindgen-futures", + "web-sys", + "window_clipboard", + "winit", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec", + "smallvec", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.8.0", +] + +[[package]] +name = "libudev" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" +dependencies = [ + "libc", + "libudev-sys", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "lilt" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67562e5eff6b20553fa9be1c503356768420994e28f67e3eafe6f41910e57ad" +dependencies = [ + "web-time", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "lyon" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.11.1", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "mundy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f32eb0db40f2df2bcfb05c93b8f73938d4c26ce9ac8881f1df0c8d3296921a73" +dependencies = [ + "android-build", + "async-io", + "cfg-if", + "dispatch", + "futures-channel", + "futures-lite", + "jni", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "pin-project-lite", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", + "zbus", +] + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "papergrid" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" +dependencies = [ + "bytecount", + "fnv", + "unicode-width", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_syscall" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c7591fa2c6b601dfcfe5f043f65a1c39fcdf50efefcd7f1572e538c1f4b398d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serialport" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "core-foundation 0.10.1", + "core-foundation-sys", + "io-kit-sys", + "libudev", + "mach2", + "nix", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.11.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.11.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "fastrand", + "js-sys", + "memmap2", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.4", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" +dependencies = [ + "skrifa 0.40.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "tabled" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d" +dependencies = [ + "papergrid", + "tabled_derive", + "testing_table", +] + +[[package]] +name = "tabled_derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846" +dependencies = [ + "heck", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "testing_table" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor", + "libloading", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unescaper" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "pkg-config" -version = "0.3.33" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "portable-atomic" -version = "1.13.1" +name = "wgpu" +version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] [[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" +name = "wgpu-core" +version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.11.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.11.1", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "window_clipboard" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5654226305eaf2dde8853fb482861d28e5dcecbbd40cb88e8393d94bb80d733" +dependencies = [ + "clipboard-win", + "clipboard_macos", + "clipboard_wayland", + "clipboard_x11", + "raw-window-handle", + "thiserror 2.0.18", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", + "syn", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ - "proc-macro-error-attr2", "proc-macro2", "quote", "syn", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "windows-interface" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ - "unicode-ident", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "quote" -version = "1.0.45" +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "rustversion" -version = "1.0.22" +name = "windows_x86_64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "scopeguard" -version = "1.2.0" +name = "windows_x86_64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "serialport" -version = "4.9.0" +name = "winit" +version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ + "ahash", + "android-activity", + "atomic-waker", "bitflags 2.11.1", - "cfg-if", - "core-foundation", - "core-foundation-sys", - "io-kit-sys", - "libudev", - "mach2", - "nix", - "scopeguard", - "unescaper", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", ] [[package]] -name = "slab" -version = "0.4.12" +name = "winnow" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] [[package]] -name = "strsim" -version = "0.11.1" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] -name = "syn" -version = "2.0.117" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "anyhow", + "heck", + "wit-parser", ] [[package]] -name = "tabled" -version = "0.20.0" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ - "papergrid", - "tabled_derive", - "testing_table", + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", ] [[package]] -name = "tabled_derive" -version = "0.11.0" +name = "wit-bindgen-rust-macro" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" dependencies = [ - "heck", - "proc-macro-error2", + "anyhow", + "prettyplease", "proc-macro2", "quote", "syn", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] -name = "testing_table" -version = "0.3.0" +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ - "unicode-width", + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] -name = "thiserror" -version = "2.0.18" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ - "thiserror-impl", + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +name = "wm-tools" +version = "0.1.2" dependencies = [ - "proc-macro2", - "quote", - "syn", + "anyhow", + "clap", + "colored", + "crc", + "dso-parser", + "indicatif", + "serialport", + "tabled", ] [[package]] -name = "unescaper" -version = "0.1.8" +name = "writeable" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" -dependencies = [ - "thiserror", -] +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "x11-dl" +version = "2.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] [[package]] -name = "unicode-width" -version = "0.2.2" +name = "x11rb" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] [[package]] -name = "unit-prefix" -version = "0.5.2" +name = "x11rb-protocol" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] -name = "utf8parse" -version = "0.2.2" +name = "xcursor" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" [[package]] -name = "wasm-bindgen" -version = "0.2.122" +name = "xkbcommon-dl" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "cfg-if", + "bitflags 2.11.1", + "dlib", + "log", "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", + "xkeysym", ] [[package]] -name = "wasm-bindgen-macro" -version = "0.2.122" +name = "xkeysym" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ - "quote", - "wasm-bindgen-macro-support", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.122" +name = "yoke-derive" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ - "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-shared", + "synstructure", ] [[package]] -name = "wasm-bindgen-shared" -version = "0.2.122" +name = "zbus" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" dependencies = [ - "unicode-ident", + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", ] [[package]] -name = "web-time" -version = "1.1.0" +name = "zbus_macros" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" dependencies = [ - "js-sys", - "wasm-bindgen", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", ] [[package]] -name = "windows-link" -version = "0.2.1" +name = "zbus_names" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow", + "zvariant", +] [[package]] -name = "windows-sys" -version = "0.52.0" +name = "zeno" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "zerocopy" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ - "windows-link", + "zerocopy-derive", ] [[package]] -name = "windows-targets" -version = "0.52.6" +name = "zerocopy-derive" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" +name = "zerofrom" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] [[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" +name = "zerofrom-derive" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] [[package]] -name = "windows_i686_gnu" -version = "0.52.6" +name = "zerotrie" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] [[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" +name = "zerovec" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] [[package]] -name = "windows_i686_msvc" -version = "0.52.6" +name = "zerovec-derive" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" +name = "zvariant" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow", + "zvariant_derive", + "zvariant_utils", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" +name = "zvariant_derive" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] [[package]] -name = "wm-tools" -version = "0.1.2" +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" dependencies = [ - "anyhow", - "clap", - "colored", - "crc", - "indicatif", - "serialport", - "tabled", + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", ] diff --git a/gui/img/dso3d12_ico.png b/gui/img/dso3d12_ico.png new file mode 100644 index 0000000000000000000000000000000000000000..fa7ba2f7c61064fe44db8b61ab92bca449cbbf0c GIT binary patch literal 49420 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 + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + __APP_NAME__ + CFBundleExecutable + dso3d12-gui + CFBundleIconFile + icon + CFBundleIdentifier + __IDENTIFIER__ + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + __APP_NAME__ + CFBundlePackageType + APPL + CFBundleShortVersionString + __VERSION__ + CFBundleVersion + __VERSION__ + LSMinimumSystemVersion + 10.13 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/packaging/macos/README-unsigned.txt b/packaging/macos/README-unsigned.txt new file mode 100644 index 0000000..197faba --- /dev/null +++ b/packaging/macos/README-unsigned.txt @@ -0,0 +1,48 @@ +DSO3D12 GUI — macOS Unsigned App Instructions + +This package contains an unsigned macOS application bundle (.app). +The application has NOT been signed with an Apple Developer Certificate. + +=== Installing the Application === + +1. Extract DSO3D12.app from the zip archive (or mount the DMG) +2. Drag DSO3D12.app to your Applications folder + +=== Running the Application for the First Time === + +macOS will warn about the unsigned application. To allow it to run: + +METHOD 1: Right-click in Finder (Recommended) + 1. Open Finder and go to Applications + 2. Right-click on DSO3D12.app + 3. Click "Open" (not just double-click) + 4. Click "Open" in the confirmation dialog + +METHOD 2: Command Line (Terminal) + 1. Open Terminal + 2. Run: xattr -d com.apple.quarantine /Applications/DSO3D12.app + 3. Then double-click the app normally + +METHOD 3: System Settings + 1. Open System Settings → Privacy & Security + 2. Scroll down to look for "DSO3D12.app was blocked..." + 3. Click "Open Anyway" + 4. Double-click the app + +=== Running from Terminal === + +You can also run the application from the terminal: + /Applications/DSO3D12.app/Contents/MacOS/dso3d12-gui + +=== Troubleshooting === + +If the app won't open: +- Ensure you've allowed it through System Settings +- Try the xattr command from METHOD 2 above +- Restart your Mac + +If the serial port is not detected: +- Check that the oscilloscope is connected via USB +- Verify the USB-serial driver is installed (CH340/CP210x) + +For more information, see the main README.md file. diff --git a/packaging/macos/create_app_bundle.sh b/packaging/macos/create_app_bundle.sh new file mode 100755 index 0000000..47b8944 --- /dev/null +++ b/packaging/macos/create_app_bundle.sh @@ -0,0 +1,137 @@ +#!/bin/bash + +# Script to create a macOS .app bundle for DSO3D12 GUI. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +CREATE_DMG=false +APP_NAME="DSO3D12" +BUNDLE_NAME="DSO3D12.app" +DMG_NAME="DSO3D12.dmg" +IDENTIFIER="com.dso3d12.gui" +VERSION="0.1.0" +ASSETS_DIR="$SCRIPT_DIR" +ICON_SOURCE="$PROJECT_ROOT/gui/img/dso3d12_ico.png" +OUTPUT_DIR="$PROJECT_ROOT" +BINARY_PATH="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dmg) + CREATE_DMG=true + shift + ;; + --binary-path) + BINARY_PATH="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --assets-dir) + ASSETS_DIR="$2" + shift 2 + ;; + --icon) + ICON_SOURCE="$2" + shift 2 + ;; + --version) + VERSION="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +if [[ -z "$BINARY_PATH" ]]; then + BINARY_PATH="$PROJECT_ROOT/target/release/dso3d12-gui" +fi + +INFO_TEMPLATE="$ASSETS_DIR/Info.plist.template" +APP_DIR="$OUTPUT_DIR/$BUNDLE_NAME" +DMG_PATH="$OUTPUT_DIR/$DMG_NAME" + +if [[ ! -f "$BINARY_PATH" ]]; then + echo "Binary not found: $BINARY_PATH" >&2 + exit 1 +fi + +if [[ ! -f "$INFO_TEMPLATE" ]]; then + echo "Info.plist template not found: $INFO_TEMPLATE" >&2 + exit 1 +fi + +if [[ ! -f "$ICON_SOURCE" ]]; then + echo "Icon source not found: $ICON_SOURCE" >&2 + exit 1 +fi + +mkdir -p "$OUTPUT_DIR" + +echo "Creating macOS app bundle: $APP_DIR" + +rm -rf "$APP_DIR" +mkdir -p "$APP_DIR/Contents/MacOS" +mkdir -p "$APP_DIR/Contents/Resources" + +echo "Copying binary..." +cp "$BINARY_PATH" "$APP_DIR/Contents/MacOS/dso3d12-gui" +chmod +x "$APP_DIR/Contents/MacOS/dso3d12-gui" + +echo "Converting icon to PNG (512px)..." +sips -s format png "$ICON_SOURCE" --out "$APP_DIR/Contents/Resources/icon.png" --resampleWidth 512 > /dev/null 2>&1 + +echo "Creating icon set..." +ICONSET="$APP_DIR/Contents/Resources/icon.iconset" +mkdir -p "$ICONSET" + +sips -z 16 16 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_16x16.png" > /dev/null 2>&1 +sips -z 32 32 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_16x16@2x.png" > /dev/null 2>&1 +sips -z 32 32 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_32x32.png" > /dev/null 2>&1 +sips -z 64 64 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_32x32@2x.png" > /dev/null 2>&1 +sips -z 128 128 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_128x128.png" > /dev/null 2>&1 +sips -z 256 256 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_128x128@2x.png" > /dev/null 2>&1 +sips -z 256 256 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_256x256.png" > /dev/null 2>&1 +sips -z 512 512 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_256x256@2x.png" > /dev/null 2>&1 +sips -z 512 512 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_512x512.png" > /dev/null 2>&1 +cp "$APP_DIR/Contents/Resources/icon.png" "$ICONSET/icon_512x512@2x.png" + +echo "Converting to .icns format..." +iconutil -c icns "$ICONSET" -o "$APP_DIR/Contents/Resources/icon.icns" + +rm -rf "$ICONSET" +rm "$APP_DIR/Contents/Resources/icon.png" + +echo "Creating Info.plist..." +sed \ + -e "s|__APP_NAME__|$APP_NAME|g" \ + -e "s|__IDENTIFIER__|$IDENTIFIER|g" \ + -e "s|__VERSION__|$VERSION|g" \ + "$INFO_TEMPLATE" > "$APP_DIR/Contents/Info.plist" + +echo "" +echo "App bundle created successfully: $APP_DIR" +echo "" + +if [[ "$CREATE_DMG" == true ]]; then + echo "Creating DMG disk image..." + rm -f "$DMG_PATH" + hdiutil create -volname "$APP_NAME" -srcfolder "$APP_DIR" -ov -format UDZO "$DMG_PATH" + echo "" + echo "DMG created: $DMG_PATH" + echo "" +else + echo "To run the app:" + echo " open $APP_DIR" + echo "" + echo "To create a DMG for distribution:" + echo " ./create_app_bundle.sh --dmg" + echo "" +fi diff --git a/packaging/windows/README.txt b/packaging/windows/README.txt new file mode 100644 index 0000000..7782e5d --- /dev/null +++ b/packaging/windows/README.txt @@ -0,0 +1,5 @@ +Windows packaging notes + +- Archives are produced as zip files containing the `.exe` and supporting files. +- Extract the full archive before launching the application. +- You may need to install USB serial drivers for the oscilloscope connection. From d79c7e46af890df74f03e79086ec32c7556dd02d Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Sun, 31 May 2026 19:42:01 +0300 Subject: [PATCH 04/23] cicd: fixed bundle generation --- .github/workflows/release.yml | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b16d4e8..b31d34d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: build and release on: release: - types: [created] + types: [created, published] workflow_dispatch: permissions: @@ -39,7 +39,7 @@ jobs: capture_binary: capture.exe - label: macos arch: x86_64 - runner: macos-13 + runner: macos-latest target: x86_64-apple-darwin cli_archive: wm-tools-cli-macos-x86_64.zip gui_archive: dso3d12-gui-macos-x86_64.zip @@ -57,7 +57,7 @@ jobs: capture_binary: capture steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: @@ -183,6 +183,10 @@ jobs: set -euo pipefail shasum -a 256 "dist/${{ matrix.platform.cli_archive }}" | awk '{print $1}' > "dist/${{ matrix.platform.cli_archive }}.sha256" shasum -a 256 "dist/${{ matrix.platform.gui_archive }}" | awk '{print $1}' > "dist/${{ matrix.platform.gui_archive }}.sha256" + # Bundle CLI zip + sha256 + cd dist && zip "${{ matrix.platform.cli_archive }}-bundle.zip" "${{ matrix.platform.cli_archive }}" "${{ matrix.platform.cli_archive }}.sha256" + # Bundle GUI zip + sha256 + cd dist && zip "${{ matrix.platform.gui_archive }}-bundle.zip" "${{ matrix.platform.gui_archive }}" "${{ matrix.platform.gui_archive }}.sha256" if [[ "${{ runner.os }}" == "macOS" ]]; then shasum -a 256 dist/DSO3D12.dmg | awk '{print $1}' > dist/DSO3D12.dmg.sha256 fi @@ -193,6 +197,10 @@ jobs: run: | (Get-FileHash "dist/${{ matrix.platform.cli_archive }}" -Algorithm SHA256).Hash.ToLower() | Set-Content "dist/${{ matrix.platform.cli_archive }}.sha256" (Get-FileHash "dist/${{ matrix.platform.gui_archive }}" -Algorithm SHA256).Hash.ToLower() | Set-Content "dist/${{ matrix.platform.gui_archive }}.sha256" + # Bundle CLI zip + sha256 + Compress-Archive -Path "dist/${{ matrix.platform.cli_archive }}","dist/${{ matrix.platform.cli_archive }}.sha256" -DestinationPath "dist/${{ matrix.platform.cli_archive }}-bundle.zip" -Force + # Bundle GUI zip + sha256 + Compress-Archive -Path "dist/${{ matrix.platform.gui_archive }}","dist/${{ matrix.platform.gui_archive }}.sha256" -DestinationPath "dist/${{ matrix.platform.gui_archive }}-bundle.zip" -Force # --- Upload --- @@ -201,20 +209,16 @@ jobs: uses: softprops/action-gh-release@v2 with: files: | - dist/${{ matrix.platform.cli_archive }} - dist/${{ matrix.platform.cli_archive }}.sha256 - dist/${{ matrix.platform.gui_archive }} - dist/${{ matrix.platform.gui_archive }}.sha256 + dist/${{ matrix.platform.cli_archive }}-bundle.zip + dist/${{ matrix.platform.gui_archive }}-bundle.zip - name: Release macOS artifacts if: github.event_name == 'release' && runner.os == 'macOS' uses: softprops/action-gh-release@v2 with: files: | - dist/${{ matrix.platform.cli_archive }} - dist/${{ matrix.platform.cli_archive }}.sha256 - dist/${{ matrix.platform.gui_archive }} - dist/${{ matrix.platform.gui_archive }}.sha256 + dist/${{ matrix.platform.cli_archive }}-bundle.zip + dist/${{ matrix.platform.gui_archive }}-bundle.zip dist/DSO3D12.dmg dist/DSO3D12.dmg.sha256 dist/README-unsigned.txt From c221d092b3cb55d9ac7a99b30c4fc8d5043fc077 Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Sun, 31 May 2026 20:04:34 +0300 Subject: [PATCH 05/23] cicd fix. And embedded an icon --- .github/workflows/release.yml | 12 +- Cargo.lock | 639 ++++++++++++++++++++++++++++++++++ gui/Cargo.toml | 2 +- gui/src/main.rs | 11 + 4 files changed, 657 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b31d34d..7b5c455 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -183,12 +183,12 @@ jobs: set -euo pipefail shasum -a 256 "dist/${{ matrix.platform.cli_archive }}" | awk '{print $1}' > "dist/${{ matrix.platform.cli_archive }}.sha256" shasum -a 256 "dist/${{ matrix.platform.gui_archive }}" | awk '{print $1}' > "dist/${{ matrix.platform.gui_archive }}.sha256" - # Bundle CLI zip + sha256 - cd dist && zip "${{ matrix.platform.cli_archive }}-bundle.zip" "${{ matrix.platform.cli_archive }}" "${{ matrix.platform.cli_archive }}.sha256" - # Bundle GUI zip + sha256 - cd dist && zip "${{ matrix.platform.gui_archive }}-bundle.zip" "${{ matrix.platform.gui_archive }}" "${{ matrix.platform.gui_archive }}.sha256" + # Bundle CLI zip + sha256 into a single archive + (cd dist && zip "${{ matrix.platform.cli_archive }}-bundle.zip" "${{ matrix.platform.cli_archive }}" "${{ matrix.platform.cli_archive }}.sha256") + # Bundle GUI zip + sha256 into a single archive + (cd dist && zip "${{ matrix.platform.gui_archive }}-bundle.zip" "${{ matrix.platform.gui_archive }}" "${{ matrix.platform.gui_archive }}.sha256") if [[ "${{ runner.os }}" == "macOS" ]]; then - shasum -a 256 dist/DSO3D12.dmg | awk '{print $1}' > dist/DSO3D12.dmg.sha256 + shasum -a 256 "dist/DSO3D12.dmg" | awk '{print $1}' > "dist/DSO3D12.dmg.sha256" fi - name: Generate checksums (Windows) @@ -225,7 +225,7 @@ jobs: - name: Upload artifacts (manual run) if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: packages-${{ matrix.platform.label }}-${{ matrix.platform.arch }} path: | diff --git a/Cargo.lock b/Cargo.lock index 42b5309..74996f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -31,6 +37,24 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "android-activity" version = "0.6.1" @@ -130,6 +154,23 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -148,6 +189,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -344,6 +394,49 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -359,6 +452,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -371,6 +470,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "block" version = "0.1.6" @@ -408,6 +516,12 @@ dependencies = [ "piper", ] +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.20.3" @@ -440,6 +554,12 @@ dependencies = [ "syn", ] +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -652,6 +772,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -807,6 +933,34 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -987,6 +1141,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1020,6 +1180,26 @@ dependencies = [ "syn", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1082,18 +1262,58 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "float_next_after" version = "1.0.0" @@ -1342,6 +1562,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -1551,6 +1781,7 @@ dependencies = [ "iced_runtime", "iced_widget", "iced_winit", + "image", "thiserror 2.0.18", ] @@ -1649,6 +1880,8 @@ dependencies = [ "half", "iced_core", "iced_futures", + "image", + "kamadak-exif", "log", "lyon_path", "raw-window-handle", @@ -1873,6 +2106,46 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" + [[package]] name = "indexmap" version = "2.14.0" @@ -1898,6 +2171,17 @@ dependencies = [ "web-time", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "io-kit-sys" version = "0.4.1" @@ -1933,6 +2217,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2019,6 +2312,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -2052,12 +2354,28 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.8.9" @@ -2160,6 +2478,15 @@ version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru" version = "0.16.4" @@ -2236,6 +2563,16 @@ dependencies = [ "libc", ] +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.8.1" @@ -2275,6 +2612,26 @@ dependencies = [ "paste", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "mundy" version = "0.2.3" @@ -2300,6 +2657,12 @@ dependencies = [ "zbus", ] +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + [[package]] name = "naga" version = "27.0.3" @@ -2356,6 +2719,12 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.26.4" @@ -2367,6 +2736,51 @@ dependencies = [ "libc", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "num-format" version = "0.4.4" @@ -2377,6 +2791,26 @@ dependencies = [ "itoa", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2881,6 +3315,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -2948,6 +3388,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -3062,6 +3515,40 @@ name = "profiling" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" @@ -3134,12 +3621,82 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "read-fonts" version = "0.35.0" @@ -3229,6 +3786,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "roxmltree" version = "0.20.0" @@ -3429,6 +3992,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -3439,6 +4008,15 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -3765,6 +4343,20 @@ dependencies = [ "syn", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -4018,6 +4610,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -4316,6 +4919,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "wgpu" version = "27.0.1" @@ -4993,6 +5602,12 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yazi" version = "0.2.1" @@ -5169,6 +5784,30 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.12.0" diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 084746b..9ceb8a2 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -15,7 +15,7 @@ dso-parser = { path = "../dso-parser" } anyhow = { workspace = true } log = { workspace = true, features = ["std"] } thiserror = { workspace = true } -iced = { version = "0.14", features = ["canvas", "tokio", "advanced"] } +iced = { version = "0.14", features = ["canvas", "tokio", "advanced", "image"] } iced_aw = { version = "0.14", features = ["context_menu"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/gui/src/main.rs b/gui/src/main.rs index 7f34e47..195f626 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -7,14 +7,25 @@ mod serial; mod settings; use app::App; +use iced::window; fn main() -> iced::Result { logging::init(); log::info!("starting dso3d12-gui"); + let icon = window::icon::from_file_data( + include_bytes!("../img/dso3d12_ico.png"), + None, + ) + .ok(); + iced::application(App::new, App::update, App::view) .title(App::title) .theme(App::theme) .subscription(App::subscription) + .window(window::Settings { + icon, + ..Default::default() + }) .run() } From dd4bd4e88c5c52551a6d61ebaf6eed43decd2296 Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Sun, 31 May 2026 20:24:09 +0300 Subject: [PATCH 06/23] GUI: added sounds for Linux and Windows --- gui/src/app.rs | 80 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 22 deletions(-) diff --git a/gui/src/app.rs b/gui/src/app.rs index 30fd2b2..0e753e5 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -1802,33 +1802,69 @@ async fn load_dump(path: PathBuf) -> Result { /// Play a short "start receiving" beep sound. fn play_beep_start() { #[cfg(target_os = "macos")] - { - std::thread::spawn(|| { - let _ = std::process::Command::new("afplay") - .arg("/System/Library/Sounds/Tink.aiff") - .output(); - }); - } - #[cfg(not(target_os = "macos"))] - { - print!("\x07"); - } + std::thread::spawn(|| { + let _ = std::process::Command::new("afplay") + .arg("/System/Library/Sounds/Tink.aiff") + .output(); + }); + + #[cfg(target_os = "linux")] + std::thread::spawn(|| { + // Try paplay (PulseAudio/PipeWire) with freedesktop sound theme. + let ok = std::process::Command::new("paplay") + .arg("/usr/share/sounds/freedesktop/stereo/bell.oga") + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !ok { + print!("\x07"); + } + }); + + #[cfg(target_os = "windows")] + std::thread::spawn(|| { + // Asterisk is the standard Windows "notification" system sound. + let _ = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", "[System.Media.SystemSounds]::Asterisk.Play()"]) + .output(); + }); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + print!("\x07"); } /// Play a "capture complete" beep sound (different from start). fn play_beep_end() { #[cfg(target_os = "macos")] - { - std::thread::spawn(|| { - let _ = std::process::Command::new("afplay") - .arg("/System/Library/Sounds/Glass.aiff") - .output(); - }); - } - #[cfg(not(target_os = "macos"))] - { - print!("\x07"); - } + std::thread::spawn(|| { + let _ = std::process::Command::new("afplay") + .arg("/System/Library/Sounds/Glass.aiff") + .output(); + }); + + #[cfg(target_os = "linux")] + std::thread::spawn(|| { + // "complete" is the standard task-done sound in the freedesktop theme. + let ok = std::process::Command::new("paplay") + .arg("/usr/share/sounds/freedesktop/stereo/complete.oga") + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !ok { + print!("\x07"); + } + }); + + #[cfg(target_os = "windows")] + std::thread::spawn(|| { + // Exclamation is the standard Windows "action complete" system sound. + let _ = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", "[System.Media.SystemSounds]::Exclamation.Play()"]) + .output(); + }); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + print!("\x07"); } // ── File save/load helpers ───────────────────────────────────────────────── From 41a0a5515ba27d7be47ecdfdc73f77337b67afd4 Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Sun, 7 Jun 2026 12:36:58 +0300 Subject: [PATCH 07/23] GUI: added undelete for removed snapshots --- gui/src/app.rs | 151 ++++++++++++++++++++++++++++++++++++-------- gui/src/main.rs | 4 ++ gui/src/settings.rs | 8 +++ 3 files changed, 136 insertions(+), 27 deletions(-) diff --git a/gui/src/app.rs b/gui/src/app.rs index 0e753e5..2dfcae5 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -76,10 +76,14 @@ pub enum Message { FirmwareEvent(#[allow(dead_code)] crate::flash::FlashEvent), // Port scan tick (auto-refresh) PortScanTick, + // Window resized + WindowResized(f32, f32), // Open a URL in the default browser OpenUrl(String), // Context menu on thumbnails (right-click via iced_aw::ContextMenu) ContextMenuDeleteAt(usize), + UndoDelete, + UndoExpiredTick, ContextMenuExportPngAt(usize), ContextMenuExportPngPicked(Option), ContextMenuExportCsvAt(usize), @@ -135,6 +139,10 @@ pub struct App { was_listening_before_flash: bool, /// Target capture index for context menu export operations. context_export_idx: Option, + /// Last deleted capture, available for undo (index it was at, entry). + deleted_capture: Option<(usize, CaptureEntry)>, + /// Timestamp of the last deletion, for 30-second undo expiry. + deleted_at: Option, /// Text input state for V/div scale. v_per_div_input: String, /// Text input state for t/div scale. @@ -181,6 +189,8 @@ impl App { firmware_confirming: false, was_listening_before_flash: false, context_export_idx: None, + deleted_capture: None, + deleted_at: None, v_per_div_input: v_per_div_str, t_per_div_input: t_per_div_str, v_offset_input: v_offset_str, @@ -251,6 +261,12 @@ impl App { .map(|_| Message::PortScanTick), ); + // Track window resize to persist size + subs.push( + iced::window::resize_events() + .map(|(_id, size)| Message::WindowResized(size.width, size.height)), + ); + // Poll firmware events if self.firmware_rx.is_some() { subs.push( @@ -259,6 +275,14 @@ impl App { ); } + // Expire undo buffer after 30 seconds + if self.deleted_capture.is_some() { + subs.push( + iced::time::every(std::time::Duration::from_secs(1)) + .map(|_| Message::UndoExpiredTick), + ); + } + Subscription::batch(subs) } @@ -272,6 +296,8 @@ impl App { Message::Loaded(r.map_err(|e| e.to_string())) }), Message::Loaded(Ok(d)) => { + self.deleted_capture = None; + self.deleted_at = None; self.status = format!( "Loaded {} capture{} from {}", d.captures.len(), @@ -445,6 +471,8 @@ impl App { self.current = 0; } self.capture_progress = 0; + self.deleted_capture = None; + self.deleted_at = None; self.status = format!( "Received {count} capture(s). Total: {}", self.dump.as_ref().map(|d| d.captures.len()).unwrap_or(0) @@ -484,6 +512,8 @@ impl App { self.current = 0; } self.capture_progress = 0; + self.deleted_capture = None; + self.deleted_at = None; self.status = format!( "Screenshot received ({}/div, {}/div). Total: {}", dso_parser::format_uv( @@ -673,6 +703,11 @@ impl App { } Task::none() } + Message::WindowResized(w, h) => { + self.settings.window_size = (w, h); + self.settings.save(); + Task::none() + } Message::OpenUrl(url) => { let _ = open::that(url); Task::none() @@ -680,11 +715,40 @@ impl App { Message::ContextMenuDeleteAt(idx) => { if let Some(ref mut dump) = self.dump { if idx < dump.captures.len() { - dump.captures.remove(idx); + let entry = dump.captures.remove(idx); + self.deleted_capture = Some((idx, entry)); + self.deleted_at = Some(std::time::Instant::now()); if self.current >= dump.captures.len() && self.current > 0 { self.current -= 1; } - self.status = format!("Deleted capture #{}", idx + 1); + self.status = format!("Deleted capture #{}. Press Undo to restore.", idx + 1); + } + } + Task::none() + } + Message::UndoDelete => { + self.deleted_at = None; + if let Some((idx, entry)) = self.deleted_capture.take() { + let dump = self.dump.get_or_insert_with(|| LoadedDump { + path: PathBuf::from(""), + captures: vec![], + }); + let insert_at = idx.min(dump.captures.len()); + dump.captures.insert(insert_at, entry); + self.current = insert_at; + self.status = format!("Restored capture #{}.", insert_at + 1); + } + Task::none() + } + Message::UndoExpiredTick => { + if let Some(at) = self.deleted_at { + if at.elapsed().as_secs() >= 30 { + self.deleted_capture = None; + self.deleted_at = None; + // Clear the status hint only if it still mentions undo + if self.status.contains("Undo") { + self.status = "Undo expired.".to_string(); + } } } Task::none() @@ -878,7 +942,7 @@ impl App { .height(Length::FillPortion(1)) .into(); let controls = self.controls_panel(); - let thumbs = thumbnails(&self.dump, self.current); + let thumbs = thumbnails(&self.dump, self.current, self.deleted_capture.is_some()); let actions = self.action_buttons(); let instructions: Element<'_, Message> = if self.settings.show_instructions { @@ -1117,8 +1181,7 @@ impl App { .placeholder("Port…") .text_size(12); - container( - row![ + let status_row = row![ text("DSO3D12").size(14), Space::new().width(Length::Fixed(12.0)), port_picker, @@ -1128,8 +1191,9 @@ impl App { text(self.status.clone()).size(12), ] .spacing(6) - .align_y(iced::Alignment::Center), - ) + .align_y(iced::Alignment::Center); + + container(status_row) .padding(4) .width(Length::Fill) .style(container::rounded_box) @@ -1641,13 +1705,25 @@ fn samples_to_time_ms(n: usize) -> f64 { /// Thumbnails strip: scrollable row of mini waveform canvases. /// Each thumbnail shows the signal shape and is clickable. -fn thumbnails<'a>(dump: &'a Option, current: usize) -> Element<'a, Message> { - let Some(dump) = dump else { - return Space::new().height(Length::Fixed(0.0)).into(); - }; - if dump.captures.is_empty() { +fn thumbnails<'a>(dump: &'a Option, current: usize, can_undo: bool) -> Element<'a, Message> { + let is_empty = dump.as_ref().map_or(true, |d| d.captures.is_empty()); + if is_empty { + if can_undo { + return container( + button(text("Undo Delete").size(12)) + .on_press(Message::UndoDelete) + .style(button::secondary), + ) + .height(Length::Fixed(62.0)) + .width(Length::Fill) + .align_x(iced::Alignment::Start) + .align_y(iced::Alignment::Center) + .padding(4) + .into(); + } return Space::new().height(Length::Fixed(0.0)).into(); } + let dump = dump.as_ref().unwrap(); let row_items = dump.captures.iter().enumerate().map(|(i, entry)| { let thumb = Thumbnail { capture: &entry.capture, @@ -1672,27 +1748,48 @@ fn thumbnails<'a>(dump: &'a Option, current: usize) -> Element<'a, M let tooltip_ts = entry.timestamp.format("%Y-%m-%d %H:%M:%S").to_string(); let underlay: Element<'a, Message> = iced::widget::tooltip( btn, - text(tooltip_ts).size(11), + text(tooltip_ts.clone()).size(11), iced::widget::tooltip::Position::Top, ) .into(); let ctx_idx = i; + let menu_ts = tooltip_ts.clone(); iced_aw::ContextMenu::new(underlay, move || { - iced::widget::column![ - button(text("Export PNG…").size(12)) - .on_press(Message::ContextMenuExportPngAt(ctx_idx)) - .width(Length::Fill), - button(text("Export CSV…").size(12)) - .on_press(Message::ContextMenuExportCsvAt(ctx_idx)) - .width(Length::Fill), - button(text("Delete").size(12)) - .on_press(Message::ContextMenuDeleteAt(ctx_idx)) - .width(Length::Fill), - ] - .spacing(2) - .padding(4) - .width(Length::Fixed(140.0)) + container( + iced::widget::column![ + text(menu_ts.clone()).size(10), + rule::horizontal(1), + button(text("Export PNG…").size(12)) + .on_press(Message::ContextMenuExportPngAt(ctx_idx)) + .width(Length::Fill) + .style(button::text), + button(text("Export CSV…").size(12)) + .on_press(Message::ContextMenuExportCsvAt(ctx_idx)) + .width(Length::Fill) + .style(button::text), + rule::horizontal(1), + button(text("Delete").size(12)) + .on_press(Message::ContextMenuDeleteAt(ctx_idx)) + .width(Length::Fill) + .style(button::danger), + rule::horizontal(1), + { + let undo_btn = button(text("Undo Delete").size(12)) + .width(Length::Fill) + .style(button::text); + if can_undo { + undo_btn.on_press(Message::UndoDelete) + } else { + undo_btn + } + }, + ] + .spacing(1) + .padding(4) + .width(Length::Fixed(160.0)), + ) + .style(container::bordered_box) .into() }) .into() diff --git a/gui/src/main.rs b/gui/src/main.rs index 195f626..8a6f0b4 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -19,12 +19,16 @@ fn main() -> iced::Result { ) .ok(); + let settings = crate::settings::Settings::load(); + let (w, h) = settings.window_size; + iced::application(App::new, App::update, App::view) .title(App::title) .theme(App::theme) .subscription(App::subscription) .window(window::Settings { icon, + size: iced::Size::new(w, h), ..Default::default() }) .run() diff --git a/gui/src/settings.rs b/gui/src/settings.rs index 7015ebb..a98156a 100644 --- a/gui/src/settings.rs +++ b/gui/src/settings.rs @@ -40,6 +40,9 @@ pub struct Settings { /// Voltage offset (zero-level of graph) in volts. Default 0.0 V. #[serde(default)] pub v_offset: f64, + /// Last window size (width, height) in logical pixels. + #[serde(default = "default_window_size")] + pub window_size: (f32, f32), } fn default_v_per_div() -> f64 { @@ -50,6 +53,10 @@ fn default_t_per_div_ms() -> f64 { 1.0 } +fn default_window_size() -> (f32, f32) { + (800.0, 600.0) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ViewMode { Line, @@ -93,6 +100,7 @@ impl Default for Settings { v_per_div: 1.0, t_per_div_ms: 1.0, v_offset: 0.0, + window_size: default_window_size(), } } } From bda1396f331ea8d5247852a06209ddfe482a5065 Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Thu, 18 Jun 2026 12:13:43 +0300 Subject: [PATCH 08/23] GUI: fixed graphs representation. Fixed V/div selection --- docs/GUI_DESIGN.md | 21 ++- docs/USER_MANUAL.md | 31 ++-- dso-parser/src/container.rs | 50 +++++- dso-parser/src/lib.rs | 36 ++-- gui/src/app.rs | 321 +++++++++++++++++++++++------------- gui/src/canvas.rs | 42 +++-- gui/src/settings.rs | 20 ++- 7 files changed, 342 insertions(+), 179 deletions(-) diff --git a/docs/GUI_DESIGN.md b/docs/GUI_DESIGN.md index c0f01e7..8c3e212 100644 --- a/docs/GUI_DESIGN.md +++ b/docs/GUI_DESIGN.md @@ -64,16 +64,16 @@ yellow CH1, cyan CH2. Renders: ### 3. Measurement panel (right, 220 px) Vertical column inside a rounded container. -**Scale configuration** (always visible): -* **V/div** text input — volts per division (default 1.0 V). With "V" unit. -* **t/div** text input — time per division in ms (default 1.0 ms). With "ms" unit. -* **V/off** text input — voltage offset at graph center (default 0.0 V). With "V" unit. +**Scale configuration** (always visible, two-column layout with CH1/CH2 headers): +* **V/div** text inputs — volts per division per channel (default 1.0 V each). With "V" unit. +* **V/off** text inputs — voltage offset at graph center per channel (default 0.0 V each). With "V" unit. +* **t/div** text input — time per division in ms (shared, default 1.0 ms). With "ms" unit. * Label: "AC mode: 8×V, 12×t divs" — documents the scope grid geometry. These values drive all voltage/time calculations throughout the panel and graph scales. The oscilloscope screen has 8 vertical and 12 horizontal divs. -When a capture arrives via the screenshot protocol, V/div, t/div, and V/offset -are **automatically filled** from the packet metadata. +When a capture arrives via the screenshot protocol, V/div and V/offset are +**automatically filled** from CH1/CH2 packet metadata separately. **Signal information** (shown when a capture is loaded): * Voltage range for CH1/CH2 (computed from V/div × 8 divs, centered at 0). @@ -85,7 +85,8 @@ user drags whichever knob is nearer. Below each slider the panel shows cursor measurements: * X cursors → Δt in ms + frequency in Hz (using real t/div scale). -* Y cursors → ΔV in volts + absolute voltage pair (using real V/div scale). +* Y cursors → ΔV in volts + absolute voltage pair per channel (using each + channel's V/div and V/offset scale independently). A divider, then per-channel basic stats (`n`, `min`, `max`, `avg`). @@ -198,6 +199,8 @@ external image crate needed). Filename is auto-generated as "view_mode": "Line", "cursor_x_range": [0.3, 0.7], "cursor_y_range": [0.3, 0.7], + "v_per_div_ch1": 1.0, "v_per_div_ch2": 1.0, + "v_offset_ch1": 0.0, "v_offset_ch2": 0.0, "show_scales": false, "alert_on_data": true, "auto_listen": false, @@ -217,7 +220,9 @@ Default level: info; override with `RUST_LOG`. ## Voltage & time assumptions -* ADC 0..255 mapped to ±5 V (0 = −5 V, 128 = 0 V, 255 ≈ +5 V) for raw debug dumps. +* ADC 0..255 uses screen-coordinate convention (0 = top of screen ≈ +5 V, + 127/128 = 0 V, 255 = bottom of screen ≈ −5 V). This is consistent across + both debug-dump and screenshot data paths. * Scale labels are derived from V/div and V/offset settings: - Top of graph = V/offset + 4×V/div - Bottom of graph = V/offset − 4×V/div diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 746ffa7..8781933 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -70,19 +70,24 @@ saved from the device. Files can contain one or many consecutive captures. ## Scale configuration -At the top of the **Measurement** panel you can set the real-world scale: +At the top of the **Measurement** panel you can set the real-world scale. +**V/div** and **V/off** are per-channel (CH1 / CH2 columns), while **t/div** +is shared (same timebase for both channels): -| Field | Default | Meaning | -|----------|---------|-------------------------------------------------| -| V/div | 1.0 V | Volts per vertical division on the scope screen | -| t/div | 1.0 ms | Time per horizontal division | -| V/off | 0.0 V | Voltage at graph center (offset) | +| Field | Default | Meaning | +|----------|---------|-----------------------------------------------------------| +| V/div | 1.0 V | Volts per vertical division on the scope screen (per CH) | +| t/div | 1.0 ms | Time per horizontal division (shared) | +| V/off | 0.0 V | Voltage at graph center (offset, per CH) | -The DSO3D12 screen has **8 vertical** and **12 horizontal** divisions (AC mode). -All voltage and time calculations, axis labels, and PNG scales use these values. +CH1 and CH2 can have independent V/div and V/offset settings (just like +the real scope). The DSO3D12 screen has **8 vertical** and **12 horizontal** +divisions (AC mode). All voltage and time calculations, axis labels, and PNG +scales use the per-channel values. -When a capture arrives via the **screenshot protocol**, V/div, t/div, and V/offset -are automatically filled from the packet's embedded settings metadata. +When a capture arrives via the **screenshot protocol**, V/div and V/offset +are automatically filled for each channel from the packet's embedded settings +metadata (CH1 and CH2 separately). ## Measurement cursors @@ -91,16 +96,18 @@ In the right-hand **Measurement** panel: | Toggle | Slider | Readings | |------------------|----------------------------|-----------------------------------| | ☐ Cursor X (time) | Two knobs on horizontal range | Δt (ms), frequency (Hz) | -| ☐ Cursor Y (voltage) | Two knobs on vertical range | ΔV (volts), absolute voltages | +| ☐ Cursor Y (voltage) | Two knobs on vertical range | ΔV (volts) + absolute voltages per channel (CH1 and CH2) | **Drag the knob closest to your click** in the slider — or **click/drag directly on the graph** to move the nearest cursor line. The shaded band between cursors is drawn on the canvas in pink and updates in real time. +Y cursor voltages are computed independently for CH1 and CH2 using each +channel's V/div and V/offset settings. ### Signal information When a capture is loaded the panel also shows: -* Voltage range for each channel (derived from V/div setting). +* Voltage range for each channel (derived from each channel's V/div setting). * Total time range (derived from t/div setting) + sample count. * Per-channel statistics (sample count, min, max, average ADC). diff --git a/dso-parser/src/container.rs b/dso-parser/src/container.rs index fb5fa52..b34d94d 100644 --- a/dso-parser/src/container.rs +++ b/dso-parser/src/container.rs @@ -16,7 +16,11 @@ pub const ZWCAP_MAGIC: &[u8; 8] = b"ZWCAP\x00\x01\x00"; /// Top-level capture record stored in the container. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CaptureRecord { - /// Schema version (currently 1). + /// Schema version. + /// - **1**: legacy — CH1 from ASCII dumps stored in raw-ADC convention + /// (high value = top of screen). + /// - **2**: current — all channels use screen-coordinate convention + /// (low value = top of screen = positive voltage). pub schema_version: u16, /// How the capture was originally acquired. pub source_format: SourceFormat, @@ -175,7 +179,7 @@ pub fn capture_to_record( }); } CaptureRecord { - schema_version: 1, + schema_version: 2, source_format: SourceFormat::HiddenAsciiDump, device_model: Some("DSO3D12".to_string()), captured_at: Some(timestamp.format("%Y-%m-%dT%H:%M:%S%:z").to_string()), @@ -316,7 +320,13 @@ fn measurements_to_state(m: &screenshot::Measurements, probe: u8) -> ChannelMeas /// Convert a `CaptureRecord` back to the legacy `Capture` struct for display. /// This allows the GUI to use a unified rendering pipeline. +/// +/// Schema version 1 (legacy) stored CH1 from ASCII dumps in raw-ADC convention +/// (high value = top of screen). Version 2+ stores both channels in screen- +/// coordinate convention (low value = top of screen = positive voltage). pub fn record_to_capture(rec: &CaptureRecord) -> Capture { + let old_ascii = rec.schema_version <= 1 + && rec.source_format == SourceFormat::HiddenAsciiDump; match rec.source_format { SourceFormat::HiddenAsciiDump => { let mut ch1 = Vec::new(); @@ -324,7 +334,12 @@ pub fn record_to_capture(rec: &CaptureRecord) -> Capture { for w in &rec.waveforms { match w { Waveform::RawSamples { channel: 1, counts, .. } => { - ch1 = counts.iter().map(|&c| c.clamp(0, 255) as u8).collect(); + let mut raw: Vec = counts.iter().map(|&c| c.clamp(0, 255) as u8).collect(); + // Old-format: CH1 stored in raw-ADC convention → invert to match. + if old_ascii { + raw.iter_mut().for_each(|v| *v = 255 - *v); + } + ch1 = raw; } Waveform::RawSamples { channel: 2, counts, .. } => { ch2 = Some(counts.iter().map(|&c| c.clamp(0, 255) as u8).collect()); @@ -387,7 +402,9 @@ mod tests { } #[test] - fn record_to_capture_roundtrip() { + fn record_to_capture_v1_backward_compat() { + // Old-format (schema_version == 1): CH1 stored in raw-ADC convention, + // CH2 already in screen-coordinate convention. let rec = CaptureRecord { schema_version: 1, source_format: SourceFormat::HiddenAsciiDump, @@ -401,10 +418,33 @@ mod tests { ], }; let cap = record_to_capture(&rec); - assert_eq!(cap.ch1, vec![10, 20, 30]); + // CH1 inverted for backward compat: 255-10=245, 255-20=235, 255-30=225 + assert_eq!(cap.ch1, vec![245, 235, 225]); + // CH2 was already in screen-coordinate convention assert_eq!(cap.ch2, Some(vec![40, 50, 60])); } + #[test] + fn record_to_capture_v2_roundtrip() { + // New format (schema_version == 2): both channels in screen-coordinate + // convention — no transformation needed on read-back. + let rec = CaptureRecord { + schema_version: 2, + source_format: SourceFormat::HiddenAsciiDump, + device_model: None, + captured_at: None, + raw_payload: vec![], + scope_state: None, + waveforms: vec![ + Waveform::RawSamples { channel: 1, counts: vec![245, 235, 225], sample_interval_s: None, volts_per_count: None }, + Waveform::RawSamples { channel: 2, counts: vec![215, 205, 195], sample_interval_s: None, volts_per_count: None }, + ], + }; + let cap = record_to_capture(&rec); + assert_eq!(cap.ch1, vec![245, 235, 225]); + assert_eq!(cap.ch2, Some(vec![215, 205, 195])); + } + #[test] fn is_zwcap_detection() { assert!(!is_zwcap(b"not a zwcap file")); diff --git a/dso-parser/src/lib.rs b/dso-parser/src/lib.rs index a2928c4..5bd1906 100644 --- a/dso-parser/src/lib.rs +++ b/dso-parser/src/lib.rs @@ -92,16 +92,18 @@ pub fn parse_captures(data: &[u8]) -> Result, ParseError> { // CH1 samples end either at the CH2 marker or at the next CH1 marker // (next capture) or at end of file. let ch1_end = find_next_boundary(data, ch1_body); - let ch1 = decode_ascii_samples(&data[ch1_body..ch1_end]); + // Invert CH1 so it uses the same screen-coordinate convention as + // screenshot data (low value = top of screen = positive voltage), + // matching what was already done for CH2 below. + let ch1: Vec = decode_ascii_samples(&data[ch1_body..ch1_end]) + .into_iter().map(|v| 255 - v).collect(); // Check whether the next boundary is a CH2 within the same capture. let mut next_cursor = ch1_end; let ch2 = if data[ch1_end..].starts_with(CH2_MARKER) { let ch2_body = skip_marker_eol(data, ch1_end + CH2_MARKER.len()); let ch2_end = find_next_boundary(data, ch2_body); - // The device sends CH2 ADC values with inverted polarity in the ASCII - // dump (a hardware quirk). Flip here so Capture.ch2 uses the same - // visual-screen-coordinate convention as CH1 and screenshot data: + // CH2 uses the same screen-coordinate convention as CH1 and screenshot data: // low value = near top of screen = positive voltage. let samples: Vec = decode_ascii_samples(&data[ch2_body..ch2_end]) .into_iter().map(|v| 255 - v).collect(); @@ -142,6 +144,11 @@ pub fn looks_like_dump_start(data: &[u8]) -> bool { /// Convert a raw ADC byte (0..=255) into a normalized signed value in `[-1, 1]`, /// matching the way the device places "screen center" at code 128. /// +/// **Note:** This function expects **raw ADC values** from the device, not the +/// screen-coordinate convention used by [`Capture`] (where 0 = top of screen). +/// `Capture.ch1` / `Capture.ch2` values are already in screen-coordinate +/// convention and should NOT be passed here directly. +/// /// This is the simplest scaling we can apply without device-side metadata /// (volt-scale, probe mode, zero offset). Callers that have access to the /// settings buffer can substitute their own conversion. @@ -355,7 +362,8 @@ mod tests { fn parses_single_ch1_capture() { let dump = make_dump(&[137, 138, 137, 139], None); let cap = parse_first(&dump).unwrap(); - assert_eq!(cap.ch1, vec![137, 138, 137, 139]); + // Both channels now use screen-coordinate convention (low = top). + assert_eq!(cap.ch1, vec![118, 117, 118, 116]); assert!(cap.ch2.is_none()); } @@ -363,9 +371,8 @@ mod tests { fn parses_dual_channel_capture() { let dump = make_dump(&[10, 20, 30], Some(&[40, 50, 60])); let cap = parse_first(&dump).unwrap(); - assert_eq!(cap.ch1, vec![10, 20, 30]); - // ASCII dump CH2 is inverted at parse time so it matches the - // screen-coordinate convention used by screenshot data (low value = top). + // Both channels now use screen-coordinate convention (low = top). + assert_eq!(cap.ch1, vec![245, 235, 225]); assert_eq!(cap.ch2.as_deref(), Some(&[215u8, 205, 195][..])); } @@ -375,9 +382,9 @@ mod tests { data.extend_from_slice(&make_dump(&[4, 5, 6], Some(&[7, 8]))); let caps = parse_captures(&data).unwrap(); assert_eq!(caps.len(), 2); - assert_eq!(caps[0].ch1, vec![1, 2, 3]); - assert_eq!(caps[1].ch1, vec![4, 5, 6]); - // CH2 is inverted at parse time: 255-7=248, 255-8=247 + // Both channels use screen-coordinate convention (low = top). + assert_eq!(caps[0].ch1, vec![254, 253, 252]); + assert_eq!(caps[1].ch1, vec![251, 250, 249]); assert_eq!(caps[1].ch2.as_deref(), Some(&[248u8, 247][..])); } @@ -394,7 +401,8 @@ mod tests { data.extend_from_slice(b"\r\n"); data.extend_from_slice(b"137 \x00garbage \x00200 \x00"); let cap = parse_first(&data).unwrap(); - assert_eq!(cap.ch1, vec![137, 200]); + // Inverted: 255-137=118, 255-200=55 + assert_eq!(cap.ch1, vec![118, 55]); } #[test] @@ -404,8 +412,8 @@ mod tests { data.extend_from_slice(b"\r\n"); data.extend_from_slice(b"300 \x00128 \x00"); let cap = parse_first(&data).unwrap(); - // 300 is out-of-range for u8 and gets dropped. - assert_eq!(cap.ch1, vec![128]); + // 300 is out-of-range for u8 and gets dropped. 128 inverted = 127. + assert_eq!(cap.ch1, vec![127]); } #[test] diff --git a/gui/src/app.rs b/gui/src/app.rs index 2dfcae5..02f2e1b 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -50,9 +50,11 @@ pub enum Message { MeasCursorXChanged(f32, f32), MeasCursorYChanged(f32, f32), // Scale inputs (V/div, time/div, V offset) - VPerCellChanged(String), + VPerCellChangedCh1(String), + VPerCellChangedCh2(String), TPerCellChanged(String), - VOffsetChanged(String), + VOffsetChangedCh1(String), + VOffsetChangedCh2(String), // Graph click — move nearest cursor GraphClicked(f32, f32), // Misc @@ -143,12 +145,16 @@ pub struct App { deleted_capture: Option<(usize, CaptureEntry)>, /// Timestamp of the last deletion, for 30-second undo expiry. deleted_at: Option, - /// Text input state for V/div scale. - v_per_div_input: String, + /// Text input state for V/div scale (CH1). + v_per_div_ch1_input: String, + /// Text input state for V/div scale (CH2). + v_per_div_ch2_input: String, /// Text input state for t/div scale. t_per_div_input: String, - /// Text input state for V/offset. - v_offset_input: String, + /// Text input state for V/offset (CH1). + v_offset_ch1_input: String, + /// Text input state for V/offset (CH2). + v_offset_ch2_input: String, } impl App { @@ -162,9 +168,11 @@ impl App { ); let baud_str = settings.baud_rate.to_string(); let port_str = settings.serial_port.clone().unwrap_or_default(); - let v_per_div_str = format_float(settings.v_per_div); + let v_per_div_ch1_str = format_float(settings.v_per_div_ch1); + let v_per_div_ch2_str = format_float(settings.v_per_div_ch2); let t_per_div_str = format_float(settings.t_per_div_ms); - let v_offset_str = format_float(settings.v_offset); + let v_offset_ch1_str = format_float(settings.v_offset_ch1); + let v_offset_ch2_str = format_float(settings.v_offset_ch2); ( Self { settings, @@ -191,9 +199,11 @@ impl App { context_export_idx: None, deleted_capture: None, deleted_at: None, - v_per_div_input: v_per_div_str, + v_per_div_ch1_input: v_per_div_ch1_str, + v_per_div_ch2_input: v_per_div_ch2_str, t_per_div_input: t_per_div_str, - v_offset_input: v_offset_str, + v_offset_ch1_input: v_offset_ch1_str, + v_offset_ch2_input: v_offset_ch2_str, }, Task::none(), ) @@ -491,12 +501,17 @@ impl App { // Auto-fill scale from screenshot metadata let s = &pkt.settings; let probe1 = 10f64.powi(s.ch1.probe_mode as i32); - self.settings.v_per_div = s.ch1.volt_scale_uv as f64 / 1_000_000.0 * probe1; + let probe2 = 10f64.powi(s.ch2.probe_mode as i32); + self.settings.v_per_div_ch1 = s.ch1.volt_scale_uv as f64 / 1_000_000.0 * probe1; + self.settings.v_per_div_ch2 = s.ch2.volt_scale_uv as f64 / 1_000_000.0 * probe2; self.settings.t_per_div_ms = s.timebase_ns_per_div as f64 / 1_000_000.0; - self.settings.v_offset = s.ch1.zero_volt_uv as f64 / 1_000_000.0 * probe1; - self.v_per_div_input = format_float(self.settings.v_per_div); + self.settings.v_offset_ch1 = s.ch1.zero_volt_uv as f64 / 1_000_000.0 * probe1; + self.settings.v_offset_ch2 = s.ch2.zero_volt_uv as f64 / 1_000_000.0 * probe2; + self.v_per_div_ch1_input = format_float(self.settings.v_per_div_ch1); + self.v_per_div_ch2_input = format_float(self.settings.v_per_div_ch2); self.t_per_div_input = format_float(self.settings.t_per_div_ms); - self.v_offset_input = format_float(self.settings.v_offset); + self.v_offset_ch1_input = format_float(self.settings.v_offset_ch1); + self.v_offset_ch2_input = format_float(self.settings.v_offset_ch2); self.settings.save(); let entry = CaptureEntry { capture, timestamp: now, record }; @@ -515,9 +530,12 @@ impl App { self.deleted_capture = None; self.deleted_at = None; self.status = format!( - "Screenshot received ({}/div, {}/div). Total: {}", + "Screenshot received (CH1: {}/div, CH2: {}/div, {}/div). Total: {}", dso_parser::format_uv( - (self.settings.v_per_div * 1_000_000.0) as i64, 0 + (self.settings.v_per_div_ch1 * 1_000_000.0) as i64, 0 + ), + dso_parser::format_uv( + (self.settings.v_per_div_ch2 * 1_000_000.0) as i64, 0 ), s.timebase_label(), self.dump.as_ref().map(|d| d.captures.len()).unwrap_or(0) @@ -581,12 +599,21 @@ impl App { self.settings.cursor_y_range = (lo, hi); Task::none() } - Message::VPerCellChanged(s) => { - self.v_per_div_input = s.clone(); - // Accept both decimal ("0.001") and scientific notation ("1e-3") + Message::VPerCellChangedCh1(s) => { + self.v_per_div_ch1_input = s.clone(); + if let Ok(v) = s.parse::() { + if v > 0.0 && v.is_finite() { + self.settings.v_per_div_ch1 = v; + self.settings.save(); + } + } + Task::none() + } + Message::VPerCellChangedCh2(s) => { + self.v_per_div_ch2_input = s.clone(); if let Ok(v) = s.parse::() { if v > 0.0 && v.is_finite() { - self.settings.v_per_div = v; + self.settings.v_per_div_ch2 = v; self.settings.save(); } } @@ -602,11 +629,21 @@ impl App { } Task::none() } - Message::VOffsetChanged(s) => { - self.v_offset_input = s.clone(); + Message::VOffsetChangedCh1(s) => { + self.v_offset_ch1_input = s.clone(); + if let Ok(v) = s.parse::() { + if v.is_finite() { + self.settings.v_offset_ch1 = v; + self.settings.save(); + } + } + Task::none() + } + Message::VOffsetChangedCh2(s) => { + self.v_offset_ch2_input = s.clone(); if let Ok(v) = s.parse::() { if v.is_finite() { - self.settings.v_offset = v; + self.settings.v_offset_ch2 = v; self.settings.save(); } } @@ -1244,9 +1281,11 @@ impl App { } else { None }, - v_per_div: self.settings.v_per_div, + v_per_div_ch1: self.settings.v_per_div_ch1, + v_per_div_ch2: self.settings.v_per_div_ch2, t_per_div_ms: self.settings.t_per_div_ms, - v_offset: self.settings.v_offset, + v_offset_ch1: self.settings.v_offset_ch1, + v_offset_ch2: self.settings.v_offset_ch2, on_click: Some(Message::GraphClicked), }; canvas(scope) @@ -1263,85 +1302,21 @@ impl App { let (xlo, xhi) = self.settings.cursor_x_range; let (ylo, yhi) = self.settings.cursor_y_range; - // Scale parameters - let v_per_div = self.settings.v_per_div; + // Scale parameters (per-channel) + let v_per_div_ch1 = self.settings.v_per_div_ch1; + let v_per_div_ch2 = self.settings.v_per_div_ch2; let t_per_div_ms = self.settings.t_per_div_ms; - let v_offset = self.settings.v_offset; - let half_v_range = 4.0 * v_per_div; // 8 div / 2 + let v_offset_ch1 = self.settings.v_offset_ch1; + let v_offset_ch2 = self.settings.v_offset_ch2; + let half_v_range_ch1 = 4.0 * v_per_div_ch1; // 8 div / 2 + let half_v_range_ch2 = 4.0 * v_per_div_ch2; let total_time_ms = 12.0 * t_per_div_ms; - // ── Signal information ── let mut items: Vec> = vec![ text("Measurement").size(13).into(), rule::horizontal(1).into(), ]; - // ── Scale inputs ── - items.push( - row![ - text("V/div:").size(11), - text_input("1.0", &self.v_per_div_input) - .on_input(Message::VPerCellChanged) - .size(11) - .width(Length::Fixed(50.0)), - text("V").size(11), - ] - .spacing(4) - .align_y(iced::Alignment::Center) - .into(), - ); - items.push( - row![ - text("t/div:").size(11), - text_input("1.0", &self.t_per_div_input) - .on_input(Message::TPerCellChanged) - .size(11) - .width(Length::Fixed(50.0)), - text("ms").size(11), - ] - .spacing(4) - .align_y(iced::Alignment::Center) - .into(), - ); - items.push( - row![ - text("V/off:").size(11), - text_input("0.0", &self.v_offset_input) - .on_input(Message::VOffsetChanged) - .size(11) - .width(Length::Fixed(50.0)), - text("V").size(11), - ] - .spacing(4) - .align_y(iced::Alignment::Center) - .into(), - ); - items.push( - text("AC mode: 8×V, 12×t div").size(9).into(), - ); - items.push(rule::horizontal(1).into()); - - if let Some(cap) = cap { - let v_min = frac_to_voltage(1.0, half_v_range, v_offset); // bottom - let v_max = frac_to_voltage(0.0, half_v_range, v_offset); // top - items.push( - text(format!("CH1 range: {v_min:.2}V … {v_max:.2}V")).size(11).into(), - ); - items.push( - text(format!("Time range: {total_time_ms:.2} ms ({} samp)", cap.ch1.len())) - .size(11) - .into(), - ); - if cap.ch2.is_some() { - items.push( - text(format!("CH2 range: {v_min:.2}V … {v_max:.2}V")) - .size(11) - .into(), - ); - } - items.push(rule::horizontal(1).into()); - } - // ── X cursor (time) ── items.push( checkbox(self.settings.measurement_cursor_x_enabled) @@ -1382,15 +1357,20 @@ impl App { if self.settings.measurement_cursor_y_enabled { items.push(range_slider::horizontal(ylo, yhi, Message::MeasCursorYChanged)); - let v_lo = frac_to_voltage(ylo as f64, half_v_range, v_offset); - let v_hi = frac_to_voltage(yhi as f64, half_v_range, v_offset); - let dv = (v_hi - v_lo).abs(); + let v_ch1_lo = frac_to_voltage(ylo as f64, half_v_range_ch1, v_offset_ch1); + let v_ch1_hi = frac_to_voltage(yhi as f64, half_v_range_ch1, v_offset_ch1); + let v_ch2_lo = frac_to_voltage(ylo as f64, half_v_range_ch2, v_offset_ch2); + let v_ch2_hi = frac_to_voltage(yhi as f64, half_v_range_ch2, v_offset_ch2); + let dv_ch1 = (v_ch1_hi - v_ch1_lo).abs(); + let dv_ch2 = (v_ch2_hi - v_ch2_lo).abs(); items.push( column![ - text(format!("ΔV = {dv:.3} V")).size(11), - text(format!("({v_lo:.2}V … {v_hi:.2}V)")).size(11), + text(format!("CH1 ΔV = {dv_ch1:.3} V")).size(11), + text(format!(" ({v_ch1_lo:.2}V … {v_ch1_hi:.2}V)")).size(10), + text(format!("CH2 ΔV = {dv_ch2:.3} V")).size(11), + text(format!(" ({v_ch2_lo:.2}V … {v_ch2_hi:.2}V)")).size(10), ] - .spacing(2) + .spacing(0) .into(), ); } @@ -1398,6 +1378,92 @@ impl App { items.push(Space::new().height(Length::Fixed(6.0)).into()); items.push(rule::horizontal(1).into()); + // ── Scale inputs (two-column layout: CH1 | CH2) ── + let col_w = Length::Fixed(85.0); + let input_w = Length::Fixed(42.0); + items.push( + row![ + text("").size(11).width(col_w), + text("CH1").size(11).width(input_w), + text("CH2").size(11).width(input_w), + ] + .spacing(2) + .into(), + ); + // V/div row + items.push( + row![ + text("V/div, V:").size(11).width(col_w), + text_input("1.0", &self.v_per_div_ch1_input) + .on_input(Message::VPerCellChangedCh1) + .size(11) + .width(input_w), + text_input("1.0", &self.v_per_div_ch2_input) + .on_input(Message::VPerCellChangedCh2) + .size(11) + .width(input_w), + ] + .spacing(2) + .align_y(iced::Alignment::Center) + .into(), + ); + // V/off row + items.push( + row![ + text("V/off, V:").size(11).width(col_w), + text_input("0.0", &self.v_offset_ch1_input) + .on_input(Message::VOffsetChangedCh1) + .size(11) + .width(input_w), + text_input("0.0", &self.v_offset_ch2_input) + .on_input(Message::VOffsetChangedCh2) + .size(11) + .width(input_w), + ] + .spacing(2) + .align_y(iced::Alignment::Center) + .into(), + ); + // t/div row (shared, spans both channels) + items.push( + row![ + text("t/div, ms:").size(11).width(col_w), + text_input("1.0", &self.t_per_div_input) + .on_input(Message::TPerCellChanged) + .size(11) + .width(Length::Fixed(90.0)) + ] + .spacing(2) + .align_y(iced::Alignment::Center) + .into(), + ); + items.push(text("AC mode: 8×V, 12×t div").size(9).into()); + items.push(rule::horizontal(1).into()); + + // ── Signal information (per-channel) ── + if let Some(cap) = cap { + let v_ch1_min = frac_to_voltage(1.0, half_v_range_ch1, v_offset_ch1); + let v_ch1_max = frac_to_voltage(0.0, half_v_range_ch1, v_offset_ch1); + let v_ch2_min = frac_to_voltage(1.0, half_v_range_ch2, v_offset_ch2); + let v_ch2_max = frac_to_voltage(0.0, half_v_range_ch2, v_offset_ch2); + items.push( + text(format!("CH1 range: {v_ch1_min:.2}V … {v_ch1_max:.2}V")).size(11).into(), + ); + if cap.ch2.is_some() { + items.push( + text(format!("CH2 range: {v_ch2_min:.2}V … {v_ch2_max:.2}V")) + .size(11) + .into(), + ); + } + items.push( + text(format!("Time range: {total_time_ms:.2} ms ({} samp)", cap.ch1.len())) + .size(11) + .into(), + ); + items.push(rule::horizontal(1).into()); + } + // ── Per-channel stats ── if let Some(cap) = cap { let (mn, mx, av) = stats(&cap.ch1); @@ -1529,7 +1595,7 @@ impl App { } container(column(items).spacing(4).padding(6)) - .width(Length::Fixed(220.0)) + .width(Length::Fixed(240.0)) .style(container::rounded_box) .into() } @@ -2088,11 +2154,11 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any }; // Helper to draw scale labels (simple 3x5 digit font). - let v_per_div = settings.v_per_div; let t_per_div_ms = settings.t_per_div_ms; - let v_offset = settings.v_offset; let draw_scales_on_region = - |pixels: &mut Vec, x_off: u32, y_off: u32, w: u32, h: u32, _n_samples: usize, ch1_color: Option<[u8; 3]>, ch2_color: Option<[u8; 3]>| { + |pixels: &mut Vec, x_off: u32, y_off: u32, w: u32, h: u32, _n_samples: usize, + vpd_ch1: f64, vo_ch1: f64, ch1_color: Option<[u8; 3]>, + vpd_ch2: f64, vo_ch2: f64, ch2_color: Option<[u8; 3]>| { // X axis: time labels at bottom let cols = 10u32; let total_time_ms = 12.0 * t_per_div_ms; @@ -2111,18 +2177,27 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any // Y axis: voltage labels on left margin, per-channel let rows = 8u32; - let half_v_range = 4.0 * v_per_div; let x_ch1 = 2u32; let x_ch2 = if ch1_color.is_some() { 26u32 } else { 2u32 }; - for i in 0..=rows { - let frac = i as f64 / rows as f64; - let voltage = v_offset + half_v_range * (1.0 - 2.0 * frac); - let label = format!("{voltage:.1}"); - let y_pos = y_off + (i * h / rows); - if let Some(c) = ch1_color { + // CH1 labels + if let Some(c) = ch1_color { + let half_v_range = 4.0 * vpd_ch1; + for i in 0..=rows { + let frac = i as f64 / rows as f64; + let voltage = vo_ch1 + half_v_range * (1.0 - 2.0 * frac); + let label = format!("{voltage:.1}"); + let y_pos = y_off + (i * h / rows); draw_text_tiny(pixels, width, &label, x_ch1, y_pos, c); } - if let Some(c) = ch2_color { + } + // CH2 labels + if let Some(c) = ch2_color { + let half_v_range = 4.0 * vpd_ch2; + for i in 0..=rows { + let frac = i as f64 / rows as f64; + let voltage = vo_ch2 + half_v_range * (1.0 - 2.0 * frac); + let label = format!("{voltage:.1}"); + let y_pos = y_off + (i * h / rows); draw_text_tiny(pixels, width, &label, x_ch2, y_pos, c); } } @@ -2136,7 +2211,11 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any draw_ch(&mut pixels, &cap.ch1, [255, 217, 25], scale_margin_left, y_off_1, graph_w, sub_h); if settings.show_scales { let ch1_col = Some([255u8, 217, 25]); - draw_scales_on_region(&mut pixels, scale_margin_left, y_off_1, graph_w, sub_h, cap.ch1.len(), ch1_col, None); + draw_scales_on_region( + &mut pixels, scale_margin_left, y_off_1, graph_w, sub_h, cap.ch1.len(), + settings.v_per_div_ch1, settings.v_offset_ch1, ch1_col, + settings.v_per_div_ch2, settings.v_offset_ch2, None, + ); } // Lower subimage: CH2 @@ -2148,7 +2227,11 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any if settings.show_scales { let ch2_col = Some([38u8, 217, 255]); let n = cap.ch2.as_ref().map(|c| c.len()).unwrap_or(cap.ch1.len()); - draw_scales_on_region(&mut pixels, scale_margin_left, y_off_2, graph_w, sub_h, n, None, ch2_col); + draw_scales_on_region( + &mut pixels, scale_margin_left, y_off_2, graph_w, sub_h, n, + settings.v_per_div_ch1, settings.v_offset_ch1, None, + settings.v_per_div_ch2, settings.v_offset_ch2, ch2_col, + ); } } else { // Single combined image @@ -2164,7 +2247,11 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any if settings.show_scales { let ch1_col = if settings.show_ch1 { Some([255u8, 217, 25]) } else { None }; let ch2_col = if settings.show_ch2 && has_ch2 { Some([38u8, 217, 255]) } else { None }; - draw_scales_on_region(&mut pixels, scale_margin_left, 0, graph_w, graph_h, cap.ch1.len(), ch1_col, ch2_col); + draw_scales_on_region( + &mut pixels, scale_margin_left, 0, graph_w, graph_h, cap.ch1.len(), + settings.v_per_div_ch1, settings.v_offset_ch1, ch1_col, + settings.v_per_div_ch2, settings.v_offset_ch2, ch2_col, + ); } } diff --git a/gui/src/canvas.rs b/gui/src/canvas.rs index 996fd58..be47532 100644 --- a/gui/src/canvas.rs +++ b/gui/src/canvas.rs @@ -27,12 +27,16 @@ pub struct Scope<'a, Message = ()> { pub has_ch1: bool, /// Whether CH2 data is present in the capture (for scale labelling). pub has_ch2: bool, - /// Volts per division. 8 divisions total. - pub v_per_div: f64, + /// Volts per division for CH1. 8 divisions total. + pub v_per_div_ch1: f64, + /// Volts per division for CH2. 8 divisions total. + pub v_per_div_ch2: f64, /// Time per division in milliseconds. 12 divisions total. pub t_per_div_ms: f64, - /// Voltage offset (voltage at graph center). - pub v_offset: f64, + /// Voltage offset for CH1 (voltage at graph center). + pub v_offset_ch1: f64, + /// Voltage offset for CH2 (voltage at graph center). + pub v_offset_ch2: f64, /// Callback message factory for graph clicks: (frac_x, frac_y). pub on_click: Option Message>, } @@ -270,9 +274,11 @@ impl canvas::Program for Scope<'_, Message> { self.n_samples, self.has_ch1, self.has_ch2, - self.v_per_div, + self.v_per_div_ch1, + self.v_per_div_ch2, self.t_per_div_ms, - self.v_offset, + self.v_offset_ch1, + self.v_offset_ch2, ); } @@ -319,21 +325,23 @@ const V_CELLS: usize = 8; /// Number of horizontal divisions on the oscilloscope screen for time. const T_CELLS: usize = 12; +#[allow(clippy::too_many_arguments)] fn draw_scales( frame: &mut Frame, _n_samples: usize, has_ch1: bool, has_ch2: bool, - v_per_div: f64, + v_per_div_ch1: f64, + v_per_div_ch2: f64, t_per_div_ms: f64, - v_offset: f64, + v_offset_ch1: f64, + v_offset_ch2: f64, ) { let size = frame.size(); let rows = V_CELLS; let cols = 10; // grid visual divisions (drawn grid is 10 columns) // X axis: time labels at each visual division. - // Total time span = T_CELLS * t_per_div_ms. We distribute over the sample window. let total_time_ms = T_CELLS as f64 * t_per_div_ms; let dx = size.width / cols as f32; for i in 0..=cols { @@ -355,17 +363,15 @@ fn draw_scales( frame.fill_text(label); } - // Y axis: voltage scale. Total range = V_CELLS * v_per_div, centered around v_offset. - // Top = v_offset + half_range, bottom = v_offset - half_range (AC mode). - let half_range_v = (rows as f64 * v_per_div) / 2.0; + // Y axis: voltage scale per channel. let ch1_x_offset = 2.0; let ch2_x_offset = if has_ch1 { 42.0 } else { 2.0 }; if has_ch1 { + let half_range_v = (rows as f64 * v_per_div_ch1) / 2.0; for i in 0..=rows { let frac = i as f32 / rows as f32; - // frac=0 → top → v_offset+half_range; frac=1 → bottom → v_offset-half_range - let voltage = v_offset + half_range_v * (1.0 - 2.0 * frac as f64); + let voltage = v_offset_ch1 + half_range_v * (1.0 - 2.0 * frac as f64); let y = frac * size.height; let label = Text { content: format!("{voltage:.2}V"), @@ -379,9 +385,10 @@ fn draw_scales( } if has_ch2 { + let half_range_v = (rows as f64 * v_per_div_ch2) / 2.0; for i in 0..=rows { let frac = i as f32 / rows as f32; - let voltage = v_offset + half_range_v * (1.0 - 2.0 * frac as f64); + let voltage = v_offset_ch2 + half_range_v * (1.0 - 2.0 * frac as f64); let y = frac * size.height; let label = Text { content: format!("{voltage:.2}V"), @@ -423,8 +430,9 @@ fn draw_trace(frame: &mut Frame, samples: &[u8], color: Color, mode: ViewMode) { let to_xy = |i: usize, v: f32| -> Point { let x = (i as f32 / (n - 1).max(1) as f32) * size.width; - // Invert Y: higher ADC value (higher voltage) at top of screen. - let y = (1.0 - v / 255.0) * size.height; + // Capture values use screen-coordinate convention: low value = top + // of screen = positive voltage, high value = bottom = negative voltage. + let y = (v / 255.0) * size.height; Point::new(x, y) }; diff --git a/gui/src/settings.rs b/gui/src/settings.rs index a98156a..d8da80b 100644 --- a/gui/src/settings.rs +++ b/gui/src/settings.rs @@ -31,15 +31,21 @@ pub struct Settings { pub alert_on_data: bool, /// Automatically start listening when the configured port appears. pub auto_listen: bool, - /// Volts per division for the scope grid. Default 1.0 V/div. + /// Volts per division for CH1. Default 1.0 V/div. #[serde(default = "default_v_per_div")] - pub v_per_div: f64, + pub v_per_div_ch1: f64, + /// Volts per division for CH2. Default 1.0 V/div. + #[serde(default = "default_v_per_div")] + pub v_per_div_ch2: f64, /// Time per division in milliseconds. Default 1.0 ms/div. #[serde(default = "default_t_per_div_ms")] pub t_per_div_ms: f64, - /// Voltage offset (zero-level of graph) in volts. Default 0.0 V. + /// Voltage offset for CH1 (zero-level of graph) in volts. Default 0.0 V. + #[serde(default)] + pub v_offset_ch1: f64, + /// Voltage offset for CH2 (zero-level of graph) in volts. Default 0.0 V. #[serde(default)] - pub v_offset: f64, + pub v_offset_ch2: f64, /// Last window size (width, height) in logical pixels. #[serde(default = "default_window_size")] pub window_size: (f32, f32), @@ -97,9 +103,11 @@ impl Default for Settings { split_png: false, alert_on_data: true, auto_listen: false, - v_per_div: 1.0, + v_per_div_ch1: 1.0, + v_per_div_ch2: 1.0, t_per_div_ms: 1.0, - v_offset: 0.0, + v_offset_ch1: 0.0, + v_offset_ch2: 0.0, window_size: default_window_size(), } } From 8283b471d1ba70d7a39843ce04035898204c236c Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Mon, 29 Jun 2026 19:42:56 +0300 Subject: [PATCH 09/23] GUI: fixed inverted PNG --- gui/src/app.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gui/src/app.rs b/gui/src/app.rs index 02f2e1b..98ac0ba 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -2116,8 +2116,9 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any let sample_y = |px: u32| -> u32 { let i = (px as f64 / w as f64 * (n - 1) as f64) as usize; let v = samples[i.min(n - 1)]; - // Invert Y: higher ADC value (higher voltage) at top of image. - let py = ((1.0 - v as f64 / 255.0) * (h - 1) as f64) as u32; + // Capture values use screen-coordinate convention: + // low value = top of screen = positive voltage. + let py = ((v as f64 / 255.0) * (h - 1) as f64) as u32; py.min(h - 1) }; From c86e22776c1b480e6fe0ff4246271958c2042e99 Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Wed, 15 Jul 2026 17:24:18 +0300 Subject: [PATCH 10/23] GUI: fixed V-scales and offsets, fixed saving/reading snapshorts info --- docs/GUI_DESIGN.md | 29 ++- docs/USER_MANUAL.md | 17 +- dso-parser/src/container.rs | 20 +- dso-parser/src/lib.rs | 27 ++- dso-parser/src/screenshot.rs | 26 +-- gui/Cargo.toml | 5 + gui/src/app.rs | 400 +++++++++++++++++++++++++++-------- gui/src/canvas.rs | 185 ++++++++-------- 8 files changed, 482 insertions(+), 227 deletions(-) diff --git a/docs/GUI_DESIGN.md b/docs/GUI_DESIGN.md index 8c3e212..9e1648c 100644 --- a/docs/GUI_DESIGN.md +++ b/docs/GUI_DESIGN.md @@ -59,7 +59,10 @@ yellow CH1, cyan CH2. Renders: * Green thin lines for **device cursors** (data-supplied, read-only) with numeric labels (sample numbers for X, voltage for Y). * Optional **axis scales** toggled via "Show scales" setting. Scale values - are derived from user-configurable V/div and time/div parameters. + are derived from user-configurable V/div and time/div parameters: + top grid line = V/off + 4×V/div, center = V/off, bottom = V/off − 4×V/div. + V/off affects voltage labels and cursor measurements. The trace Y-position + uses the device pixel density (25): `y = v / 200 × height`. ### 3. Measurement panel (right, 220 px) Vertical column inside a rounded container. @@ -72,8 +75,18 @@ Vertical column inside a rounded container. These values drive all voltage/time calculations throughout the panel and graph scales. The oscilloscope screen has 8 vertical and 12 horizontal divs. -When a capture arrives via the screenshot protocol, V/div and V/offset are -**automatically filled** from CH1/CH2 packet metadata separately. +When capture metadata includes probe-mode information (×1, ×10, ×100), a +**Probe:** row is shown beneath V/div, and probe + coupling info appear in the +Trigger section. + +When a capture arrives via the **screenshot protocol** (live), V/div, V/offset +and t/div are **automatically filled** from the packet's embedded settings +metadata. The same auto-population applies when **switching between captures** +in the thumbnail strip — clicking a screenshot capture applies its `scope_state` +settings. For single-capture `.zwcap` or `.bin` files, settings are applied +automatically on load. For multi-capture files, select the desired capture +from the thumbnails to apply its settings. ASCII dumps (without metadata) +retain the last-used settings. **Signal information** (shown when a capture is loaded): * Voltage range for CH1/CH2 (computed from V/div × 8 divs, centered at 0). @@ -223,8 +236,10 @@ Default level: info; override with `RUST_LOG`. * ADC 0..255 uses screen-coordinate convention (0 = top of screen ≈ +5 V, 127/128 = 0 V, 255 = bottom of screen ≈ −5 V). This is consistent across both debug-dump and screenshot data paths. -* Scale labels are derived from V/div and V/offset settings: - - Top of graph = V/offset + 4×V/div - - Bottom of graph = V/offset − 4×V/div +* Voltage uses the device-calibrated formula: + `V = V_offset + (127 − v) × V/div / 25` + where `v` = screen-coordinate ADC value (0 = top, 255 = bottom) and + 25 = pixels per division. This matches the DSO3D12's own `pixel_to_volts`. * Time axis = 12 divisions × t/div (ms). -* When receiving screenshot packets, these are auto-filled from device metadata. +* When receiving screenshot packets or loading `.zwcap` files with metadata, + V/div, V/offset, t/div and probe mode are auto-filled from device metadata. diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 8781933..01ed801 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -47,7 +47,10 @@ channel. Use `V/div` and `t/div` in the Measurement panel to set the scale. binary packet** containing the 300 display pixels plus all instrument settings. The app detects this packet by its size and decodes the embedded calibration -data to set `V/div` and `t/div` automatically. +data to set `V/div`, `V/off`, `t/div` and **probe mode** (×1, ×10, ×100) +automatically. Probe and coupling info are shown in the Measurement panel's +Trigger section, and the probe multiplier is reflected in the displayed V/div +and V/off values. Use the `capture` CLI tool to capture and analyze screenshot packets from the command line (see [CLI tools](#cli-tools) below). @@ -59,8 +62,16 @@ Re-plug and the connection resumes without manual intervention. ## Loading captures from files -Click **Load Capture** and select a `.bin` / `.dat` / `.raw` file previously -saved from the device. Files can contain one or many consecutive captures. +Click **Load Capture** and select a `.zwcap`, `.bin`, `.dat` or `.raw` file. +Files can contain one or many consecutive captures. + +When loading files that contain embedded instrument metadata (`.zwcap` files +or screenshot `.bin` files), the **V/div**, **V/off** and **t/div** settings +are **automatically populated** from the capture data. You can still adjust +them manually if needed. + +When switching between captures in the thumbnail strip, the scale settings +update automatically for each capture that has embedded metadata. ## Viewing the graph diff --git a/dso-parser/src/container.rs b/dso-parser/src/container.rs index b34d94d..eed0c0a 100644 --- a/dso-parser/src/container.rs +++ b/dso-parser/src/container.rs @@ -69,6 +69,9 @@ pub struct ScopeState { pub cursor_y1_frac: f64, /// Cursor Y2 position as fraction 0.0–1.0 of graph height. pub cursor_y2_frac: f64, + /// Active channel (0 = CH1, 1 = CH2). Used for device cursor voltage. + #[serde(default)] + pub active_channel: u8, } /// Per-channel state. @@ -156,7 +159,7 @@ pub fn is_zwcap(data: &[u8]) -> bool { // ── Conversion helpers ───────────────────────────────────────────────────── use crate::screenshot::{self, ScreenshotPacket}; -use crate::Capture; +use crate::{ADC_VALUE_MAX, ADC_VALUE_MID, Capture, PIXELS_PER_DIV}; /// Convert an ASCII debug dump `Capture` into a `CaptureRecord`. pub fn capture_to_record( @@ -221,8 +224,8 @@ pub fn screenshot_to_record( // Cursor fractions: device uses pixel offsets from center (150 for X, 128 for Y) let cx1 = (s.cursor_x1 as f64 + 150.0) / 300.0; let cx2 = (s.cursor_x2 as f64 + 150.0) / 300.0; - let cy1 = (s.cursor_y1 as f64 + 128.0) / 256.0; - let cy2 = (s.cursor_y2 as f64 + 128.0) / 256.0; + let cy1 = (s.cursor_y1 as f64 + ADC_VALUE_MID as f64) / 256.0; + let cy2 = (s.cursor_y2 as f64 + ADC_VALUE_MID as f64) / 256.0; let scope_state = ScopeState { timebase_label: s.timebase_label().to_string(), @@ -246,6 +249,7 @@ pub fn screenshot_to_record( cursor_x2_frac: cx2.clamp(0.0, 1.0), cursor_y1_frac: cy1.clamp(0.0, 1.0), cursor_y2_frac: cy2.clamp(0.0, 1.0), + active_channel: s.active_channel, }; let mut waveforms = vec![Waveform::DisplayEnvelope { @@ -253,7 +257,7 @@ pub fn screenshot_to_record( min_counts: pkt.ch1_min.to_vec(), max_counts: pkt.ch1_max.to_vec(), time_per_pixel_s: Some(s.time_per_pixel_s()), - volts_per_pixel: Some(s.ch1.volt_scale_uv as f64 / 25.0), + volts_per_pixel: Some(s.ch1.volt_scale_uv as f64 / PIXELS_PER_DIV), zero_pixel: Some(s.ch1.zero_volt_pixels as i16), }]; if let (Some(mn), Some(mx)) = (&pkt.ch2_min, &pkt.ch2_max) { @@ -262,7 +266,7 @@ pub fn screenshot_to_record( min_counts: mn.to_vec(), max_counts: mx.to_vec(), time_per_pixel_s: Some(s.time_per_pixel_s()), - volts_per_pixel: Some(s.ch2.volt_scale_uv as f64 / 25.0), + volts_per_pixel: Some(s.ch2.volt_scale_uv as f64 / PIXELS_PER_DIV), zero_pixel: Some(s.ch2.zero_volt_pixels as i16), }); } @@ -334,15 +338,15 @@ pub fn record_to_capture(rec: &CaptureRecord) -> Capture { for w in &rec.waveforms { match w { Waveform::RawSamples { channel: 1, counts, .. } => { - let mut raw: Vec = counts.iter().map(|&c| c.clamp(0, 255) as u8).collect(); + let mut raw: Vec = counts.iter().map(|&c| c.clamp(0, ADC_VALUE_MAX as i16) as u8).collect(); // Old-format: CH1 stored in raw-ADC convention → invert to match. if old_ascii { - raw.iter_mut().for_each(|v| *v = 255 - *v); + raw.iter_mut().for_each(|v| *v = ADC_VALUE_MAX - *v); } ch1 = raw; } Waveform::RawSamples { channel: 2, counts, .. } => { - ch2 = Some(counts.iter().map(|&c| c.clamp(0, 255) as u8).collect()); + ch2 = Some(counts.iter().map(|&c| c.clamp(0, ADC_VALUE_MAX as i16) as u8).collect()); } _ => {} } diff --git a/dso-parser/src/lib.rs b/dso-parser/src/lib.rs index 5bd1906..50a73af 100644 --- a/dso-parser/src/lib.rs +++ b/dso-parser/src/lib.rs @@ -42,7 +42,18 @@ pub const CH1_MARKER: &[u8] = b"--------CH1--------"; /// ASCII header marking the start of channel 2 samples. pub const CH2_MARKER: &[u8] = b"--------CH2--------"; -/// A single decoded oscilloscope capture. +// ── ADC & display constants ─────────────────────────────────────────────── + +/// Maximum ADC sample value (8-bit unsigned). +pub const ADC_VALUE_MAX: u8 = 255; + +/// ADC midpoint (centre of the 0..=255 range, zero reference in pixel_to_volts). +pub const ADC_VALUE_MID: u8 = 128; + +/// Device display vertical pixels per division (200 px ÷ 8 div = 25 px/div). +pub const PIXELS_PER_DIV: f64 = 25.0; + +// ── Capture struct ──────────────────────────────────────────────────────── /// /// Sample values are raw 8-bit ADC counts (0..=255). #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -96,7 +107,7 @@ pub fn parse_captures(data: &[u8]) -> Result, ParseError> { // screenshot data (low value = top of screen = positive voltage), // matching what was already done for CH2 below. let ch1: Vec = decode_ascii_samples(&data[ch1_body..ch1_end]) - .into_iter().map(|v| 255 - v).collect(); + .into_iter().map(|v| ADC_VALUE_MAX - v).collect(); // Check whether the next boundary is a CH2 within the same capture. let mut next_cursor = ch1_end; @@ -106,7 +117,7 @@ pub fn parse_captures(data: &[u8]) -> Result, ParseError> { // CH2 uses the same screen-coordinate convention as CH1 and screenshot data: // low value = near top of screen = positive voltage. let samples: Vec = decode_ascii_samples(&data[ch2_body..ch2_end]) - .into_iter().map(|v| 255 - v).collect(); + .into_iter().map(|v| ADC_VALUE_MAX - v).collect(); next_cursor = ch2_end; Some(samples) } else { @@ -153,9 +164,9 @@ pub fn looks_like_dump_start(data: &[u8]) -> bool { /// (volt-scale, probe mode, zero offset). Callers that have access to the /// settings buffer can substitute their own conversion. pub fn adc_to_normalized(byte: u8) -> f32 { - // Match ZeeTweak: y_screen = 255 - raw; center = 128. - let centered = (255i16 - byte as i16) - 128; - centered as f32 / 128.0 + // Match ZeeTweak: y_screen = ADC_VALUE_MAX - raw; center = ADC_VALUE_MID. + let centered = (ADC_VALUE_MAX as i16 - byte as i16) - ADC_VALUE_MID as i16; + centered as f32 / ADC_VALUE_MID as f32 } // ---------- internals ---------- @@ -210,7 +221,7 @@ fn decode_ascii_samples(body: &[u8]) -> Vec { // Terminators that complete a token. b' ' | 0x00 | b'\r' | b'\n' | b'\t' => { if have_digit { - if acc <= 255 { + if acc <= ADC_VALUE_MAX as u32 { out.push(acc as u8); } acc = 0; @@ -224,7 +235,7 @@ fn decode_ascii_samples(body: &[u8]) -> Vec { } } } - if have_digit && acc <= 255 { + if have_digit && acc <= ADC_VALUE_MAX as u32 { out.push(acc as u8); } out diff --git a/dso-parser/src/screenshot.rs b/dso-parser/src/screenshot.rs index e6e1d58..7342b06 100644 --- a/dso-parser/src/screenshot.rs +++ b/dso-parser/src/screenshot.rs @@ -21,7 +21,7 @@ //! //! DSO2512G firmware uses slightly different field offsets (see [`DeviceVariant`]). -use crate::{Capture, ParseError}; +use crate::{ADC_VALUE_MAX, ADC_VALUE_MID, Capture, ParseError, PIXELS_PER_DIV}; /// Which firmware / hardware variant we're parsing. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -231,10 +231,10 @@ pub struct ChannelSettings { impl ChannelSettings { /// Converts a raw (inverted) ADC pixel value to voltage in volts. pub fn pixel_to_volts(&self, raw: u8) -> f64 { - let pixel_val = (255 - raw) as f64; - let vpp = self.volt_scale_uv as f64 / 25.0 / 1_000_000.0; + let pixel_val = (ADC_VALUE_MAX - raw) as f64; + let vpp = self.volt_scale_uv as f64 / PIXELS_PER_DIV / 1_000_000.0; let multiplier = 10f64.powi(self.probe_mode as i32); - (pixel_val - 128.0 - self.zero_volt_pixels as f64) * vpp * multiplier + (pixel_val - ADC_VALUE_MID as f64 - self.zero_volt_pixels as f64) * vpp * multiplier } pub fn vdiv_str(&self) -> String { @@ -293,7 +293,7 @@ pub struct OscSettings { impl OscSettings { /// Time per pixel in seconds (timebase_ns / 25 / 1e9). pub fn time_per_pixel_s(&self) -> f64 { - self.timebase_ns_per_div as f64 / 25.0 / 1e9 + self.timebase_ns_per_div as f64 / PIXELS_PER_DIV / 1e9 } pub fn timebase_label(&self) -> &'static str { @@ -438,8 +438,8 @@ impl ScreenshotPacket { let ch1_min_v = self.settings.ch1.pixel_to_volts(self.ch1_min[i]); let ch1_max_v = self.settings.ch1.pixel_to_volts(self.ch1_max[i]); let ch2 = if self.settings.ch2.enabled { - let mn = self.ch2_min.as_ref().map(|a| a[i]).unwrap_or(128); - let mx = self.ch2_max.as_ref().map(|a| a[i]).unwrap_or(128); + let mn = self.ch2_min.as_ref().map(|a| a[i]).unwrap_or(ADC_VALUE_MID); + let mx = self.ch2_max.as_ref().map(|a| a[i]).unwrap_or(ADC_VALUE_MID); Some(( self.settings.ch2.pixel_to_volts(mn), self.settings.ch2.pixel_to_volts(mx), @@ -503,8 +503,8 @@ impl ScreenshotPacket { out.push_str(&format!(" V/Div : {}/div (index {})\n", s.ch1.vdiv_str(), s.ch1.vdiv_index)); out.push_str(&format!(" Volt scale : {} µV/px ({}/pixel)\n", - s.ch1.volt_scale_uv as f64 / 25.0, - format_uv((s.ch1.volt_scale_uv as f64 / 25.0) as i64, 0))); + s.ch1.volt_scale_uv as f64 / PIXELS_PER_DIV, + format_uv((s.ch1.volt_scale_uv as f64 / PIXELS_PER_DIV) as i64, 0))); out.push_str(&format!(" Zero V (pixels): {} px\n", s.ch1.zero_volt_pixels)); out.push_str(&format!(" Zero V (µV) : {}\n", format_uv(s.ch1.zero_volt_uv, 0))); @@ -519,7 +519,7 @@ impl ScreenshotPacket { out.push_str(&format!(" V/Div : {}/div (index {})\n", s.ch2.vdiv_str(), s.ch2.vdiv_index)); out.push_str(&format!(" Volt scale : {} µV/px\n", - s.ch2.volt_scale_uv as f64 / 25.0)); + s.ch2.volt_scale_uv as f64 / PIXELS_PER_DIV)); out.push_str(&format!(" Zero V (pixels): {} px\n", s.ch2.zero_volt_pixels)); out.push_str(&format!(" Zero V (µV) : {}\n", format_uv(s.ch2.zero_volt_uv, 0))); @@ -543,7 +543,7 @@ impl ScreenshotPacket { } if s.cursors_y_enable { let act_ch = if s.active_channel == 0 { &s.ch1 } else { &s.ch2 }; - let uv_per_px = act_ch.volt_scale_uv as f64 / 25.0; + let uv_per_px = act_ch.volt_scale_uv as f64 / PIXELS_PER_DIV; let dy_px = (s.cursor_y2 - s.cursor_y1).abs() as f64; let dy_uv = (dy_px * uv_per_px) as i64; let y1_uv = (s.cursor_y1 as f64 * uv_per_px) as i64; @@ -943,8 +943,8 @@ mod tests { probe_mode: 0, enabled_measurements: 0, }; - // raw=128 → pixel_val=127 → (127-128-0)*scale = -1 × (100000/25/1e6) ≈ -4mV - let v = ch.pixel_to_volts(128); + // raw=ADC_VALUE_MID → pixel_val=127 → (127-128-0)*scale = -1 × (100000/25/1e6) ≈ -4mV + let v = ch.pixel_to_volts(ADC_VALUE_MID); assert!((v - -0.004).abs() < 1e-6, "v={v}"); } } diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 9ceb8a2..9241841 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -27,3 +27,8 @@ serialport = "4.6" tokio = { version = "1", features = ["sync", "time"] } crc = "3.2" open = "5" +ab_glyph = "0.2" +font-kit = "0.14" + +[dev-dependencies] +ab_glyph = "0.2" diff --git a/gui/src/app.rs b/gui/src/app.rs index 98ac0ba..1d9ec85 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -3,14 +3,16 @@ use std::path::PathBuf; use chrono::{DateTime, Local}; -use dso_parser::{Capture, DEFAULT_BUFFER_DEPTH}; +use dso_parser::{ADC_VALUE_MID, Capture, DEFAULT_BUFFER_DEPTH}; +#[cfg(test)] +use dso_parser::ADC_VALUE_MAX; use iced::widget::{ button, canvas, checkbox, column, container, pick_list, progress_bar, row, rule, scrollable, text, text_input, Space, }; use iced::{Element, Length, Subscription, Task, Theme}; -use crate::canvas::{Scope, Thumbnail}; +use crate::canvas::{Scope, T_CELLS, Thumbnail, V_CELLS}; use crate::range_slider; use crate::serial::{self, SerialConfig, SerialEvent, SerialHandle}; use crate::settings::{Settings, ViewMode}; @@ -317,6 +319,17 @@ impl App { log::info!("{}", self.status); self.current = 0; self.dump = Some(d); + // Auto-populate from the first record's scope_state only if it + // is the record being shown (single-capture files). For multi- + // capture files the user must select a record to apply its + // scope_state via the thumbnail strip. + if self.dump.as_ref().map(|d| d.captures.len() <= 1).unwrap_or(false) { + if let Some(state) = self.current_entry() + .and_then(|e| e.record.scope_state.clone()) + { + self.apply_scope_state(&state); + } + } Task::none() } Message::Loaded(Err(e)) => { @@ -380,6 +393,12 @@ impl App { if let Some(d) = &self.dump { if i < d.captures.len() { self.current = i; + // Clone scope_state before calling apply_scope_state + // to avoid borrow conflict with self.dump. + let scope_state = d.captures[i].record.scope_state.clone(); + if let Some(ref state) = scope_state { + self.apply_scope_state(state); + } } } Task::none() @@ -505,8 +524,10 @@ impl App { self.settings.v_per_div_ch1 = s.ch1.volt_scale_uv as f64 / 1_000_000.0 * probe1; self.settings.v_per_div_ch2 = s.ch2.volt_scale_uv as f64 / 1_000_000.0 * probe2; self.settings.t_per_div_ms = s.timebase_ns_per_div as f64 / 1_000_000.0; - self.settings.v_offset_ch1 = s.ch1.zero_volt_uv as f64 / 1_000_000.0 * probe1; - self.settings.v_offset_ch2 = s.ch2.zero_volt_uv as f64 / 1_000_000.0 * probe2; + // pixel_to_volts at screen centre gives −zero_volt_pixels × vpp × mult, + // but zero_volt_uv stores the value with opposite sign → negate. + self.settings.v_offset_ch1 = -(s.ch1.zero_volt_uv as f64) / 1_000_000.0 * probe1; + self.settings.v_offset_ch2 = -(s.ch2.zero_volt_uv as f64) / 1_000_000.0 * probe2; self.v_per_div_ch1_input = format_float(self.settings.v_per_div_ch1); self.v_per_div_ch2_input = format_float(self.settings.v_per_div_ch2); self.t_per_div_input = format_float(self.settings.t_per_div_ms); @@ -1237,6 +1258,32 @@ impl App { .into() } + /// Apply oscilloscope state metadata (V/div, V/offset, t/div) to the + /// current settings and input fields. Used when loading captures that + /// contain embedded scope_state (screenshot packets or .zwcap files). + /// + /// Note: `v_offset_v` is stored with the same sign as the device's + /// `zero_volt_uv` field. The device's `pixel_to_volts` formula at + /// screen centre gives `−zero_volt_pixels × vpp × mult`, i.e. the + /// *opposite* sign, so we negate here. + fn apply_scope_state(&mut self, state: &dso_parser::ScopeState) { + if let Some(ref ch1) = state.ch1 { + self.settings.v_per_div_ch1 = ch1.vdiv_v; + self.settings.v_offset_ch1 = -ch1.v_offset_v; + } + if let Some(ref ch2) = state.ch2 { + self.settings.v_per_div_ch2 = ch2.vdiv_v; + self.settings.v_offset_ch2 = -ch2.v_offset_v; + } + self.settings.t_per_div_ms = state.timebase_ns_per_div as f64 / 1_000_000.0; + self.v_per_div_ch1_input = format_float(self.settings.v_per_div_ch1); + self.v_per_div_ch2_input = format_float(self.settings.v_per_div_ch2); + self.t_per_div_input = format_float(self.settings.t_per_div_ms); + self.v_offset_ch1_input = format_float(self.settings.v_offset_ch1); + self.v_offset_ch2_input = format_float(self.settings.v_offset_ch2); + self.settings.save(); + } + fn scope_widget(&self) -> Element<'_, Message> { let cap = self.current_capture(); log::trace!( @@ -1286,6 +1333,10 @@ impl App { t_per_div_ms: self.settings.t_per_div_ms, v_offset_ch1: self.settings.v_offset_ch1, v_offset_ch2: self.settings.v_offset_ch2, + active_channel: self.current_entry() + .and_then(|e| e.record.scope_state.as_ref()) + .map(|s| s.active_channel) + .unwrap_or(0), on_click: Some(Message::GraphClicked), }; canvas(scope) @@ -1308,8 +1359,6 @@ impl App { let t_per_div_ms = self.settings.t_per_div_ms; let v_offset_ch1 = self.settings.v_offset_ch1; let v_offset_ch2 = self.settings.v_offset_ch2; - let half_v_range_ch1 = 4.0 * v_per_div_ch1; // 8 div / 2 - let half_v_range_ch2 = 4.0 * v_per_div_ch2; let total_time_ms = 12.0 * t_per_div_ms; let mut items: Vec> = vec![ @@ -1357,10 +1406,10 @@ impl App { if self.settings.measurement_cursor_y_enabled { items.push(range_slider::horizontal(ylo, yhi, Message::MeasCursorYChanged)); - let v_ch1_lo = frac_to_voltage(ylo as f64, half_v_range_ch1, v_offset_ch1); - let v_ch1_hi = frac_to_voltage(yhi as f64, half_v_range_ch1, v_offset_ch1); - let v_ch2_lo = frac_to_voltage(ylo as f64, half_v_range_ch2, v_offset_ch2); - let v_ch2_hi = frac_to_voltage(yhi as f64, half_v_range_ch2, v_offset_ch2); + let v_ch1_lo = frac_to_voltage(ylo as f64, v_per_div_ch1, v_offset_ch1); + let v_ch1_hi = frac_to_voltage(yhi as f64, v_per_div_ch1, v_offset_ch1); + let v_ch2_lo = frac_to_voltage(ylo as f64, v_per_div_ch2, v_offset_ch2); + let v_ch2_hi = frac_to_voltage(yhi as f64, v_per_div_ch2, v_offset_ch2); let dv_ch1 = (v_ch1_hi - v_ch1_lo).abs(); let dv_ch2 = (v_ch2_hi - v_ch2_lo).abs(); items.push( @@ -1407,6 +1456,30 @@ impl App { .align_y(iced::Alignment::Center) .into(), ); + // Probe row (from device metadata, if available) + let probe_ch1 = self.current_entry() + .and_then(|e| e.record.scope_state.as_ref()) + .and_then(|s| s.ch1.as_ref()) + .map(|c| c.probe.clone()) + .unwrap_or_default(); + let probe_ch2 = self.current_entry() + .and_then(|e| e.record.scope_state.as_ref()) + .and_then(|s| s.ch2.as_ref()) + .map(|c| c.probe.clone()) + .unwrap_or_default(); + if !probe_ch1.is_empty() || !probe_ch2.is_empty() { + let p1 = if probe_ch1.is_empty() { "—".to_string() } else { probe_ch1 }; + let p2 = if probe_ch2.is_empty() { "—".to_string() } else { probe_ch2 }; + items.push( + row![ + text("Probe:").size(11).width(col_w), + text(p1).size(11).width(input_w), + text(p2).size(11).width(input_w), + ] + .spacing(2) + .into(), + ); + } // V/off row items.push( row![ @@ -1442,16 +1515,18 @@ impl App { // ── Signal information (per-channel) ── if let Some(cap) = cap { - let v_ch1_min = frac_to_voltage(1.0, half_v_range_ch1, v_offset_ch1); - let v_ch1_max = frac_to_voltage(0.0, half_v_range_ch1, v_offset_ch1); - let v_ch2_min = frac_to_voltage(1.0, half_v_range_ch2, v_offset_ch2); - let v_ch2_max = frac_to_voltage(0.0, half_v_range_ch2, v_offset_ch2); + let (ch1_mn, ch1_mx, _) = stats(&cap.ch1); + let v_ch1_neg = v_offset_ch1 + (ADC_VALUE_MID as f64 - ch1_mx as f64) * v_per_div_ch1 / PX_PER_DIV; + let v_ch1_pos = v_offset_ch1 + (ADC_VALUE_MID as f64 - ch1_mn as f64) * v_per_div_ch1 / PX_PER_DIV; items.push( - text(format!("CH1 range: {v_ch1_min:.2}V … {v_ch1_max:.2}V")).size(11).into(), + text(format!("CH1 range: {v_ch1_neg:.2}V … {v_ch1_pos:.2}V")).size(11).into(), ); - if cap.ch2.is_some() { + if let Some(ch2) = &cap.ch2 { + let (ch2_mn, ch2_mx, _) = stats(ch2); + let v_ch2_neg = v_offset_ch2 + (ADC_VALUE_MID as f64 - ch2_mx as f64) * v_per_div_ch2 / PX_PER_DIV; + let v_ch2_pos = v_offset_ch2 + (ADC_VALUE_MID as f64 - ch2_mn as f64) * v_per_div_ch2 / PX_PER_DIV; items.push( - text(format!("CH2 range: {v_ch2_min:.2}V … {v_ch2_max:.2}V")) + text(format!("CH2 range: {v_ch2_neg:.2}V … {v_ch2_pos:.2}V")) .size(11) .into(), ); @@ -1502,6 +1577,21 @@ impl App { .size(10) .into(), ); + // Channel probe/coupling info + if let Some(ref ch1) = scope_state.ch1 { + items.push( + text(format!("CH1: {} probe, {}", ch1.probe, ch1.coupling)) + .size(10) + .into(), + ); + } + if let Some(ref ch2) = scope_state.ch2 { + items.push( + text(format!("CH2: {} probe, {}", ch2.probe, ch2.coupling)) + .size(10) + .into(), + ); + } items.push( text(format!( "Delay: {:.6} s | Rate: {:.0} Sa/s", @@ -1735,7 +1825,7 @@ pub(crate) fn stats(samples: &[u8]) -> (u8, u8, f32) { /// Used in tests. #[cfg(test)] fn adc_to_voltage(adc: u8) -> f32 { - (adc as f32 - 128.0) * 5.0 / 128.0 + (adc as f32 - ADC_VALUE_MID as f32) * 5.0 / ADC_VALUE_MID as f32 } /// Convert a fractional position (0.0..1.0) on the Y axis to voltage @@ -1755,8 +1845,15 @@ fn format_float(v: f64) -> String { } } -fn frac_to_voltage(frac: f64, half_v_range: f64, v_offset: f64) -> f64 { - v_offset + half_v_range * (1.0 - 2.0 * frac) +/// Grid constants from canvas.rs: 8 divs × 25 px/div = 200 px grid height. +use crate::canvas::{GRID_HEIGHT_PX, PX_PER_DIV}; + +/// Convert a fractional Y position on the graph (0.0 = top, 1.0 = bottom) +/// to voltage. Formula from pixel_to_volts: V = V_offset + (ADC_MID - v) × V/div / PX_PER_DIV, +/// where v = frac × GRID_HEIGHT_PX is the stored ADC value at that position. +fn frac_to_voltage(frac: f64, v_per_div: f64, v_offset: f64) -> f64 { + let v = frac * GRID_HEIGHT_PX; + v_offset + (ADC_VALUE_MID as f64 - v) * v_per_div / PX_PER_DIV } /// Convert sample count to time in milliseconds at the default 1 MHz sample rate. @@ -1772,7 +1869,7 @@ fn samples_to_time_ms(n: usize) -> f64 { /// Thumbnails strip: scrollable row of mini waveform canvases. /// Each thumbnail shows the signal shape and is clickable. fn thumbnails<'a>(dump: &'a Option, current: usize, can_undo: bool) -> Element<'a, Message> { - let is_empty = dump.as_ref().map_or(true, |d| d.captures.is_empty()); + let is_empty = dump.as_ref().is_none_or(|d| d.captures.is_empty()); if is_empty { if can_undo { return container( @@ -2064,10 +2161,17 @@ fn export_csv(path: &std::path::Path, cap: &Capture) -> anyhow::Result<()> { Ok(()) } +// ── PNG export colour palette (mirrors canvas.rs constants) ──────────────── +const PNG_BG: [u8; 3] = [10, 15, 26]; +const PNG_GRID: [u8; 3] = [51, 61, 77]; +const PNG_CH1: [u8; 3] = [255, 217, 25]; +const PNG_CH2: [u8; 3] = [242, 25, 242]; +const PNG_SCALE_TEXT: [u8; 3] = [153, 166, 179]; + /// Export the current capture as a PNG image. fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> anyhow::Result<()> { - let graph_w: u32 = 1200; - let graph_h: u32 = 600; + let graph_w: u32 = T_CELLS as u32 * 50; + let graph_h: u32 = V_CELLS as u32 * 50; let scale_margin_left: u32 = if settings.show_scales { 50 } else { 0 }; let scale_margin_bottom: u32 = if settings.show_scales { 16 } else { 0 }; @@ -2082,23 +2186,22 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any }; let mut pixels = vec![0u8; (width * height * 3) as usize]; - let bg = [10u8, 15, 26]; for chunk in pixels.chunks_exact_mut(3) { - chunk.copy_from_slice(&bg); + chunk.copy_from_slice(&PNG_BG); } // Helper to draw a grid into a sub-region of the pixel buffer. let draw_grid = |pixels: &mut Vec, x_off: u32, y_off: u32, w: u32, h: u32| { - let grid_color = [51u8, 61, 77]; - for col in 1..10u32 { - let x = x_off + col * w / 10; + let grid_color = PNG_GRID; + for col in 1..T_CELLS as u32 { + let x = x_off + col * w / T_CELLS as u32; for y in y_off..y_off + h { let idx = ((y * width + x) * 3) as usize; pixels[idx..idx + 3].copy_from_slice(&grid_color); } } - for r in 1..8u32 { - let y = y_off + r * h / 8; + for r in 1..V_CELLS as u32 { + let y = y_off + r * h / V_CELLS as u32; for x in x_off..x_off + w { let idx = ((y * width + x) * 3) as usize; pixels[idx..idx + 3].copy_from_slice(&grid_color); @@ -2116,10 +2219,9 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any let sample_y = |px: u32| -> u32 { let i = (px as f64 / w as f64 * (n - 1) as f64) as usize; let v = samples[i.min(n - 1)]; - // Capture values use screen-coordinate convention: - // low value = top of screen = positive voltage. - let py = ((v as f64 / 255.0) * (h - 1) as f64) as u32; - py.min(h - 1) + // Match draw_trace: subtract PX_PER_DIV so v=25 maps to y=0 (top). + let yf = ((v as f64 - PX_PER_DIV) / GRID_HEIGHT_PX) * (h - 1) as f64; + yf.round().max(0.0).min((h - 1) as f64) as u32 }; let mut prev_y = sample_y(0); @@ -2161,8 +2263,8 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any vpd_ch1: f64, vo_ch1: f64, ch1_color: Option<[u8; 3]>, vpd_ch2: f64, vo_ch2: f64, ch2_color: Option<[u8; 3]>| { // X axis: time labels at bottom - let cols = 10u32; - let total_time_ms = 12.0 * t_per_div_ms; + let cols = T_CELLS as u32; + let total_time_ms = T_CELLS as f64 * t_per_div_ms; for i in 0..=cols { let frac = i as f64 / cols as f64; let time_ms = frac * total_time_ms; @@ -2173,81 +2275,68 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any }; let x_pos = x_off + i * w / cols + 2; let y_pos = y_off + h + 2; - draw_text_tiny(pixels, width, &label, x_pos, y_pos, [153, 166, 179]); + draw_text_tiny(pixels, width, height, &label, x_pos, y_pos, PNG_SCALE_TEXT); } - // Y axis: voltage labels on left margin, per-channel - let rows = 8u32; - let x_ch1 = 2u32; - let x_ch2 = if ch1_color.is_some() { 26u32 } else { 2u32 }; - // CH1 labels - if let Some(c) = ch1_color { - let half_v_range = 4.0 * vpd_ch1; + // Y axis: voltage labels on left margin, per-channel. + // Formula from pixel_to_volts: V = V_offset + (ADC_MID - v) × V/div / PX_PER_DIV. + // Match draw_scales: use (i+1) offset so v=25 maps to top label. + let rows = V_CELLS as u32; + let mut draw_voltage_label = |vpd: f64, vo: f64, color: [u8; 3], x_lbl: u32| { for i in 0..=rows { - let frac = i as f64 / rows as f64; - let voltage = vo_ch1 + half_v_range * (1.0 - 2.0 * frac); - let label = format!("{voltage:.1}"); + let v = ((i + 1) as f64 / rows as f64) * GRID_HEIGHT_PX; + let voltage = vo + (ADC_VALUE_MID as f64 - v) * vpd / PX_PER_DIV; let y_pos = y_off + (i * h / rows); - draw_text_tiny(pixels, width, &label, x_ch1, y_pos, c); + draw_text_tiny(pixels, width, height, &format!("{voltage:.1}"), x_lbl, y_pos, color); } - } - // CH2 labels - if let Some(c) = ch2_color { - let half_v_range = 4.0 * vpd_ch2; - for i in 0..=rows { - let frac = i as f64 / rows as f64; - let voltage = vo_ch2 + half_v_range * (1.0 - 2.0 * frac); - let label = format!("{voltage:.1}"); - let y_pos = y_off + (i * h / rows); - draw_text_tiny(pixels, width, &label, x_ch2, y_pos, c); - } - } + }; + + let x_ch1 = 2u32; + let x_ch2 = if ch1_color.is_some() { 26u32 } else { 2u32 }; + if let Some(c) = ch1_color { draw_voltage_label(vpd_ch1, vo_ch1, c, x_ch1); } + if let Some(c) = ch2_color { draw_voltage_label(vpd_ch2, vo_ch2, c, x_ch2); } }; - if do_split { - let sub_h = graph_h; - // Upper subimage: CH1 - let y_off_1 = 0u32; - draw_grid(&mut pixels, scale_margin_left, y_off_1, graph_w, sub_h); - draw_ch(&mut pixels, &cap.ch1, [255, 217, 25], scale_margin_left, y_off_1, graph_w, sub_h); - if settings.show_scales { - let ch1_col = Some([255u8, 217, 25]); + // Draw one channel into a region: grid + trace + optional scale labels. + let mut draw_one = |samples: &[u8], trace_color: [u8; 3], + y_off: u32, vpd: f64, vo: f64, ch_col: Option<[u8; 3]>| { + draw_grid(&mut pixels, scale_margin_left, y_off, graph_w, graph_h); + if !samples.is_empty() { + draw_ch(&mut pixels, samples, trace_color, scale_margin_left, y_off, graph_w, graph_h); + } + if let Some(c) = ch_col { draw_scales_on_region( - &mut pixels, scale_margin_left, y_off_1, graph_w, sub_h, cap.ch1.len(), - settings.v_per_div_ch1, settings.v_offset_ch1, ch1_col, - settings.v_per_div_ch2, settings.v_offset_ch2, None, + &mut pixels, scale_margin_left, y_off, graph_w, graph_h, samples.len(), + vpd, vo, Some(c), 0.0, 0.0, None, ); } + }; + + if do_split { + // Upper subimage: CH1 + let ch1_col = settings.show_scales.then_some(PNG_CH1); + draw_one(&cap.ch1, PNG_CH1, 0, settings.v_per_div_ch1, settings.v_offset_ch1, ch1_col); // Lower subimage: CH2 - let y_off_2 = graph_h + scale_margin_bottom; - draw_grid(&mut pixels, scale_margin_left, y_off_2, graph_w, sub_h); + let ch2_col = settings.show_scales.then_some(PNG_CH2); if let Some(ch2) = &cap.ch2 { - draw_ch(&mut pixels, ch2, [38, 217, 255], scale_margin_left, y_off_2, graph_w, sub_h); - } - if settings.show_scales { - let ch2_col = Some([38u8, 217, 255]); - let n = cap.ch2.as_ref().map(|c| c.len()).unwrap_or(cap.ch1.len()); - draw_scales_on_region( - &mut pixels, scale_margin_left, y_off_2, graph_w, sub_h, n, - settings.v_per_div_ch1, settings.v_offset_ch1, None, - settings.v_per_div_ch2, settings.v_offset_ch2, ch2_col, - ); + draw_one(ch2, PNG_CH2, graph_h + scale_margin_bottom, + settings.v_per_div_ch2, settings.v_offset_ch2, ch2_col); } } else { // Single combined image draw_grid(&mut pixels, scale_margin_left, 0, graph_w, graph_h); if settings.show_ch1 { - draw_ch(&mut pixels, &cap.ch1, [255, 217, 25], scale_margin_left, 0, graph_w, graph_h); + draw_ch(&mut pixels, &cap.ch1, PNG_CH1, scale_margin_left, 0, graph_w, graph_h); } if settings.show_ch2 { if let Some(ch2) = &cap.ch2 { - draw_ch(&mut pixels, ch2, [38, 217, 255], scale_margin_left, 0, graph_w, graph_h); + draw_ch(&mut pixels, ch2, PNG_CH2, scale_margin_left, 0, graph_w, graph_h); } } if settings.show_scales { - let ch1_col = if settings.show_ch1 { Some([255u8, 217, 25]) } else { None }; - let ch2_col = if settings.show_ch2 && has_ch2 { Some([38u8, 217, 255]) } else { None }; + let ch1_col = if settings.show_ch1 { Some(PNG_CH1) } else { None }; + let ch2_col = if settings.show_ch2 && has_ch2 { Some(PNG_CH2) } else { None }; draw_scales_on_region( &mut pixels, scale_margin_left, 0, graph_w, graph_h, cap.ch1.len(), settings.v_per_div_ch1, settings.v_offset_ch1, ch1_col, @@ -2260,8 +2349,23 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any Ok(()) } -/// Draw a string using a tiny 3x5 pixel font. Each character is 4px wide (3+1 spacing). -fn draw_text_tiny(pixels: &mut [u8], img_width: u32, text: &str, x: u32, y: u32, color: [u8; 3]) { +/// Threshold below which the tiny (3×5) bitmap font is used instead of a system font. +const TINY_FONT_MAX_HEIGHT: u32 = 1000; + +/// Draw text onto a raw RGB pixel buffer. +/// +/// Uses the built-in 3×5 bitmap font when `img_height ≤ TINY_FONT_MAX_HEIGHT`, +/// otherwise renders with the system monospace font via `ab_glyph`. +fn draw_text_tiny(pixels: &mut [u8], img_width: u32, img_height: u32, text: &str, x: u32, y: u32, color: [u8; 3]) { + if img_height <= TINY_FONT_MAX_HEIGHT { + draw_tiny_glyph(pixels, img_width, text, x, y, color); + } else { + draw_system_font(pixels, img_width, text, x, y, color); + } +} + +/// Render text with the built-in 3×5 bitmap font. +fn draw_tiny_glyph(pixels: &mut [u8], img_width: u32, text: &str, x: u32, y: u32, color: [u8; 3]) { let mut cx = x; for ch in text.chars() { let glyph = tiny_glyph(ch); @@ -2281,6 +2385,118 @@ fn draw_text_tiny(pixels: &mut [u8], img_width: u32, text: &str, x: u32, y: u32, } } +/// Render text using a system monospace font via `ab_glyph`. +/// Render text into an RGB pixel buffer using the operating system's +/// default monospace font family. +fn draw_system_font( + pixels: &mut [u8], + img_width: u32, + text: &str, + x: u32, + y: u32, + color: [u8; 3], +) { + use ab_glyph::{Font, FontArc, PxScale, ScaleFont}; + use font_kit::{ + family_name::FamilyName, + properties::Properties, + source::SystemSource, + }; + use std::sync::OnceLock; + + /// Resolve the default system monospace family and convert it to an + /// `ab_glyph` font. + fn load_system_monospace() -> Option { + let handle = SystemSource::new() + .select_best_match( + &[FamilyName::Monospace], + &Properties::new(), + ) + .ok()?; + + // Extract font data and index from the handle, then load via ab_glyph. + let (font_data, font_index) = match &handle { + font_kit::handle::Handle::Memory { bytes, font_index } => { + ((**bytes).clone(), *font_index) + } + font_kit::handle::Handle::Path { path, font_index } => { + (std::fs::read(path).ok()?, *font_index) + } + }; + + let font = ab_glyph::FontVec::try_from_vec_and_index(font_data, font_index).ok()?; + Some(FontArc::new(font)) + } + + static FONT: OnceLock> = OnceLock::new(); + + let Some(font) = FONT.get_or_init(load_system_monospace).as_ref() else { + return; + }; + + const FONT_SIZE: f32 = 11.0; + + let scaled_font = font.as_scaled(PxScale::from(FONT_SIZE)); + let baseline = y as f32 + scaled_font.ascent(); + + let mut cursor_x = x as f32; + + for ch in text.chars() { + let glyph_id = font.glyph_id(ch); + + let glyph = glyph_id.with_scale_and_position( + FONT_SIZE, + ab_glyph::point(cursor_x, baseline), + ); + + if let Some(outlined) = font.outline_glyph(glyph) { + let bounds = outlined.px_bounds(); + + outlined.draw(|local_x, local_y, coverage| { + let pixel_x = bounds.min.x.floor() as i32 + local_x as i32; + let pixel_y = bounds.min.y.floor() as i32 + local_y as i32; + + if pixel_x < 0 || pixel_y < 0 { + return; + } + + let pixel_x = pixel_x as u32; + let pixel_y = pixel_y as u32; + + if pixel_x >= img_width { + return; + } + + let index = + (pixel_y as usize * img_width as usize + pixel_x as usize) * 3; + + let Some(destination) = pixels.get_mut(index..index + 3) else { + return; + }; + + blend_rgb(destination, color, coverage); + }); + } + + cursor_x += scaled_font.h_advance(glyph_id); + } +} + +/// Alpha-blend one RGB color over another. +fn blend_rgb(destination: &mut [u8], foreground: [u8; 3], alpha: f32) { + let alpha = alpha.clamp(0.0, 1.0); + let inverse_alpha = 1.0 - alpha; + + for channel in 0..3 { + destination[channel] = ( + foreground[channel] as f32 * alpha + + destination[channel] as f32 * inverse_alpha + ) + .round() + .clamp(0.0, 255.0) as u8; + } +} + /// Returns a 5-row bitmask (3 bits wide) for a character. fn tiny_glyph(ch: char) -> [u8; 5] { match ch { @@ -2519,10 +2735,10 @@ mod tests { #[test] fn adc_voltage_conversion() { - // 128 → 0V (center), 0 → -5V, 255 → ~+5V - assert!((adc_to_voltage(128) - 0.0).abs() < 0.05); + // ADC_VALUE_MID → 0V (center), 0 → -5V, ADC_VALUE_MAX → ~+5V + assert!((adc_to_voltage(ADC_VALUE_MID) - 0.0).abs() < 0.05); assert!((adc_to_voltage(0) - (-5.0)).abs() < 0.05); - assert!((adc_to_voltage(255) - 4.96).abs() < 0.1); + assert!((adc_to_voltage(ADC_VALUE_MAX) - 4.96).abs() < 0.1); } #[test] diff --git a/gui/src/canvas.rs b/gui/src/canvas.rs index be47532..844a146 100644 --- a/gui/src/canvas.rs +++ b/gui/src/canvas.rs @@ -1,7 +1,7 @@ //! Oscilloscope-style graph canvas (iced::widget::canvas). use crate::settings::ViewMode; -use dso_parser::Capture; +use dso_parser::{ADC_VALUE_MAX, ADC_VALUE_MID, Capture}; use iced::widget::canvas::{self, Frame, Geometry, Path, Stroke, Text}; use iced::{mouse, Color, Point, Rectangle, Renderer, Size, Theme}; @@ -37,6 +37,8 @@ pub struct Scope<'a, Message = ()> { pub v_offset_ch1: f64, /// Voltage offset for CH2 (voltage at graph center). pub v_offset_ch2: f64, + /// Active channel (0 = CH1, 1 = CH2). Used for device cursor voltage. + pub active_channel: u8, /// Callback message factory for graph clicks: (frac_x, frac_y). pub on_click: Option Message>, } @@ -59,13 +61,24 @@ const BG: Color = Color::from_rgb(0.04, 0.06, 0.10); const GRID: Color = Color::from_rgb(0.20, 0.24, 0.30); const GRID_AXIS: Color = Color::from_rgb(0.35, 0.40, 0.46); const CH1_COLOR: Color = Color::from_rgb(1.0, 0.85, 0.10); -const CH2_COLOR: Color = Color::from_rgb(0.15, 0.85, 1.0); +const CH2_COLOR: Color = Color::from_rgb(0.95, 0.10, 0.95); /// User-driven measurement cursors (interactive). const MEAS_CURSOR: Color = Color::from_rgb(1.0, 0.30, 0.50); const MEAS_FILL: Color = Color::from_rgba(1.0, 0.30, 0.50, 0.08); /// Device-supplied cursors (read-only). const DEV_CURSOR: Color = Color::from_rgb(0.40, 1.0, 0.40); +/// Build a `Text` label for a device cursor with consistent styling. +fn dev_label(content: String, position: Point) -> Text { + Text { + content, + position, + color: DEV_CURSOR, + size: 10.0.into(), + ..Text::default() + } +} + impl canvas::Program for Scope<'_, Message> { type State = ScopeState; @@ -153,12 +166,14 @@ impl canvas::Program for Scope<'_, Message> { draw_grid(&mut frame, bounds.size()); if let Some(cap) = self.capture { - if self.show_ch1 { - draw_trace(&mut frame, &cap.ch1, CH1_COLOR, self.view_mode); - } - if self.show_ch2 { - if let Some(ch2) = cap.ch2.as_ref() { - draw_trace(&mut frame, ch2, CH2_COLOR, self.view_mode); + // Draw traces for each enabled channel. + let channels = [ + (self.show_ch1, Some(&cap.ch1[..]) as Option<&[u8]>, CH1_COLOR), + (self.show_ch2, cap.ch2.as_deref(), CH2_COLOR), + ]; + for &(show, data, color) in &channels { + if let Some(samples) = show.then_some(data).flatten() { + draw_trace(&mut frame, samples, color, self.view_mode); } } } else { @@ -216,25 +231,26 @@ impl canvas::Program for Scope<'_, Message> { dev_stroke, ); } - // Show numeric values next to device X cursors - let sample_a = (a * self.n_samples as f32) as usize; - let sample_b = (b * self.n_samples as f32) as usize; - let label_a = Text { - content: format!("{sample_a}"), - position: Point::new(a.clamp(0.0, 1.0) * w + 2.0, 12.0), - color: DEV_CURSOR, - size: 10.0.into(), - ..Text::default() + // Show time values next to device X cursors. + let total_time_ms = T_CELLS as f64 * self.t_per_div_ms; + let fmt_time = |frac: f32| -> String { + let t_ms = frac as f64 * total_time_ms; + if t_ms >= 1.0 { + format!("{:.1}ms", t_ms) + } else { + format!("{:.0}µs", t_ms * 1000.0) + } }; - let label_b = Text { - content: format!("{sample_b}"), - position: Point::new(b.clamp(0.0, 1.0) * w + 2.0, 12.0), - color: DEV_CURSOR, - size: 10.0.into(), - ..Text::default() + frame.fill_text(dev_label(fmt_time(a), Point::new(a.clamp(0.0, 1.0) * w + 2.0, 12.0))); + frame.fill_text(dev_label(fmt_time(b), Point::new(b.clamp(0.0, 1.0) * w + 2.0, 12.0))); + // Also show Δt between cursors. + let dt_ms = (b - a).abs() as f64 * total_time_ms; + let dt_label = if dt_ms >= 1.0 { + format!("Δt={:.1}ms", dt_ms) + } else { + format!("Δt={:.0}µs", dt_ms * 1000.0) }; - frame.fill_text(label_a); - frame.fill_text(label_b); + frame.fill_text(dev_label(dt_label, Point::new(4.0, 22.0))); } if let Some((a, b)) = self.device_cursor_y { let w = frame.width(); @@ -246,25 +262,23 @@ impl canvas::Program for Scope<'_, Message> { dev_stroke, ); } - // Show numeric voltage values next to device Y cursors - let v_a = (a * 255.0 - 128.0) * 5.0 / 128.0; - let v_b = (b * 255.0 - 128.0) * 5.0 / 128.0; - let label_a = Text { - content: format!("{v_a:.2}V"), - position: Point::new(4.0, a.clamp(0.0, 1.0) * h + 2.0), - color: DEV_CURSOR, - size: 10.0.into(), - ..Text::default() + // Show numeric voltage values next to device Y cursors. + // Use the active channel's V/div and V_offset for correct voltage. + let (v_per_div, v_offset) = if self.active_channel == 0 { + (self.v_per_div_ch1, self.v_offset_ch1) + } else { + (self.v_per_div_ch2, self.v_offset_ch2) }; - let label_b = Text { - content: format!("{v_b:.2}V"), - position: Point::new(4.0, b.clamp(0.0, 1.0) * h + 2.0), - color: DEV_CURSOR, - size: 10.0.into(), - ..Text::default() + let cursor_to_voltage = |frac: f32| -> f64 { + // Map fraction (0=top, 1=bottom) to pixel_val (255=top, 0=bottom) + // using the full 0..255 ADC range to match pixel_to_volts. + let pixel_val = (1.0 - frac as f64) * ADC_VALUE_MAX as f64; + v_offset + (pixel_val - ADC_VALUE_MID as f64) * v_per_div / PX_PER_DIV }; - frame.fill_text(label_a); - frame.fill_text(label_b); + let v_a = cursor_to_voltage(a); + let v_b = cursor_to_voltage(b); + frame.fill_text(dev_label(format!("{v_a:.2}V"), Point::new(4.0, a.clamp(0.0, 1.0) * h + 2.0))); + frame.fill_text(dev_label(format!("{v_b:.2}V"), Point::new(4.0, b.clamp(0.0, 1.0) * h + 2.0))); } // Axis scales (sample numbers on X, voltage on Y per-channel) @@ -288,8 +302,8 @@ impl canvas::Program for Scope<'_, Message> { fn draw_grid(frame: &mut Frame, size: Size) { // 12 horizontal x 8 vertical divisions, like a real scope. - let cols = 12; - let rows = 8; + let cols = T_CELLS; + let rows = V_CELLS; let dx = size.width / cols as f32; let dy = size.height / rows as f32; @@ -321,9 +335,13 @@ fn draw_grid(frame: &mut Frame, size: Size) { const SCALE_COLOR: Color = Color::from_rgb(0.60, 0.65, 0.70); /// Number of vertical divisions on the oscilloscope screen for voltage. -const V_CELLS: usize = 8; +pub const V_CELLS: usize = 8; /// Number of horizontal divisions on the oscilloscope screen for time. -const T_CELLS: usize = 12; +pub const T_CELLS: usize = 12; +/// Device pixel density: 25 px/division. Re-exported from dso_parser. +pub use dso_parser::PIXELS_PER_DIV as PX_PER_DIV; +/// Total pixels for the full grid height: V_CELLS × PX_PER_DIV. +pub const GRID_HEIGHT_PX: f64 = V_CELLS as f64 * PX_PER_DIV; #[allow(clippy::too_many_arguments)] fn draw_scales( @@ -364,41 +382,32 @@ fn draw_scales( } // Y axis: voltage scale per channel. + // Formula from pixel_to_volts: V = V_offset + (ADC_MID - v) × V/div / PIXELS_PER_DIV, + // where v = i × PX_PER_DIV is the stored value at grid line i. let ch1_x_offset = 2.0; let ch2_x_offset = if has_ch1 { 42.0 } else { 2.0 }; - if has_ch1 { - let half_range_v = (rows as f64 * v_per_div_ch1) / 2.0; + let mut label_ch = |v_per_div: f64, v_offset: f64, color: Color, x_off: f32| { for i in 0..=rows { - let frac = i as f32 / rows as f32; - let voltage = v_offset_ch1 + half_range_v * (1.0 - 2.0 * frac as f64); - let y = frac * size.height; + let v = ((i + 1) as f64 / rows as f64) * GRID_HEIGHT_PX; + let voltage = v_offset + (ADC_VALUE_MID as f64 - v) * v_per_div / PX_PER_DIV; + let y = (i as f32 / rows as f32) * size.height; let label = Text { content: format!("{voltage:.2}V"), - position: Point::new(ch1_x_offset, y + 2.0), - color: CH1_COLOR, + position: Point::new(x_off, y + 2.0), + color, size: 9.0.into(), ..Text::default() }; frame.fill_text(label); } - } + }; + if has_ch1 { + label_ch(v_per_div_ch1, v_offset_ch1, CH1_COLOR, ch1_x_offset); + } if has_ch2 { - let half_range_v = (rows as f64 * v_per_div_ch2) / 2.0; - for i in 0..=rows { - let frac = i as f32 / rows as f32; - let voltage = v_offset_ch2 + half_range_v * (1.0 - 2.0 * frac as f64); - let y = frac * size.height; - let label = Text { - content: format!("{voltage:.2}V"), - position: Point::new(ch2_x_offset, y + 2.0), - color: CH2_COLOR, - size: 9.0.into(), - ..Text::default() - }; - frame.fill_text(label); - } + label_ch(v_per_div_ch2, v_offset_ch2, CH2_COLOR, ch2_x_offset); } } @@ -432,7 +441,8 @@ fn draw_trace(frame: &mut Frame, samples: &[u8], color: Color, mode: ViewMode) { let x = (i as f32 / (n - 1).max(1) as f32) * size.width; // Capture values use screen-coordinate convention: low value = top // of screen = positive voltage, high value = bottom = negative voltage. - let y = (v / 255.0) * size.height; + // Use device pixel density: GRID_HEIGHT_PX = V_CELLS × PX_PER_DIV. + let y = ((v - PX_PER_DIV as f32) / GRID_HEIGHT_PX as f32) * size.height; Point::new(x, y) }; @@ -498,9 +508,11 @@ impl canvas::Program for Thumbnail<'_> { let border = Path::rectangle(Point::ORIGIN, size); frame.stroke(&border, Stroke::default().with_color(border_color).with_width(if self.selected { 2.0 } else { 1.0 })); - // Draw CH1 trace - let samples = &self.capture.ch1; - if !samples.is_empty() { + // Draw miniature traces for each channel. + let mut draw_mini = |samples: &[u8], color: Color| { + if samples.is_empty() { + return; + } let n = samples.len(); let max_pts = (size.width as usize).max(2); let step = n.div_ceil(max_pts).max(1); @@ -508,7 +520,7 @@ impl canvas::Program for Thumbnail<'_> { let mut started = false; for i in (0..n).step_by(step) { let x = (i as f32 / (n - 1).max(1) as f32) * size.width; - let y = (samples[i] as f32 / 255.0) * size.height; + let y = ((samples[i] - PX_PER_DIV as u8) as f32 / GRID_HEIGHT_PX as f32) * size.height; let p = Point::new(x, y); if started { builder.line_to(p); @@ -518,31 +530,12 @@ impl canvas::Program for Thumbnail<'_> { } } }); - frame.stroke(&path, Stroke::default().with_color(CH1_COLOR).with_width(1.0)); - } + frame.stroke(&path, Stroke::default().with_color(color).with_width(1.0)); + }; - // Draw CH2 if present + draw_mini(&self.capture.ch1, CH1_COLOR); if let Some(ch2) = &self.capture.ch2 { - if !ch2.is_empty() { - let n = ch2.len(); - let max_pts = (size.width as usize).max(2); - let step = n.div_ceil(max_pts).max(1); - let path = Path::new(|builder| { - let mut started = false; - for i in (0..n).step_by(step) { - let x = (i as f32 / (n - 1).max(1) as f32) * size.width; - let y = (ch2[i] as f32 / 255.0) * size.height; - let p = Point::new(x, y); - if started { - builder.line_to(p); - } else { - builder.move_to(p); - started = true; - } - } - }); - frame.stroke(&path, Stroke::default().with_color(CH2_COLOR).with_width(1.0)); - } + draw_mini(ch2, CH2_COLOR); } vec![frame.into_geometry()] From f6a39590177224817b7bcd5d25b391223dc44562 Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Wed, 15 Jul 2026 18:05:49 +0300 Subject: [PATCH 11/23] GUI: refactored instruction page --- gui/src/app.rs | 168 ++++++++++++++++++++++++++++++++++++------------ gui/src/main.rs | 1 + 2 files changed, 128 insertions(+), 41 deletions(-) diff --git a/gui/src/app.rs b/gui/src/app.rs index 1d9ec85..253823d 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -61,6 +61,8 @@ pub enum Message { GraphClicked(f32, f32), // Misc ToggleInstructions(bool), + OpenInstructions, + CloseInstructions, ToggleAlertOnData(bool), ToggleAutoListen(bool), // Settings dialog @@ -127,6 +129,8 @@ pub struct App { capture_progress: usize, /// Settings dialog open flag. show_settings: bool, + /// Instructions overlay open flag. + show_instructions: bool, /// Editable fields in settings dialog. settings_baud_input: String, settings_port_input: String, @@ -187,6 +191,7 @@ impl App { serial_rx: None, capture_progress: 0, show_settings: false, + show_instructions: false, settings_baud_input: baud_str, settings_port_input: port_str, show_firmware: false, @@ -701,6 +706,14 @@ impl App { self.settings.save(); Task::none() } + Message::OpenInstructions => { + self.show_instructions = true; + Task::none() + } + Message::CloseInstructions => { + self.show_instructions = false; + Task::none() + } Message::OpenSettings => { self.show_settings = true; self.settings_baud_input = self.settings.baud_rate.to_string(); @@ -975,6 +988,9 @@ impl App { if self.show_firmware { return self.firmware_dialog(); } + if self.show_instructions { + return self.instructions_overlay(); + } if self.show_settings { return self.settings_dialog(); } @@ -1003,23 +1019,12 @@ impl App { let thumbs = thumbnails(&self.dump, self.current, self.deleted_capture.is_some()); let actions = self.action_buttons(); - let instructions: Element<'_, Message> = if self.settings.show_instructions { - instructions_panel() - } else { - Space::new().height(Length::Fixed(0.0)).into() - }; - let bottom = column![ controls, rule::horizontal(1), thumbs, rule::horizontal(1), actions, - rule::horizontal(1), - checkbox(self.settings.show_instructions) - .label("Show instructions") - .on_toggle(Message::ToggleInstructions), - instructions, ] .spacing(4) .padding(4); @@ -1219,6 +1224,30 @@ impl App { .into() } + fn instructions_overlay(&self) -> Element<'_, Message> { + let title = text("Instructions").size(22); + + let content = instructions_panel(); + + let close_btn = button(text("Close").size(13)).on_press(Message::CloseInstructions); + + container( + column![ + title, + Space::new().height(Length::Fixed(8.0)), + content, + Space::new().height(Length::Fixed(16.0)), + close_btn, + ] + .spacing(4) + .padding(20) + .max_width(700), + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + } + fn menu_bar(&self) -> Element<'_, Message> { let port_display = match &self.settings.serial_port { Some(p) => text(p.clone()).size(12), @@ -1239,6 +1268,8 @@ impl App { .placeholder("Port…") .text_size(12); + let help_btn = button(text("Help").size(12)).on_press(Message::OpenInstructions); + let status_row = row![ text("DSO3D12").size(14), Space::new().width(Length::Fixed(12.0)), @@ -1247,6 +1278,8 @@ impl App { listen_btn, Space::new().width(Length::Fill), text(self.status.clone()).size(12), + Space::new().width(Length::Fixed(8.0)), + help_btn ] .spacing(6) .align_y(iced::Alignment::Center); @@ -1684,10 +1717,16 @@ impl App { } } - container(column(items).spacing(4).padding(6)) - .width(Length::Fixed(240.0)) - .style(container::rounded_box) - .into() + scrollable( + container(column(items).spacing(4).padding(6)) + .width(Length::Fixed(240.0)) + .style(container::rounded_box), + ) + .direction(scrollable::Direction::Vertical( + scrollable::Scrollbar::new(), + )) + .width(Length::Fixed(240.0)) + .into() } fn controls_panel(&self) -> Element<'_, Message> { @@ -1766,42 +1805,89 @@ impl App { } // ── Helpers ──────────────────────────────────────────────────────────────── - fn instructions_panel<'a>() -> Element<'a, Message> { - let standard_fw = "Standard firmware (raw debug dump):\n\n\ - 1. Feed a signal into the scope.\n\ - 2. Press Stop to freeze the sample buffer.\n\ - 3. Press Menu → Stop so Channel 1\n Measurements menu is on screen.\n\ - 4. Long-press the Save button to start\n the debug data transmission.\n\ - 5. Wait until the transfer completes\n (~300 kB at 115200 baud)."; - - let screenshot_fw = "Customized firmware:\n\ - 1. Capture a waveform on the device\n (press Save while running or stopped).\n\ - 2. Press Menu → Gallery to open\n the saved screenshots list.\n\ - 3. Select the waveform to transmit.\n\ - 4. The device sends a 2048-byte binary\n packet automatically over USB-serial.\n\ - 5. Includes full metadata: V/div, timebase,\n trigger, cursors, measurements."; - - let left = container(text(standard_fw).size(11)) - .padding(6) - .width(Length::FillPortion(1)); - let right = container( + // Keep each complete instruction block in a separate string so it can be + // moved into locale files later without restructuring the widget tree. + let standard_firmware_instructions = "\ +Standard firmware — raw debug dump + +1. Connect the oscilloscope to the computer using a USB Type-C cable. +2. Feed a signal into the oscilloscope. +3. Press Stop to freeze the sample buffer. +4. Press Menu → Stop and leave the Channel 1 Measurements menu open. +5. In the application, select the serial port and click Listen. +6. Long-press the Save button on the oscilloscope to start transmitting the debug data. +7. Wait for the transfer to complete. The oscilloscope sends approximately 300 kB at 115200 baud."; + + let customized_firmware_instructions = "\ +Customized firmware + +1. Connect the oscilloscope to the computer using a USB Type-C cable. +2. In the application, select the serial port and click Listen. +3. Capture a waveform on the oscilloscope by pressing Save while acquisition is running or stopped. +4. Press Menu → Gallery to open the saved screenshots. +5. Select the waveform that you want to transmit. +6. The oscilloscope automatically sends a 2048-byte binary packet over the USB serial connection. +7. The packet includes waveform data and metadata such as V/div, timebase, trigger settings, cursors, and measurements."; + + let application_instructions = "\ +Using the waveform viewer + +The application can receive oscilloscope data in both supported transmission modes. + +After a waveform is received, you can inspect it, measure voltage and time intervals with cursors, and export the result as a PNG image or CSV data. + +Before receiving data, connect the oscilloscope using a USB Type-C cable, select the correct serial port, and click Listen. Keep the application listening while starting the transmission from the oscilloscope."; + + let standard_firmware = container( + text(standard_firmware_instructions) + .size(11) + .width(Length::Fill), + ) + .padding(6) + .width(Length::FillPortion(1)); + + let customized_firmware = container( column![ - button(text("ZeeTweak (GitHub)").size(11)) - .on_press(Message::OpenUrl("https://github.com/taligentx/ZeeTweak".to_string())) + button(text("ZeeTweak on GitHub").size(11)) + .on_press(Message::OpenUrl( + "https://github.com/taligentx/ZeeTweak".to_owned(), + )) .style(button::text), - text(screenshot_fw).size(11), + text(customized_firmware_instructions) + .size(11) + .width(Length::Fill), ] - .spacing(2), + .spacing(6), ) - .padding(6) - .width(Length::FillPortion(1)); + .padding(6) + .width(Length::FillPortion(1)); + + let application = container( + text(application_instructions) + .size(11) + .width(Length::Fill), + ) + .padding(6) + .width(Length::Fill); container( - row![left, rule::vertical(1), right] + column![ + row![ + standard_firmware, + rule::vertical(1), + customized_firmware, + ] .spacing(4) + .width(Length::Fill), + rule::horizontal(1), + application, + ] + .spacing(4) + .width(Length::Fill), ) .padding(4) + .width(Length::Fill) .style(container::rounded_box) .into() } diff --git a/gui/src/main.rs b/gui/src/main.rs index 8a6f0b4..4c371f5 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -29,6 +29,7 @@ fn main() -> iced::Result { .window(window::Settings { icon, size: iced::Size::new(w, h), + min_size: Some(iced::Size::new(800.0, 500.0)), ..Default::default() }) .run() From a673cb580d1e89c0a664f6c8aa346bdb24c6f427 Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Wed, 15 Jul 2026 18:21:30 +0300 Subject: [PATCH 12/23] rust style fixes. No logic change --- dso-parser/src/container.rs | 2 +- flasher/src/flash.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dso-parser/src/container.rs b/dso-parser/src/container.rs index eed0c0a..839218a 100644 --- a/dso-parser/src/container.rs +++ b/dso-parser/src/container.rs @@ -397,7 +397,7 @@ mod tests { volts_per_count: None, }], }; - let data = serialize_zwcap(&[rec.clone()]); + let data = serialize_zwcap(std::slice::from_ref(&rec)); assert!(is_zwcap(&data)); let decoded = deserialize_zwcap(&data).unwrap(); assert_eq!(decoded.len(), 1); diff --git a/flasher/src/flash.rs b/flasher/src/flash.rs index 5b472de..e47866d 100644 --- a/flasher/src/flash.rs +++ b/flasher/src/flash.rs @@ -348,6 +348,7 @@ fn list_serial_ports() -> io::Result<()> { } #[cfg(windows)] { + let _ = port; true } }) From 984ba778032bcb6cf0968246fa8bb8df00f6f050 Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Wed, 15 Jul 2026 18:31:01 +0300 Subject: [PATCH 13/23] version update --- Cargo.lock | 145 ++++++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 2 +- 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 74996f7..8f45615 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -554,6 +554,12 @@ dependencies = [ "syn", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -885,6 +891,18 @@ dependencies = [ "libc", ] +[[package]] +name = "core-text" +version = "20.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +dependencies = [ + "core-foundation 0.9.4", + "core-graphics", + "foreign-types", + "libc", +] + [[package]] name = "core_maths" version = "0.1.1" @@ -1104,7 +1122,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dso-parser" -version = "0.2.0" +version = "0.2.1" dependencies = [ "chrono", "ciborium", @@ -1115,14 +1133,16 @@ dependencies = [ [[package]] name = "dso3d12-gui" -version = "0.2.0" +version = "0.2.1" dependencies = [ + "ab_glyph", "anyhow", "chrono", "crc", "csv", "dirs", "dso-parser", + "font-kit", "iced", "iced_aw", "log", @@ -1141,6 +1161,18 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + [[package]] name = "either" version = "1.16.0" @@ -1314,6 +1346,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + [[package]] name = "float_next_after" version = "1.0.0" @@ -1338,6 +1376,31 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-kit" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" +dependencies = [ + "bitflags 2.11.1", + "byteorder", + "core-foundation 0.9.4", + "core-graphics", + "core-text", + "dirs", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + [[package]] name = "font-types" version = "0.10.1" @@ -1415,6 +1478,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "futures" version = "0.3.32" @@ -2348,6 +2422,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -3327,6 +3407,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -5076,6 +5175,22 @@ dependencies = [ "web-sys", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -5085,6 +5200,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "window_clipboard" version = "0.5.1" @@ -5425,6 +5546,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -5614,6 +5744,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 78fea03..072d604 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.2.0" +version = "0.2.1" edition = "2021" description = "Tools for ZeeWeii DSO3D12 oscilloscope and Winner Micro MCUs" license = "MIT" From f0b289b9dcff2ce6608dee3c94cc25aa2a3e3ab6 Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Thu, 16 Jul 2026 08:49:00 +0300 Subject: [PATCH 14/23] GUI: added internationalization and locales for: en, ru, es, hi, zh --- Cargo.lock | 296 ++++++++++++++++++++++++- Cargo.toml | 1 + gui/Cargo.toml | 3 + gui/locales/en.json | 154 +++++++++++++ gui/locales/es.json | 153 +++++++++++++ gui/locales/hi.json | 153 +++++++++++++ gui/locales/ru.json | 153 +++++++++++++ gui/locales/zh.json | 153 +++++++++++++ gui/src/app.rs | 520 ++++++++++++++++++++++++++------------------ gui/src/canvas.rs | 2 +- gui/src/i18n.rs | 44 ++++ gui/src/main.rs | 18 ++ gui/src/settings.rs | 20 +- 13 files changed, 1439 insertions(+), 231 deletions(-) create mode 100644 gui/locales/en.json create mode 100644 gui/locales/es.json create mode 100644 gui/locales/hi.json create mode 100644 gui/locales/ru.json create mode 100644 gui/locales/zh.json create mode 100644 gui/src/i18n.rs diff --git a/Cargo.lock b/Cargo.lock index 8f45615..6584a02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "aligned" version = "0.4.3" @@ -160,6 +169,15 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -437,6 +455,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "base62" +version = "2.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" + [[package]] name = "bit-set" version = "0.8.0" @@ -516,6 +540,16 @@ dependencies = [ "piper", ] +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "built" version = "0.8.1" @@ -1148,9 +1182,11 @@ dependencies = [ "log", "open", "rfd", + "rust-i18n", "serde", "serde_json", "serialport", + "sys-locale", "thiserror 2.0.18", "tokio", ] @@ -1663,6 +1699,36 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "glow" version = "0.16.0" @@ -2180,6 +2246,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ffa3a0547a138e59ddd6fa3b7c672ed47e6ad6a3cd177984ff1116aa5ba742" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.10" @@ -2291,6 +2373,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2840,6 +2931,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "normpath" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3575,7 +3675,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -3736,7 +3836,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -3855,6 +3955,35 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -3897,6 +4026,60 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rust-i18n" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332" +dependencies = [ + "globwalk", + "once_cell", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965" +dependencies = [ + "glob", + "once_cell", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "serde_yaml", + "syn", +] + +[[package]] +name = "rust-i18n-support" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "lazy_static", + "normpath", + "once_cell", + "proc-macro2", + "regex", + "serde", + "serde_json", + "serde_yaml", + "siphasher", + "toml", + "triomphe", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4056,6 +4239,28 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serialport" version = "4.9.0" @@ -4122,6 +4327,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "skrifa" version = "0.37.0" @@ -4528,6 +4739,27 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -4537,6 +4769,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.12+spec-1.1.0" @@ -4544,9 +4790,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -4555,9 +4801,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tracing" version = "0.1.44" @@ -4590,6 +4842,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4667,6 +4930,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "url" version = "2.5.8" @@ -5537,6 +5806,15 @@ dependencies = [ "xkbcommon-dl", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.3" @@ -5807,7 +6085,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 1.0.3", "zbus_macros", "zbus_names", "zvariant", @@ -5835,7 +6113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow", + "winnow 1.0.3", "zvariant", ] @@ -5959,7 +6237,7 @@ dependencies = [ "enumflags2", "serde", "url", - "winnow", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] @@ -5987,5 +6265,5 @@ dependencies = [ "quote", "serde", "syn", - "winnow", + "winnow 1.0.3", ] diff --git a/Cargo.toml b/Cargo.toml index 072d604..169e103 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,4 @@ repository = "https://github.com/rssdev10/wm-tools" anyhow = "1.0" thiserror = "2.0" log = "0.4" +rust-i18n = "3" diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 9241841..70c9949 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -29,6 +29,9 @@ crc = "3.2" open = "5" ab_glyph = "0.2" font-kit = "0.14" +rust-i18n = { workspace = true } +sys-locale = "0.3" + [dev-dependencies] ab_glyph = "0.2" diff --git a/gui/locales/en.json b/gui/locales/en.json new file mode 100644 index 0000000..77c91fb --- /dev/null +++ b/gui/locales/en.json @@ -0,0 +1,154 @@ +{ + "app.title": "DSO3D12 Viewer", + "app.ready": "Ready.", + "app.settings_saved": "Settings saved.", + "app.no_port": "No port", + "app.no_capture": "No capture to export.", + "app.no_capture_loaded": "No capture loaded — use Load Capture…", + "app.select_port": "Select a serial port first.", + "btn.help": "Help", + "btn.stop": "Stop", + "btn.listen": "Listen", + "btn.close": "Close", + "btn.save": "Save & Close", + "btn.update": "Update", + "btn.confirm_update": "Confirm Update", + "btn.updating": "Updating…", + "btn.browse": "Browse…", + "btn.refresh": "Refresh", + "btn.load_capture": "Load Capture", + "btn.save_capture": "Save Capture", + "btn.save_png": "Save PNG", + "btn.export_data": "Export Data", + "btn.settings": "Settings", + "btn.help_open": "Help", + "btn.firmware_update": "Firmware Update…", + "btn.undo_delete": "Undo Delete", + "btn.export_png": "Export PNG…", + "btn.export_csv": "Export CSV…", + "btn.delete": "Delete", + "label.measurement": "Measurement", + "label.cursor_x": "Cursor X (time)", + "label.cursor_y": "Cursor Y (voltage)", + "label.freq": "≈ freq: %{hz}", + "label.dt": "Δt = %{ms} ms", + "label.ch1_dv": "CH1 ΔV = %{v} V", + "label.ch2_dv": "CH2 ΔV = %{v} V", + "label.ch1": "CH1", + "label.ch2": "CH2", + "label.vdiv": "V/div, V:", + "label.probe": "Probe:", + "label.voff": "V/off, V:", + "label.tdiv": "t/div, ms:", + "label.ac_mode": "AC mode: 8×V, 12×t div", + "label.time_range": "Time range: %{ms} ms (%{n} samp)", + "label.ch1_stats": "CH1: n=%{n} min=%{min} max=%{max} avg=%{avg}", + "label.ch2_stats": "CH2: n=%{n} min=%{min} max=%{max} avg=%{avg}", + "label.trigger": "Trigger", + "label.trigger_info": "Mode: %{mode} | Edge: %{edge} | Src: %{src}", + "label.trigger_level": "Level: %{v} V", + "label.ch1_probe": "CH1: %{probe} probe, %{coupling}", + "label.ch2_probe": "CH2: %{probe} probe, %{coupling}", + "label.delay_rate": "Delay: %{delay} s | Rate: %{rate} Sa/s", + "label.auto_meas": "Auto Measurements", + "label.device_x": "Device X", + "label.device_y": "Device Y", + "label.scales": "Scales", + "label.view": "View:", + "label.split_ch": "Split CH1/CH2", + "label.show_instructions": "Show instructions", + "btn.load_settings": "Settings", + "label.listening_on": "Listening on %{port}…", + "label.stopped": "Stopped.", + "label.connected_to": "Connected to %{port}.", + "label.disconnected": "Port disconnected; reconnecting…", + "label.receiving": "Receiving… %{n} bytes", + "label.screenshot_received": "Screenshot received (CH1: %{ch1}/div, CH2: %{ch2}/div, %{timebase}/div). Total: %{total}", + "label.received_captures": "Received %{count} capture(s). Total: %{total}", + "label.saved_ok": "Saved %{n} capture(s) to %{path}", + "label.save_error": "Save error: %{e}", + "label.png_saved": "PNG saved to %{path}", + "label.png_error": "PNG error: %{e}", + "label.csv_written": "Wrote %{path}", + "label.csv_error": "CSV error: %{e}", + "label.loaded_ok": "Loaded %{n} capture(s) from %{path}", + "label.load_error": "Load error: %{e}", + "label.deleted": "Deleted capture #%{n}. Press Undo to restore.", + "label.restored": "Restored capture #%{n}.", + "label.undo_expired": "Undo expired.", + "label.settings_title": "Settings", + "label.serial_section": "Serial", + "label.default_port": "Default port:", + "label.available_ports": "Available: %{ports}", + "label.no_ports": "No serial ports detected.", + "label.baud_rate": "Baud rate:", + "label.auto_listen": "Auto-listen on startup", + "label.beep_on_capture": "Beep on new capture", + "label.display_section": "Display", + "label.language": "Language", + "label.about_section": "About", + "label.repository": "Repository:", + "label.config_path": "Config path: ", + "label.instructions_title": "Instructions", + "label.standard_fw_title": "Standard firmware — raw debug dump", + "label.standard_fw_1": "1. Connect the oscilloscope to the computer using a USB Type-C cable.", + "label.standard_fw_2": "2. Feed a signal into the oscilloscope.", + "label.standard_fw_3": "3. Press Stop to freeze the sample buffer.", + "label.standard_fw_4": "4. Press Menu → Stop and leave the Channel 1 Measurements menu open.", + "label.standard_fw_5": "5. In the application, select the serial port and click Listen.", + "label.standard_fw_6": "6. Long-press the Save button on the oscilloscope to start transmitting the debug data.", + "label.standard_fw_7": "7. Wait for the transfer to complete. The oscilloscope sends approximately 300 kB at 115200 baud.", + "label.customized_fw_title": "Customized firmware", + "label.customized_fw_1": "1. Connect the oscilloscope to the computer using a USB Type-C cable.", + "label.customized_fw_2": "2. In the application, select the serial port and click Listen.", + "label.customized_fw_3": "3. Capture a waveform on the oscilloscope by pressing Save while acquisition is running or stopped.", + "label.customized_fw_4": "4. Press Menu → Gallery to open the saved screenshots.", + "label.customized_fw_5": "5. Select the waveform that you want to transmit.", + "label.customized_fw_6": "6. The oscilloscope automatically sends a 2048-byte binary packet over the USB serial connection.", + "label.customized_fw_7": "7. The packet includes waveform data and metadata such as V/div, timebase, trigger settings, cursors, and measurements.", + "label.using_viewer_title": "Using the waveform viewer", + "label.using_viewer_1": "The application can receive oscilloscope data in both supported transmission modes.", + "label.using_viewer_2": "After a waveform is received, you can inspect it, measure voltage and time intervals with cursors, and export the result as a PNG image or CSV data.", + "label.using_viewer_3": "Before receiving data, connect the oscilloscope using a USB Type-C cable, select the correct serial port, and click Listen. Keep the application listening while starting the transmission from the oscilloscope.", + "label.firmware_title": "Firmware Update", + "label.firmware_warning": "⚠ WARNING: Flashing incorrect firmware may brick your device. Ensure the device is in boot mode and the correct firmware file is selected.", + "label.boot_title": "Switching to Boot Mode (DSO3D12)", + "label.boot_1": "1. Connect the supplied USB cable to your PC while the oscilloscope is powered off.", + "label.boot_2": "2. Press and hold the power button. The scope may enter a power-cycling loop — keep the button held down continuously until flashing reaches 100%.", + "label.port": "Port:", + "label.firmware_file": "Firmware file (.fls):", + "label.no_file": "No file selected", + "label.firmware_status_ready": "Select firmware file and port, then click Update.", + "label.firmware_confirm": "⚠ Are you sure? This may brick the device. Click Update again to confirm.", + "label.firmware_no_file": "No firmware file selected.", + "label.firmware_no_port": "No port selected.", + "label.firmware_starting": "Starting…", + "label.firmware_complete": "Flash complete! You may close this dialog.", + "label.firmware_error": "Flash error: %{e}", + "label.pct_progress": "%{pct}% (%{cur}/%{total} blocks)", + "label.no_data": "No data", + "label.all_files": "All files", + "label.zwcap_filter": "ZeeWeii Capture", + "label.csv_filter": "CSV", + "label.png_filter": "PNG Image", + "label.fls_filter": "Firmware", + "label.freq_name": "Freq", + "label.pkpk_name": "PkPk", + "label.avg_name": "Avg", + "label.rms_name": "RMS", + "label.amp_name": "Amp", + "label.duty_plus_name": "+Duty", + "label.duty_minus_name": "-Duty", + "label.t_plus_name": "+T", + "label.t_minus_name": "-T", + "label.t_name": "T", + "label.max_name": "Max", + "label.min_name": "Min", + "label.top_name": "Top", + "label.base_name": "Base", + "label.cursor_x_range": "Cursor X (time)", + "label.cursor_y_range": "Cursor Y (voltage)", + "label.view_mode_line": "Line", + "label.view_mode_dot": "Dot", + "label.view_mode_smooth": "Smooth" +} diff --git a/gui/locales/es.json b/gui/locales/es.json new file mode 100644 index 0000000..55f4b94 --- /dev/null +++ b/gui/locales/es.json @@ -0,0 +1,153 @@ +{ + "app.title": "DSO3D12 Viewer", + "app.ready": "Listo.", + "app.settings_saved": "Ajustes guardados.", + "app.no_port": "Sin puerto", + "app.no_capture": "No hay captura para exportar.", + "app.no_capture_loaded": "No hay captura cargada — use Cargar captura…", + "app.select_port": "Seleccione un puerto serie primero.", + "btn.help": "Ayuda", + "btn.stop": "Detener", + "btn.listen": "Escuchar", + "btn.close": "Cerrar", + "btn.save": "Guardar y cerrar", + "btn.update": "Actualizar", + "btn.confirm_update": "Confirmar actualización", + "btn.updating": "Actualizando…", + "btn.browse": "Examinar…", + "btn.refresh": "Actualizar", + "btn.load_capture": "Cargar captura", + "btn.save_capture": "Guardar captura", + "btn.save_png": "Guardar PNG", + "btn.export_data": "Exportar datos", + "btn.settings": "Ajustes", + "btn.help_open": "Ayuda", + "btn.firmware_update": "Actualizar firmware…", + "btn.undo_delete": "Deshacer eliminar", + "btn.export_png": "Exportar PNG…", + "btn.export_csv": "Exportar CSV…", + "btn.delete": "Eliminar", + "label.measurement": "Medición", + "label.cursor_x": "Cursor X (tiempo)", + "label.cursor_y": "Cursor Y (voltaje)", + "label.freq": "≈ frecuencia: %{hz}", + "label.dt": "Δt = %{ms} ms", + "label.ch1_dv": "CH1 ΔV = %{v} V", + "label.ch2_dv": "CH2 ΔV = %{v} V", + "label.ch1": "CH1", + "label.ch2": "CH2", + "label.vdiv": "V/div, V:", + "label.probe": "Factor:", + "label.voff": "Despl V, V:", + "label.tdiv": "t/div, ms:", + "label.ac_mode": "Modo CA: 8×V, 12×t div", + "label.time_range": "Tiempo: %{ms} ms (%{n} muestras)", + "label.ch1_stats": "CH1: n=%{n} mín=%{min} máx=%{max} prom=%{avg}", + "label.ch2_stats": "CH2: n=%{n} mín=%{min} máx=%{max} prom=%{avg}", + "label.trigger": "Disparo", + "label.trigger_info": "Modo: %{mode} | Flanco: %{edge} | Fte: %{src}", + "label.trigger_level": "Nivel: %{v} V", + "label.ch1_probe": "CH1: factor %{probe}, %{coupling}", + "label.ch2_probe": "CH2: factor %{probe}, %{coupling}", + "label.delay_rate": "Retardo: %{delay} s | Frec: %{rate} M/s", + "label.auto_meas": "Mediciones auto", + "label.device_x": "Cursor X", + "label.device_y": "Cursor Y", + "label.scales": "Escalas", + "label.view": "Vista:", + "label.split_ch": "Dividir CH1/CH2", + "label.show_instructions": "Mostrar instrucciones", + "label.listening_on": "Escuchando en %{port}…", + "label.stopped": "Detenido.", + "label.connected_to": "Conectado a %{port}.", + "label.disconnected": "Puerto desconectado; reconectando…", + "label.receiving": "Recibiendo… %{n} bytes", + "label.screenshot_received": "Captura recibida (CH1: %{ch1}/div, CH2: %{ch2}/div, %{timebase}/div). Total: %{total}", + "label.received_captures": "Recibidas %{count} captura(s). Total: %{total}", + "label.saved_ok": "Guardadas %{n} captura(s) en %{path}", + "label.save_error": "Error al guardar: %{e}", + "label.png_saved": "PNG guardado en %{path}", + "label.png_error": "Error PNG: %{e}", + "label.csv_written": "Escrito %{path}", + "label.csv_error": "Error CSV: %{e}", + "label.loaded_ok": "Cargadas %{n} captura(s) desde %{path}", + "label.load_error": "Error de carga: %{e}", + "label.deleted": "Captura #%{n} eliminada. Pulse Deshacer para restaurar.", + "label.restored": "Captura #%{n} restaurada.", + "label.undo_expired": "Deshacer expirado.", + "label.settings_title": "Ajustes", + "label.serial_section": "Puerto serie", + "label.default_port": "Puerto predeterminado:", + "label.available_ports": "Disponibles: %{ports}", + "label.no_ports": "No se detectaron puertos serie.", + "label.baud_rate": "Velocidad (baudios):", + "label.auto_listen": "Escucha automática al inicio", + "label.beep_on_capture": "Sonido al recibir captura", + "label.display_section": "Pantalla", + "label.language": "Idioma", + "label.about_section": "Acerca de", + "label.repository": "Repositorio:", + "label.config_path": "Config: ", + "label.instructions_title": "Instrucciones", + "label.standard_fw_title": "Firmware estándar — volcado de datos", + "label.standard_fw_1": "1. Conecte el osciloscopio al ordenador mediante USB Type-C.", + "label.standard_fw_2": "2. Introduzca una señal en el osciloscopio.", + "label.standard_fw_3": "3. Pulse Stop para congelar el buffer.", + "label.standard_fw_4": "4. Pulse Menu → Stop y deje abierto el menú de mediciones del Canal 1.", + "label.standard_fw_5": "5. En la aplicación, seleccione el puerto serie y pulse Escuchar.", + "label.standard_fw_6": "6. Mantenga pulsado Save en el osciloscopio para transmitir los datos.", + "label.standard_fw_7": "7. Espere a que termine la transferencia (~300 kB a 115200 baudios).", + "label.customized_fw_title": "Firmware personalizado", + "label.customized_fw_1": "1. Conecte el osciloscopio al ordenador mediante USB Type-C.", + "label.customized_fw_2": "2. En la aplicación, seleccione el puerto y pulse Escuchar.", + "label.customized_fw_3": "3. Capture una forma de onda pulsando Save durante la adquisición.", + "label.customized_fw_4": "4. Pulse Menu → Gallery para abrir las capturas guardadas.", + "label.customized_fw_5": "5. Seleccione la forma de onda a transmitir.", + "label.customized_fw_6": "6. El osciloscopio enviará automáticamente un paquete binario de 2048 bytes.", + "label.customized_fw_7": "7. El paquete incluye datos de forma de onda y metadatos: V/div, base de tiempo, disparo, cursores y mediciones.", + "label.using_viewer_title": "Uso del visor", + "label.using_viewer_1": "La aplicación puede recibir datos del osciloscopio en ambos modos de transmisión compatibles.", + "label.using_viewer_2": "Tras recibir una forma de onda, puede inspeccionarla, medir voltaje y tiempo con cursores, y exportar a PNG o CSV.", + "label.using_viewer_3": "Antes de recibir datos, conecte el osciloscopio por USB Type-C, seleccione el puerto y pulse Escuchar.", + "label.firmware_title": "Actualización de firmware", + "label.firmware_warning": "⚠ ADVERTENCIA: Un firmware incorrecto puede dañar el dispositivo. Asegúrese de que el dispositivo esté en modo boot y el archivo correcto.", + "label.boot_title": "Cambio a modo boot (DSO3D12)", + "label.boot_1": "1. Conecte el cable USB al PC con el osciloscopio apagado.", + "label.boot_2": "2. Mantenga pulsado el botón de encendido. El osciloscopio puede reiniciarse en ciclo — siga pulsando hasta que la actualización llegue al 100%.", + "label.port": "Puerto:", + "label.firmware_file": "Archivo de firmware (.fls):", + "label.no_file": "Ningún archivo seleccionado", + "label.firmware_status_ready": "Seleccione el archivo de firmware y el puerto, luego pulse Actualizar.", + "label.firmware_confirm": "⚠ ¿Está seguro? Un firmware incorrecto puede dañar el dispositivo. Pulse Actualizar de nuevo para confirmar.", + "label.firmware_no_file": "Ningún archivo de firmware seleccionado.", + "label.firmware_no_port": "Ningún puerto seleccionado.", + "label.firmware_starting": "Iniciando…", + "label.firmware_complete": "¡Actualización completada! Puede cerrar este diálogo.", + "label.firmware_error": "Error de actualización: %{e}", + "label.pct_progress": "%{pct}% (%{cur}/%{total} bloques)", + "label.no_data": "Sin datos", + "label.all_files": "Todos los archivos", + "label.zwcap_filter": "Captura ZeeWeii", + "label.csv_filter": "CSV", + "label.png_filter": "Imagen PNG", + "label.fls_filter": "Firmware", + "label.freq_name": "Frec", + "label.pkpk_name": "Pico", + "label.avg_name": "Prom", + "label.rms_name": "RMS", + "label.amp_name": "Amp", + "label.duty_plus_name": "+Ciclo", + "label.duty_minus_name": "-Ciclo", + "label.t_plus_name": "+T", + "label.t_minus_name": "-T", + "label.t_name": "T", + "label.max_name": "Máx", + "label.min_name": "Mín", + "label.top_name": "Sup", + "label.base_name": "Base", + "label.cursor_x_range": "Cursor X (tiempo)", + "label.cursor_y_range": "Cursor Y (voltaje)", + "label.view_mode_line": "Línea", + "label.view_mode_dot": "Punto", + "label.view_mode_smooth": "Suave" +} diff --git a/gui/locales/hi.json b/gui/locales/hi.json new file mode 100644 index 0000000..fb9ac52 --- /dev/null +++ b/gui/locales/hi.json @@ -0,0 +1,153 @@ +{ + "app.title": "DSO3D12 Viewer", + "app.ready": "तैयार।", + "app.settings_saved": "सेटिंग्स सहेजी गईं।", + "app.no_port": "कोई पोर्ट नहीं", + "app.no_capture": "निर्यात करने के लिए कोई कैप्चर नहीं।", + "app.no_capture_loaded": "कोई कैप्चर लोड नहीं — लोड कैप्चर का उपयोग करें…", + "app.select_port": "कृपया पहले एक सीरियल पोर्ट चुनें।", + "btn.help": "सहायता", + "btn.stop": "रोकें", + "btn.listen": "सुनें", + "btn.close": "बंद करें", + "btn.save": "सहेजें और बंद करें", + "btn.update": "अपडेट करें", + "btn.confirm_update": "अपडेट की पुष्टि करें", + "btn.updating": "अपडेट हो रहा है…", + "btn.browse": "ब्राउज़ करें…", + "btn.refresh": "ताज़ा करें", + "btn.load_capture": "कैप्चर लोड करें", + "btn.save_capture": "कैप्चर सहेजें", + "btn.save_png": "PNG सहेजें", + "btn.export_data": "डेटा निर्यात करें", + "btn.settings": "सेटिंग्स", + "btn.help_open": "सहायता", + "btn.firmware_update": "फ़र्मवेयर अपडेट…", + "btn.undo_delete": "हटाना पूर्ववत करें", + "btn.export_png": "PNG निर्यात…", + "btn.export_csv": "CSV निर्यात…", + "btn.delete": "हटाएँ", + "label.measurement": "माप", + "label.cursor_x": "कर्सर X (समय)", + "label.cursor_y": "कर्सर Y (वोल्टेज)", + "label.freq": "≈ आवृत्ति: %{hz}", + "label.dt": "Δt = %{ms} मि.से.", + "label.ch1_dv": "CH1 ΔV = %{v} V", + "label.ch2_dv": "CH2 ΔV = %{v} V", + "label.ch1": "CH1", + "label.ch2": "CH2", + "label.vdiv": "V/डिव, V:", + "label.probe": "फैक्टर:", + "label.voff": "V ऑफ़सेट, V:", + "label.tdiv": "t/डिव, मि.से.:", + "label.ac_mode": "AC मोड: 8×V, 12×t डिव", + "label.time_range": "समय: %{ms} मि.से. (%{n} नमूने)", + "label.ch1_stats": "CH1: n=%{n} न्यून=%{min} अधि=%{max} औसत=%{avg}", + "label.ch2_stats": "CH2: n=%{n} न्यून=%{min} अधि=%{max} औसत=%{avg}", + "label.trigger": "ट्रिगर", + "label.trigger_info": "मोड: %{mode} | किनारा: %{edge} | स्रोत: %{src}", + "label.trigger_level": "स्तर: %{v} V", + "label.ch1_probe": "CH1: फैक्टर %{probe}, %{coupling}", + "label.ch2_probe": "CH2: फैक्टर %{probe}, %{coupling}", + "label.delay_rate": "विलंब: %{delay} से | दर: %{rate} Sa/s", + "label.auto_meas": "स्वचालित माप", + "label.device_x": "डिवाइस कर्सर X", + "label.device_y": "डिवाइस कर्सर Y", + "label.scales": "स्केल", + "label.view": "दृश्य:", + "label.split_ch": "CH1/CH2 विभाजित करें", + "label.show_instructions": "निर्देश दिखाएँ", + "label.listening_on": "%{port} पर सुन रहा है…", + "label.stopped": "रोक दिया गया।", + "label.connected_to": "%{port} से कनेक्टेड।", + "label.disconnected": "पोर्ट डिस्कनेक्ट; पुनः कनेक्ट हो रहा है…", + "label.receiving": "प्राप्त हो रहा है… %{n} बाइट", + "label.screenshot_received": "स्क्रीनशॉट प्राप्त (CH1: %{ch1}/डिव, CH2: %{ch2}/डिव, %{timebase}/डिव)। कुल: %{total}", + "label.received_captures": "%{count} कैप्चर प्राप्त। कुल: %{total}", + "label.saved_ok": "%{n} कैप्चर %{path} में सहेजे गए", + "label.save_error": "सहेजने में त्रुटि: %{e}", + "label.png_saved": "PNG %{path} में सहेजा गया", + "label.png_error": "PNG त्रुटि: %{e}", + "label.csv_written": "%{path} लिखा गया", + "label.csv_error": "CSV त्रुटि: %{e}", + "label.loaded_ok": "%{path} से %{n} कैप्चर लोड हुए", + "label.load_error": "लोड त्रुटि: %{e}", + "label.deleted": "कैप्चर #%{n} हटाया गया। पूर्ववत करने के लिए दबाएँ।", + "label.restored": "कैप्चर #%{n} पुनर्स्थापित।", + "label.undo_expired": "पूर्ववत समाप्त।", + "label.settings_title": "सेटिंग्स", + "label.serial_section": "सीरियल", + "label.default_port": "डिफ़ॉल्ट पोर्ट:", + "label.available_ports": "उपलब्ध: %{ports}", + "label.no_ports": "कोई सीरियल पोर्ट नहीं मिला।", + "label.baud_rate": "बॉड दर:", + "label.auto_listen": "स्टार्टअप पर स्वचालित सुनें", + "label.beep_on_capture": "नए कैप्चर पर बीप", + "label.display_section": "प्रदर्शन", + "label.language": "भाषा", + "label.about_section": "के बारे में", + "label.repository": "भंडार:", + "label.config_path": "कॉन्फ़िग: ", + "label.instructions_title": "निर्देश", + "label.standard_fw_title": "मानक फ़र्मवेयर — डेटा डंप", + "label.standard_fw_1": "1. USB Type-C केबल से ऑसिलोस्कोप को कंप्यूटर से कनेक्ट करें।", + "label.standard_fw_2": "2. ऑसिलोस्कोप में सिग्नल डालें।", + "label.standard_fw_3": "3. सैंपल बफ़र फ़्रीज़ करने के लिए Stop दबाएँ।", + "label.standard_fw_4": "4. Menu → Stop दबाएँ और चैनल 1 माप मेनू खुला छोड़ें।", + "label.standard_fw_5": "5. एप्लिकेशन में, सीरियल पोर्ट चुनें और Listen पर क्लिक करें।", + "label.standard_fw_6": "6. डीबग डेटा ट्रांसमिट करने के लिए ऑसिलोस्कोप पर Save बटन लॉन्ग-प्रेस करें।", + "label.standard_fw_7": "7. ट्रांसफ़र पूरा होने तक प्रतीक्षा करें। ऑसिलोस्कोप 115200 बॉड पर लगभग 300 kB भेजता है।", + "label.customized_fw_title": "अनुकूलित फ़र्मवेयर", + "label.customized_fw_1": "1. USB Type-C केबल से ऑसिलोस्कोप को कंप्यूटर से कनेक्ट करें।", + "label.customized_fw_2": "2. एप्लिकेशन में, पोर्ट चुनें और Listen पर क्लिक करें।", + "label.customized_fw_3": "3. ऑसिलोस्कोप पर Save दबाकर वेवफ़ॉर्म कैप्चर करें।", + "label.customized_fw_4": "4. सहेजे गए स्क्रीनशॉट खोलने के लिए Menu → Gallery दबाएँ।", + "label.customized_fw_5": "5. ट्रांसमिट करने के लिए वेवफ़ॉर्म चुनें।", + "label.customized_fw_6": "6. ऑसिलोस्कोप स्वचालित रूप से USB सीरियल पर 2048-बाइट बाइनरी पैकेट भेजता है।", + "label.customized_fw_7": "7. पैकेट में वेवफ़ॉर्म डेटा और मेटाडेटा शामिल है: V/डिव, टाइमबेस, ट्रिगर सेटिंग्स, कर्सर और माप।", + "label.using_viewer_title": "वेवफ़ॉर्म दर्शक का उपयोग", + "label.using_viewer_1": "एप्लिकेशन दोनों समर्थित ट्रांसमिशन मोड में ऑसिलोस्कोप डेटा प्राप्त कर सकता है।", + "label.using_viewer_2": "वेवफ़ॉर्म प्राप्त करने के बाद, आप इसका निरीक्षण कर सकते हैं, कर्सर से वोल्टेज और समय माप सकते हैं, और परिणाम PNG या CSV में निर्यात कर सकते हैं।", + "label.using_viewer_3": "डेटा प्राप्त करने से पहले, USB Type-C केबल से ऑसिलोस्कोप कनेक्ट करें, सही पोर्ट चुनें और Listen पर क्लिक करें।", + "label.firmware_title": "फ़र्मवेयर अपडेट", + "label.firmware_warning": "⚠ चेतावनी: गलत फ़र्मवेयर डिवाइस को खराब कर सकता है। सुनिश्चित करें कि डिवाइस बूट मोड में है और सही फ़ाइल चुनी गई है।", + "label.boot_title": "बूट मोड में स्विच करना (DSO3D12)", + "label.boot_1": "1. ऑसिलोस्कोप बंद होने पर USB केबल को PC से कनेक्ट करें।", + "label.boot_2": "2. पावर बटन दबाकर रखें। ऑसिलोस्कोप साइकिल में रीस्टार्ट हो सकता है — 100% फ़्लैश होने तक बटन दबाए रखें।", + "label.port": "पोर्ट:", + "label.firmware_file": "फ़र्मवेयर फ़ाइल (.fls):", + "label.no_file": "कोई फ़ाइल नहीं चुनी गई", + "label.firmware_status_ready": "फ़र्मवेयर फ़ाइल और पोर्ट चुनें, फिर अपडेट पर क्लिक करें।", + "label.firmware_confirm": "⚠ क्या आप सुनिश्चित हैं? गलत फ़र्मवेयर डिवाइस को खराब कर सकता है। पुष्टि के लिए फिर से अपडेट पर क्लिक करें।", + "label.firmware_no_file": "कोई फ़र्मवेयर फ़ाइल नहीं चुनी गई।", + "label.firmware_no_port": "कोई पोर्ट नहीं चुना गया।", + "label.firmware_starting": "शुरू हो रहा है…", + "label.firmware_complete": "फ़्लैश पूर्ण! आप इस डायलॉग को बंद कर सकते हैं।", + "label.firmware_error": "फ़्लैश त्रुटि: %{e}", + "label.pct_progress": "%{pct}% (%{cur}/%{total} ब्लॉक)", + "label.no_data": "कोई डेटा नहीं", + "label.all_files": "सभी फ़ाइलें", + "label.zwcap_filter": "ZeeWeii कैप्चर", + "label.csv_filter": "CSV", + "label.png_filter": "PNG इमेज", + "label.fls_filter": "फ़र्मवेयर", + "label.freq_name": "आवृत्ति", + "label.pkpk_name": "पीक-पीक", + "label.avg_name": "औसत", + "label.rms_name": "RMS", + "label.amp_name": "आयाम", + "label.duty_plus_name": "+ड्यूटी", + "label.duty_minus_name": "-ड्यूटी", + "label.t_plus_name": "+T", + "label.t_minus_name": "-T", + "label.t_name": "T", + "label.max_name": "अधिकतम", + "label.min_name": "न्यूनतम", + "label.top_name": "शीर्ष", + "label.base_name": "आधार", + "label.cursor_x_range": "कर्सर X (समय)", + "label.cursor_y_range": "कर्सर Y (वोल्टेज)", + "label.view_mode_line": "रेखा", + "label.view_mode_dot": "बिंदु", + "label.view_mode_smooth": "चिकना" +} diff --git a/gui/locales/ru.json b/gui/locales/ru.json new file mode 100644 index 0000000..299c1b7 --- /dev/null +++ b/gui/locales/ru.json @@ -0,0 +1,153 @@ +{ + "app.title": "DSO3D12 Viewer", + "app.ready": "Готов.", + "app.settings_saved": "Настройки сохранены.", + "app.no_port": "Не выбран порт", + "app.no_capture": "Нет данных для экспорта.", + "app.no_capture_loaded": "Не загружен сигнал — используйте «кнопку Загрузить» или «Слушать»…", + "app.select_port": "Сначала выберите последовательный порт.", + "btn.help": "Справка", + "btn.stop": "Стоп", + "btn.listen": "Слушать", + "btn.close": "Закрыть", + "btn.save": "Сохранить и закрыть", + "btn.update": "Обновить", + "btn.confirm_update": "Подтвердить обновление", + "btn.updating": "Обновление…", + "btn.browse": "Обзор…", + "btn.refresh": "Обновить", + "btn.load_capture": "Загрузить", + "btn.save_capture": "Сохранить", + "btn.save_png": "Сохранить PNG", + "btn.export_data": "Экспорт данных", + "btn.settings": "Настройки", + "btn.help_open": "Справка", + "btn.firmware_update": "Обновление прошивки…", + "btn.undo_delete": "Отменить удаление", + "btn.export_png": "Экспорт PNG…", + "btn.export_csv": "Экспорт CSV…", + "btn.delete": "Удалить", + "label.measurement": "Измерения", + "label.cursor_x": "Курсор X (время)", + "label.cursor_y": "Курсор Y (напряжение)", + "label.freq": "≈ частота: %{hz}, Гц", + "label.dt": "Δt = %{ms} мс", + "label.ch1_dv": "CH1 ΔV = %{v} В", + "label.ch2_dv": "CH2 ΔV = %{v} В", + "label.ch1": "CH1", + "label.ch2": "CH2", + "label.vdiv": "В/дел, В:", + "label.probe": "Множитель:", + "label.voff": "Смещ, В:", + "label.tdiv": "время/дел, мс:", + "label.ac_mode": "Режим АС: 8×В, 12×t дел", + "label.time_range": "Время: %{ms} мс (%{n} отсчётов)", + "label.ch1_stats": "CH1: n=%{n} мин=%{min} макс=%{max} ср=%{avg}", + "label.ch2_stats": "CH2: n=%{n} мин=%{min} макс=%{max} ср=%{avg}", + "label.trigger": "Синхронизация", + "label.trigger_info": "Режим: %{mode} | Фронт: %{edge} | Ист: %{src}", + "label.trigger_level": "Уровень: %{v} В", + "label.ch1_probe": "CH1: множ. %{probe}, %{coupling}", + "label.ch2_probe": "CH2: множ. %{probe}, %{coupling}", + "label.delay_rate": "Задержка: %{delay} с | Частота: %{rate} выборок/с", + "label.auto_meas": "Автоизмерения", + "label.device_x": "Курсор X", + "label.device_y": "Курсор Y", + "label.scales": "Шкалы", + "label.view": "Вид:", + "label.split_ch": "Разделить CH1/CH2", + "label.show_instructions": "Показать инструкции", + "label.listening_on": "Слушаю %{port}…", + "label.stopped": "Остановлено.", + "label.connected_to": "Подключено к %{port}.", + "label.disconnected": "Порт отключён; переподключение…", + "label.receiving": "Получение… %{n} байт", + "label.screenshot_received": "Скриншот получен (CH1: %{ch1}/дел, CH2: %{ch2}/дел, %{timebase}/дел). Всего: %{total}", + "label.received_captures": "Получено %{count} захватов. Всего: %{total}", + "label.saved_ok": "Сохранено %{n} захвата(ов) в %{path}", + "label.save_error": "Ошибка сохранения: %{e}", + "label.png_saved": "PNG сохранён в %{path}", + "label.png_error": "Ошибка PNG: %{e}", + "label.csv_written": "Записан %{path}", + "label.csv_error": "Ошибка CSV: %{e}", + "label.loaded_ok": "Загружено %{n} захвата(ов) из %{path}", + "label.load_error": "Ошибка загрузки: %{e}", + "label.deleted": "Удалён захват #%{n}. Нажмите «Отменить удаление» для восстановления.", + "label.restored": "Восстановлен захват #%{n}.", + "label.undo_expired": "Отмена недоступна.", + "label.settings_title": "Настройки", + "label.serial_section": "Последовательный порт", + "label.default_port": "Порт по умолчанию:", + "label.available_ports": "Доступны: %{ports}", + "label.no_ports": "Последовательные порты не обнаружены.", + "label.baud_rate": "Скорость (бод):", + "label.auto_listen": "Авто-прослушивание при запуске", + "label.beep_on_capture": "Звуковой сигнал при захвате", + "label.display_section": "Экран", + "label.language": "Язык", + "label.about_section": "О программе", + "label.repository": "Репозиторий:", + "label.config_path": "Конфиг: ", + "label.instructions_title": "Инструкции", + "label.standard_fw_title": "Стандартная прошивка — дамп данных", + "label.standard_fw_1": "1. Подключите осциллограф к компьютеру через USB Type-C.", + "label.standard_fw_2": "2. Подайте сигнал на осциллограф.", + "label.standard_fw_3": "3. Нажмите Stop для заморозки буфера.", + "label.standard_fw_4": "4. Нажмите Menu → Stop и оставьте меню измерений канала 1 открытым.", + "label.standard_fw_5": "5. В приложении выберите порт и нажмите «Слушать».", + "label.standard_fw_6": "6. Долго нажмите Save на осциллографе для передачи отладочных данных.", + "label.standard_fw_7": "7. Дождитесь завершения передачи (~300 кБ при 115200 бод).", + "label.customized_fw_title": "Модифицированная прошивка", + "label.customized_fw_1": "1. Подключите осциллограф к компьютеру через USB Type-C.", + "label.customized_fw_2": "2. В приложении выберите порт и нажмите «Слушать».", + "label.customized_fw_3": "3. Захватите сигнал на осциллографе, нажав Save во время работы или остановки.", + "label.customized_fw_4": "4. Нажмите Menu → Gallery для открытия сохранённых скриншотов.", + "label.customized_fw_5": "5. Выберите сигнал для передачи.", + "label.customized_fw_6": "6. Осциллограф автоматически отправит пакет 2048 байт через USB.", + "label.customized_fw_7": "7. Пакет включает данные сигнала и метаданные: В/дел, время, синхронизацию, курсоры и измерения.", + "label.using_viewer_title": "Использование просмотрщика", + "label.using_viewer_1": "Приложение может получать данные осциллографа в обоих поддерживаемых режимах.", + "label.using_viewer_2": "После получения сигнала вы можете просмотреть его, измерить напряжение и время с помощью курсоров и экспортировать результат в PNG или CSV.", + "label.using_viewer_3": "Перед получением данных подключите осциллограф через USB Type-C, выберите порт и нажмите «Слушать».", + "label.firmware_title": "Обновление прошивки", + "label.firmware_warning": "⚠ ВНИМАНИЕ: Неправильная прошивка может повредить устройство. Убедитесь, что устройство в режиме загрузки и выбран правильный файл.", + "label.boot_title": "Переход в режим загрузки (DSO3D12)", + "label.boot_1": "1. Подключите USB-кабель к ПК при выключенном осциллографе.", + "label.boot_2": "2. Нажмите и удерживайте кнопку питания. Осциллограф может циклически перезагружаться — продолжайте удерживать кнопку до завершения прошивки (100%).", + "label.port": "Порт:", + "label.firmware_file": "Файл прошивки (.fls):", + "label.no_file": "Файл не выбран", + "label.firmware_status_ready": "Выберите файл прошивки и порт, затем нажмите «Обновить».", + "label.firmware_confirm": "⚠ Вы уверены? Неправильная прошивка может вывести устройство из строя. Нажмите «Обновить» ещё раз для подтверждения.", + "label.firmware_no_file": "Файл прошивки не выбран.", + "label.firmware_no_port": "Порт не выбран.", + "label.firmware_starting": "Запуск…", + "label.firmware_complete": "Прошивка завершена! Вы можете закрыть этот диалог.", + "label.firmware_error": "Ошибка прошивки: %{e}", + "label.pct_progress": "%{pct}% (%{cur}/%{total} блоков)", + "label.no_data": "Нет данных", + "label.all_files": "Все файлы", + "label.zwcap_filter": "ZeeWeii захват", + "label.csv_filter": "CSV", + "label.png_filter": "PNG изображение", + "label.fls_filter": "Прошивка", + "label.freq_name": "Частота", + "label.pkpk_name": "Размах", + "label.avg_name": "Среднее", + "label.rms_name": "RMS", + "label.amp_name": "Ампл", + "label.duty_plus_name": "+Заполн", + "label.duty_minus_name": "-Заполн", + "label.t_plus_name": "+Имп", + "label.t_minus_name": "-Имп", + "label.t_name": "Пер", + "label.max_name": "Макс", + "label.min_name": "Мин", + "label.top_name": "Верх", + "label.base_name": "Осн", + "label.cursor_x_range": "Курсор X (время)", + "label.cursor_y_range": "Курсор Y (напряжение)", + "label.view_mode_line": "Линия", + "label.view_mode_dot": "Точки", + "label.view_mode_smooth": "Сглаженный" +} diff --git a/gui/locales/zh.json b/gui/locales/zh.json new file mode 100644 index 0000000..25b744f --- /dev/null +++ b/gui/locales/zh.json @@ -0,0 +1,153 @@ +{ + "app.title": "DSO3D12 Viewer", + "app.ready": "就绪。", + "app.settings_saved": "设置已保存。", + "app.no_port": "无端口", + "app.no_capture": "没有可导出的捕获。", + "app.no_capture_loaded": "信号未加载 - 请使用‘下载’或‘收听’按钮……", + "app.select_port": "请先选择串口。", + "btn.help": "帮助", + "btn.stop": "停止", + "btn.listen": "监听", + "btn.close": "关闭", + "btn.save": "保存并关闭", + "btn.update": "更新", + "btn.confirm_update": "确认更新", + "btn.updating": "正在更新…", + "btn.browse": "浏览…", + "btn.refresh": "刷新", + "btn.load_capture": "加载捕获", + "btn.save_capture": "保存捕获", + "btn.save_png": "保存PNG", + "btn.export_data": "导出数据", + "btn.settings": "设置", + "btn.help_open": "帮助", + "btn.firmware_update": "固件更新…", + "btn.undo_delete": "撤销删除", + "btn.export_png": "导出PNG…", + "btn.export_csv": "导出CSV…", + "btn.delete": "删除", + "label.measurement": "测量", + "label.cursor_x": "光标X (时间)", + "label.cursor_y": "光标Y (电压)", + "label.freq": "≈ 频率: %{hz}", + "label.dt": "Δt = %{ms} 毫秒", + "label.ch1_dv": "CH1 ΔV = %{v} V", + "label.ch2_dv": "CH2 ΔV = %{v} V", + "label.ch1": "CH1", + "label.ch2": "CH2", + "label.vdiv": "V/格, V:", + "label.probe": "倍数:", + "label.voff": "偏置, V:", + "label.tdiv": "时基, 毫秒:", + "label.ac_mode": "交流模式: 8×V, 12×t 格", + "label.time_range": "时间: %{ms} 毫秒 (%{n} 采样)", + "label.ch1_stats": "CH1: n=%{n} 最小值=%{min} 最大值=%{max} 平均值=%{avg}", + "label.ch2_stats": "CH2: n=%{n} 最小值=%{min} 最大值=%{max} 平均值=%{avg}", + "label.trigger": "触发", + "label.trigger_info": "模式: %{mode} | 边沿: %{edge} | 源: %{src}", + "label.trigger_level": "电平: %{v} V", + "label.ch1_probe": "CH1: 倍数 %{probe}, %{coupling}", + "label.ch2_probe": "CH2: 倍数 %{probe}, %{coupling}", + "label.delay_rate": "延迟: %{delay} 秒 | 采样率: %{rate} Sa/s", + "label.auto_meas": "自动测量", + "label.device_x": "设备光标X", + "label.device_y": "设备光标Y", + "label.scales": "刻度", + "label.view": "视图:", + "label.split_ch": "分离CH1/CH2", + "label.show_instructions": "显示说明", + "label.listening_on": "正在监听 %{port}…", + "label.stopped": "已停止。", + "label.connected_to": "已连接到 %{port}。", + "label.disconnected": "端口已断开; 正在重新连接…", + "label.receiving": "正在接收… %{n} 字节", + "label.screenshot_received": "已接收截图 (CH1: %{ch1}/格, CH2: %{ch2}/格, %{timebase}/格)。总计: %{total}", + "label.received_captures": "已接收 %{count} 个捕获。总计: %{total}", + "label.saved_ok": "已保存 %{n} 个捕获到 %{path}", + "label.save_error": "保存错误: %{e}", + "label.png_saved": "PNG 已保存到 %{path}", + "label.png_error": "PNG 错误: %{e}", + "label.csv_written": "已写入 %{path}", + "label.csv_error": "CSV 错误: %{e}", + "label.loaded_ok": "已从 %{path} 加载 %{n} 个捕获", + "label.load_error": "加载错误: %{e}", + "label.deleted": "已删除捕获 #%{n}。按撤销删除恢复。", + "label.restored": "已恢复捕获 #%{n}。", + "label.undo_expired": "撤销已过期。", + "label.settings_title": "设置", + "label.serial_section": "串口", + "label.default_port": "默认端口:", + "label.available_ports": "可用端口: %{ports}", + "label.no_ports": "未检测到串口。", + "label.baud_rate": "波特率:", + "label.auto_listen": "启动时自动监听", + "label.beep_on_capture": "新捕获时发出提示音", + "label.display_section": "显示", + "label.language": "语言", + "label.about_section": "关于", + "label.repository": "仓库:", + "label.config_path": "配置路径: ", + "label.instructions_title": "使用说明", + "label.standard_fw_title": "标准固件 — 原始数据转储", + "label.standard_fw_1": "1. 使用 USB Type-C 线将示波器连接到计算机。", + "label.standard_fw_2": "2. 向示波器输入信号。", + "label.standard_fw_3": "3. 按 Stop 冻结采样缓冲区。", + "label.standard_fw_4": "4. 按 Menu → Stop 并保持通道1测量菜单打开。", + "label.standard_fw_5": "5. 在应用程序中,选择串口并点击监听。", + "label.standard_fw_6": "6. 长按示波器上的 Save 按钮开始传输调试数据。", + "label.standard_fw_7": "7. 等待传输完成。示波器在115200波特率下发送约300 kB数据。", + "label.customized_fw_title": "定制固件", + "label.customized_fw_1": "1. 使用 USB Type-C 线将示波器连接到计算机。", + "label.customized_fw_2": "2. 在应用程序中,选择串口并点击监听。", + "label.customized_fw_3": "3. 在示波器上按 Save 捕获波形(运行或停止状态均可)。", + "label.customized_fw_4": "4. 按 Menu → Gallery 打开保存的截图列表。", + "label.customized_fw_5": "5. 选择要传输的波形。", + "label.customized_fw_6": "6. 示波器自动通过 USB 串口发送 2048 字节的二进制数据包。", + "label.customized_fw_7": "7. 数据包包含波形数据和元数据,如 V/格、时基、触发设置、光标和测量值。", + "label.using_viewer_title": "使用波形查看器", + "label.using_viewer_1": "应用程序可以接收两种支持传输模式下的示波器数据。", + "label.using_viewer_2": "接收波形后,您可以检查它,使用光标测量电压和时间间隔,并将结果导出为 PNG 图像或 CSV 数据。", + "label.using_viewer_3": "接收数据前,使用 USB Type-C 线连接示波器,选择正确的串口,然后点击监听。", + "label.firmware_title": "固件更新", + "label.firmware_warning": "⚠ 警告: 错误的固件可能导致设备损坏。请确保设备处于启动模式且选择了正确的固件文件。", + "label.boot_title": "切换到启动模式 (DSO3D12)", + "label.boot_1": "1. 示波器关机状态下,将 USB 线连接到电脑。", + "label.boot_2": "2. 按住电源按钮。示波器可能进入循环重启状态 — 请持续按住按钮直到刷写达到100%。", + "label.port": "端口:", + "label.firmware_file": "固件文件 (.fls):", + "label.no_file": "未选择文件", + "label.firmware_status_ready": "选择固件文件和端口,然后点击更新。", + "label.firmware_confirm": "⚠ 您确定吗?错误的固件可能导致设备损坏。再次点击更新以确认。", + "label.firmware_no_file": "未选择固件文件。", + "label.firmware_no_port": "未选择端口。", + "label.firmware_starting": "正在启动…", + "label.firmware_complete": "刷写完成!您可以关闭此对话框。", + "label.firmware_error": "刷写错误: %{e}", + "label.pct_progress": "%{pct}% (%{cur}/%{total} 块)", + "label.no_data": "无数据", + "label.all_files": "所有文件", + "label.zwcap_filter": "ZeeWeii 捕获", + "label.csv_filter": "CSV", + "label.png_filter": "PNG 图像", + "label.fls_filter": "固件", + "label.freq_name": "频率", + "label.pkpk_name": "峰峰值", + "label.avg_name": "平均值", + "label.rms_name": "有效值", + "label.amp_name": "幅度", + "label.duty_plus_name": "+占空比", + "label.duty_minus_name": "-占空比", + "label.t_plus_name": "+脉宽", + "label.t_minus_name": "-脉宽", + "label.t_name": "周期", + "label.max_name": "最大值", + "label.min_name": "最小值", + "label.top_name": "顶端", + "label.base_name": "底端", + "label.cursor_x_range": "光标X (时间)", + "label.cursor_y_range": "光标Y (电压)", + "label.view_mode_line": "连线", + "label.view_mode_dot": "散点", + "label.view_mode_smooth": "平滑" +} diff --git a/gui/src/app.rs b/gui/src/app.rs index 253823d..2817712 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -13,6 +13,7 @@ use iced::widget::{ use iced::{Element, Length, Subscription, Task, Theme}; use crate::canvas::{Scope, T_CELLS, Thumbnail, V_CELLS}; +use crate::i18n::t; use crate::range_slider; use crate::serial::{self, SerialConfig, SerialEvent, SerialHandle}; use crate::settings::{Settings, ViewMode}; @@ -60,7 +61,6 @@ pub enum Message { // Graph click — move nearest cursor GraphClicked(f32, f32), // Misc - ToggleInstructions(bool), OpenInstructions, CloseInstructions, ToggleAlertOnData(bool), @@ -80,6 +80,8 @@ pub enum Message { FirmwareConfirmStart, FirmwareStart, FirmwareEvent(#[allow(dead_code)] crate::flash::FlashEvent), + // Language selection + ChangeLanguage(String), // Port scan tick (auto-refresh) PortScanTick, // Window resized @@ -114,6 +116,19 @@ pub struct LoadedDump { pub captures: Vec, } +/// Wrapper for language display in pick_list. +#[derive(Debug, Clone, PartialEq, Eq)] +struct LangOption { + code: String, + display: String, +} + +impl std::fmt::Display for LangOption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.display) + } +} + // ── State ────────────────────────────────────────────────────────────────── pub struct App { @@ -184,7 +199,7 @@ impl App { settings, dump: None, current: 0, - status: "Ready.".to_string(), + status: t!("app.ready").to_string(), available_ports: ports, listening: false, serial_handle: None, @@ -220,12 +235,13 @@ impl App { let ver = env!("CARGO_PKG_VERSION"); match &self.dump { Some(d) => format!( - "DSO3D12 Viewer v{ver} — {} ({} capture{})", + "{} v{ver} — {} ({} {})", + t!("app.title"), d.path.file_name().and_then(|s| s.to_str()).unwrap_or("?"), d.captures.len(), - if d.captures.len() == 1 { "" } else { "s" } + if d.captures.len() == 1 { "capture" } else { "captures" } ), - None => format!("DSO3D12 Viewer v{ver}"), + None => format!("{} v{ver}", t!("app.title")), } } @@ -315,12 +331,9 @@ impl App { Message::Loaded(Ok(d)) => { self.deleted_capture = None; self.deleted_at = None; - self.status = format!( - "Loaded {} capture{} from {}", - d.captures.len(), - if d.captures.len() == 1 { "" } else { "s" }, - d.path.display() - ); + let n = d.captures.len(); + let path_s = d.path.display().to_string(); + self.status = t!("label.loaded_ok", n = n, path = path_s).to_string(); log::info!("{}", self.status); self.current = 0; self.dump = Some(d); @@ -338,7 +351,7 @@ impl App { Task::none() } Message::Loaded(Err(e)) => { - self.status = format!("Load error: {e}"); + self.status = t!("label.load_error", e = format!("{e}")).to_string(); log::error!("load error: {e}"); Task::none() } @@ -349,9 +362,9 @@ impl App { Message::SaveCapturePicked(Some(path)) => { if let Some(dump) = &self.dump { match save_all_captures(&path, &dump.captures) { - Ok(()) => self.status = format!("Saved {} capture(s) to {}", dump.captures.len(), path.display()), + Ok(()) => self.status = t!("label.saved_ok", n = dump.captures.len(), path = path.display().to_string()).to_string(), Err(e) => { - self.status = format!("Save error: {e}"); + self.status = t!("label.save_error", e = format!("{e}")).to_string(); log::error!("save error: {e}"); } } @@ -366,14 +379,14 @@ impl App { Message::PngPathPicked(Some(path)) => { if let Some(cap) = self.current_capture().cloned() { match export_png(&path, &cap, &self.settings) { - Ok(()) => self.status = format!("PNG saved to {}", path.display()), + Ok(()) => self.status = t!("label.png_saved", path = path.display().to_string()).to_string(), Err(e) => { - self.status = format!("PNG error: {e}"); + self.status = t!("label.png_error", e = format!("{e}")).to_string(); log::error!("PNG error: {e}"); } } } else { - self.status = "No capture to export.".to_string(); + self.status = t!("app.no_capture").to_string(); } Task::none() }, @@ -385,9 +398,9 @@ impl App { Message::ExportCsvPicked(Some(path)) => { if let Some(cap) = self.current_capture() { match export_csv(&path, cap) { - Ok(()) => self.status = format!("Wrote {}", path.display()), + Ok(()) => self.status = t!("label.csv_written", path = path.display().to_string()).to_string(), Err(e) => { - self.status = format!("CSV error: {e}"); + self.status = t!("label.csv_error", e = format!("{e}")).to_string(); log::error!("CSV error: {e}"); } } @@ -420,7 +433,7 @@ impl App { let port = match &self.settings.serial_port { Some(p) if !p.is_empty() => p.clone(), _ => { - self.status = "Select a serial port first.".to_string(); + self.status = t!("app.select_port").to_string(); return Task::none(); } }; @@ -434,7 +447,8 @@ impl App { self.serial_rx = Some(rx); self.listening = true; self.capture_progress = 0; - self.status = format!("Listening on {}…", self.settings.serial_port.as_deref().unwrap_or("?")); + let port = self.settings.serial_port.as_deref().unwrap_or("?").to_string(); + self.status = t!("label.listening_on", port = port).to_string(); Task::none() } Message::StopListening => { @@ -443,7 +457,7 @@ impl App { } self.serial_rx = None; self.listening = false; - self.status = "Stopped.".to_string(); + self.status = t!("label.stopped").to_string(); Task::none() } Message::SerialEvent(_) => { @@ -452,14 +466,12 @@ impl App { while let Ok(evt) = rx.try_recv() { match evt { SerialEvent::Connected => { - self.status = format!( - "Connected to {}.", - self.settings.serial_port.as_deref().unwrap_or("?") - ); + let port = self.settings.serial_port.as_deref().unwrap_or("?").to_string(); + self.status = t!("label.connected_to", port = port).to_string(); log::info!("{}", self.status); } SerialEvent::Disconnected => { - self.status = "Port disconnected; reconnecting…".to_string(); + self.status = t!("label.disconnected").to_string(); log::warn!("{}", self.status); } SerialEvent::Progress(n) => { @@ -469,7 +481,7 @@ impl App { play_beep_start(); } self.capture_progress = n; - self.status = format!("Receiving… {n} bytes"); + self.status = t!("label.receiving", n = n).to_string(); } } SerialEvent::CaptureReceived(captures) => { @@ -507,10 +519,8 @@ impl App { self.capture_progress = 0; self.deleted_capture = None; self.deleted_at = None; - self.status = format!( - "Received {count} capture(s). Total: {}", - self.dump.as_ref().map(|d| d.captures.len()).unwrap_or(0) - ); + let total = self.dump.as_ref().map(|d| d.captures.len()).unwrap_or(0); + self.status = t!("label.received_captures", count = count, total = total).to_string(); } SerialEvent::ScreenshotReceived(raw_bytes) => { log::info!("received screenshot packet ({} bytes)", raw_bytes.len()); @@ -555,26 +565,23 @@ impl App { self.capture_progress = 0; self.deleted_capture = None; self.deleted_at = None; - self.status = format!( - "Screenshot received (CH1: {}/div, CH2: {}/div, {}/div). Total: {}", - dso_parser::format_uv( - (self.settings.v_per_div_ch1 * 1_000_000.0) as i64, 0 - ), - dso_parser::format_uv( - (self.settings.v_per_div_ch2 * 1_000_000.0) as i64, 0 - ), - s.timebase_label(), - self.dump.as_ref().map(|d| d.captures.len()).unwrap_or(0) - ); + let total = self.dump.as_ref().map(|d| d.captures.len()).unwrap_or(0); + self.status = t!("label.screenshot_received", + ch1 = dso_parser::format_uv((self.settings.v_per_div_ch1 * 1_000_000.0) as i64, 0), + ch2 = dso_parser::format_uv((self.settings.v_per_div_ch2 * 1_000_000.0) as i64, 0), + timebase = s.timebase_label(), + total = total, + ).to_string(); } Err(e) => { - self.status = format!("Screenshot parse error: {e}"); + self.status = t!("label.load_error", e = format!("{e}")).to_string(); log::error!("screenshot parse: {e}"); } } } SerialEvent::Error(e) => { self.status = format!("Serial: {e}"); + // Not translated — raw device error message log::error!("serial error: {e}"); } } @@ -701,11 +708,6 @@ impl App { } Task::none() } - Message::ToggleInstructions(v) => { - self.settings.show_instructions = v; - self.settings.save(); - Task::none() - } Message::OpenInstructions => { self.show_instructions = true; Task::none() @@ -731,7 +733,7 @@ impl App { self.settings.serial_port = if p.is_empty() { None } else { Some(p) }; self.settings.save(); self.show_settings = false; - self.status = "Settings saved.".to_string(); + self.status = t!("app.settings_saved").to_string(); Task::none() } Message::SettingsBaudChanged(s) => { @@ -762,6 +764,13 @@ impl App { self.settings.save(); Task::none() } + Message::ChangeLanguage(l) => { + log::info!("language -> {l}"); + self.settings.language = l; + self.settings.save(); + crate::i18n::set_language(&self.settings.language); + Task::none() + } Message::PortScanTick => { self.available_ports = serial::list_ports(); // Auto-listen: if enabled and not currently listening and a port is available @@ -792,7 +801,7 @@ impl App { if self.current >= dump.captures.len() && self.current > 0 { self.current -= 1; } - self.status = format!("Deleted capture #{}. Press Undo to restore.", idx + 1); + self.status = t!("label.deleted", n = idx + 1).to_string(); } } Task::none() @@ -807,7 +816,7 @@ impl App { let insert_at = idx.min(dump.captures.len()); dump.captures.insert(insert_at, entry); self.current = insert_at; - self.status = format!("Restored capture #{}.", insert_at + 1); + self.status = t!("label.restored", n = insert_at + 1).to_string(); } Task::none() } @@ -818,7 +827,7 @@ impl App { self.deleted_at = None; // Clear the status hint only if it still mentions undo if self.status.contains("Undo") { - self.status = "Undo expired.".to_string(); + self.status = t!("label.undo_expired").to_string(); } } } @@ -839,8 +848,8 @@ impl App { let idx = self.context_export_idx.take().unwrap_or(self.current); if let Some(cap) = self.dump.as_ref().and_then(|d| d.captures.get(idx)).map(|e| &e.capture).cloned() { match export_png(&path, &cap, &self.settings) { - Ok(()) => self.status = format!("PNG saved to {}", path.display()), - Err(e) => self.status = format!("PNG error: {e}"), + Ok(()) => self.status = t!("label.png_saved", path = path.display().to_string()).to_string(), + Err(e) => self.status = t!("label.png_error", e = format!("{e}")).to_string(), } } Task::none() @@ -860,8 +869,8 @@ impl App { let idx = self.context_export_idx.take().unwrap_or(self.current); if let Some(cap) = self.dump.as_ref().and_then(|d| d.captures.get(idx)).map(|e| &e.capture) { match export_csv(&path, cap) { - Ok(()) => self.status = format!("CSV saved to {}", path.display()), - Err(e) => self.status = format!("CSV error: {e}"), + Ok(()) => self.status = t!("label.csv_written", path = path.display().to_string()).to_string(), + Err(e) => self.status = t!("label.csv_error", e = format!("{e}")).to_string(), } } Task::none() @@ -871,7 +880,7 @@ impl App { self.firmware_port = self.settings.serial_port.clone().unwrap_or_default(); self.firmware_file = None; self.firmware_progress = None; - self.firmware_status = "Select firmware file and port, then click Update.".to_string(); + self.firmware_status = t!("label.firmware_status_ready").to_string(); self.firmware_handle = None; self.firmware_rx = None; self.firmware_confirming = false; @@ -912,7 +921,7 @@ impl App { } Message::FirmwareConfirmStart => { self.firmware_confirming = true; - self.firmware_status = "⚠ Are you sure? This may brick the device. Click Update again to confirm.".to_string(); + self.firmware_status = t!("label.firmware_confirm").to_string(); Task::none() } Message::FirmwareStart => { @@ -921,12 +930,12 @@ impl App { let file = match &self.firmware_file { Some(p) => p.to_string_lossy().to_string(), None => { - self.firmware_status = "No firmware file selected.".to_string(); + self.firmware_status = t!("label.firmware_no_file").to_string(); return Task::none(); } }; if port.is_empty() { - self.firmware_status = "No port selected.".to_string(); + self.firmware_status = t!("label.firmware_no_port").to_string(); return Task::none(); } // Stop listening if active (can't share port) @@ -942,7 +951,7 @@ impl App { self.firmware_handle = Some(handle); self.firmware_rx = Some(rx); self.firmware_progress = Some((0, 1)); - self.firmware_status = "Starting…".to_string(); + self.firmware_status = t!("label.firmware_starting").to_string(); Task::none() } Message::FirmwareEvent(_) => { @@ -962,10 +971,10 @@ impl App { crate::flash::FlashEvent::Finished(result) => { match result { Ok(()) => { - self.firmware_status = "Flash complete! You may close this dialog.".to_string(); + self.firmware_status = t!("label.firmware_complete").to_string(); } Err(e) => { - self.firmware_status = format!("Flash error: {e}"); + self.firmware_status = t!("label.firmware_error", e = format!("{e}")).to_string(); } } finished = true; @@ -1035,102 +1044,165 @@ impl App { } fn settings_dialog(&self) -> Element<'_, Message> { - let title = text("Settings").size(22); + let title = text(t!("label.settings_title")).size(22); + + // ── Language / Display card ── + let lang_display_names = crate::i18n::language_display_names(); + let lang_options: Vec = lang_display_names + .iter() + .map(|(code, display)| LangOption { + code: code.clone(), + display: display.clone(), + }) + .collect(); + let current_lang = lang_options + .iter() + .find(|o| o.code == self.settings.language) + .cloned(); + let lang_pick = pick_list( + lang_options, + current_lang, + |opt: LangOption| Message::ChangeLanguage(opt.code), + ); + + let display_card = container( + column![ + row![ + text(t!("label.display_section")).size(15), + Space::new().width(10.0), + text("🎨").size(20), + ] + .align_y(iced::Alignment::Center), + Space::new().height(10.0), + row![ + text(t!("label.language")).size(13), + Space::new().width(Length::Fill), + lang_pick, + ] + .align_y(iced::Alignment::Center), + ] + .spacing(8), + ) + .padding(16) + .style(container::bordered_box) + .width(Length::Fill); - // ── Serial section ── - let serial_section = container( + // ── Serial port card ── + let serial_card = container( column![ - text("Serial").size(15), - rule::horizontal(1), - text("Default port:").size(13), + row![ + text(t!("label.serial_section")).size(15), + Space::new().width(10.0), + text("🔌").size(20), + ] + .align_y(iced::Alignment::Center), + Space::new().height(10.0), + text(t!("label.default_port")).size(13), text_input("e.g. /dev/tty.usbserial-1110", &self.settings_port_input) .on_input(Message::SettingsPortChanged) .size(13), if self.available_ports.is_empty() { - text("No serial ports detected.").size(11) + text(t!("label.no_ports")).size(11) } else { - text(format!("Available: {}", self.available_ports.join(", "))).size(11) + text(t!("label.available_ports", ports = self.available_ports.join(", "))).size(11) }, - text("Baud rate:").size(13), + text(t!("label.baud_rate")).size(13), text_input("115200", &self.settings_baud_input) .on_input(Message::SettingsBaudChanged) .size(13), checkbox(self.settings.auto_listen) - .label("Auto-listen on startup") + .label(t!("label.auto_listen")) .on_toggle(Message::ToggleAutoListen), checkbox(self.settings.alert_on_data) - .label("Beep on new capture") + .label(t!("label.beep_on_capture")) .on_toggle(Message::ToggleAlertOnData), ] - .spacing(4), + .spacing(8), ) .padding(16) - .style(container::bordered_box); + .style(container::bordered_box) + .width(Length::Fill); - // ── Display section ── - let display_section = container( + // ── Maintenance card (firmware updates) ── + let maintenance_card = container( column![ - text("Display").size(15), - rule::horizontal(1), - checkbox(self.settings.show_instructions) - .label("Show instructions panel") - .on_toggle(Message::ToggleInstructions), + row![ + text(t!("label.firmware_title")).size(15), + Space::new().width(10.0), + text("🛠️").size(20), + ] + .align_y(iced::Alignment::Center), + Space::new().height(10.0), + text(t!("label.firmware_warning")).size(10), + Space::new().height(8.0), + button(text(t!("btn.firmware_update")).size(13)) + .padding([6, 14]) + .on_press(Message::OpenFirmware), ] .spacing(4), ) .padding(16) - .style(container::bordered_box); + .style(container::bordered_box) + .width(Length::Fill); - // ── About section ── + // ── About card ── let ver = env!("CARGO_PKG_VERSION"); let repo = env!("CARGO_PKG_REPOSITORY"); let config_path = crate::settings::config_dir_path() .map(|p| p.display().to_string()) .unwrap_or_else(|| "unknown".to_string()); - let about_section = container( + let about_card = container( column![ - text("About").size(15), - rule::horizontal(1), + row![ + text(t!("label.about_section")).size(15), + Space::new().width(10.0), + text("ⓘ").size(20), + ] + .align_y(iced::Alignment::Center), + Space::new().height(10.0), text(format!("DSO3D12 GUI v{ver}")).size(13), button(text(repo).size(11)) .on_press(Message::OpenUrl(repo.to_string())) .style(button::text), - text(format!("Config: {config_path}")).size(11), - Space::new().height(Length::Fixed(8.0)), - button(text("Firmware Update…").size(13)).on_press(Message::OpenFirmware), + text(format!("{}{}", t!("label.config_path"), config_path)).size(11), ] - .spacing(4), + .spacing(6), ) .padding(16) - .style(container::bordered_box); + .style(container::bordered_box) + .width(Length::Fill); - let close_btn = button(text("Save & Close").size(13)).on_press(Message::CloseSettings); + let close_btn = button(text(t!("btn.save")).size(13)).on_press(Message::CloseSettings); - container( - column![ - title, - Space::new().height(Length::Fixed(8.0)), - serial_section, - Space::new().height(Length::Fixed(8.0)), - display_section, - Space::new().height(Length::Fixed(8.0)), - about_section, - Space::new().height(Length::Fixed(16.0)), - close_btn, - ] - .spacing(4) - .padding(20) - .max_width(500), + scrollable( + container( + column![ + title, + Space::new().height(Length::Fixed(8.0)), + row![display_card, serial_card].spacing(8).width(Length::Fill), + Space::new().height(Length::Fixed(8.0)), + row![maintenance_card, about_card].spacing(8).width(Length::Fill), + Space::new().height(Length::Fixed(16.0)), + close_btn, + ] + .spacing(4) + .padding(20) + .max_width(700), + ) + .center_x(Length::Fill) + .center_y(Length::Fill), ) - .center_x(Length::Fill) - .center_y(Length::Fill) + .direction(scrollable::Direction::Vertical( + scrollable::Scrollbar::new().scroller_width(0.0).width(0.0), + )) + .width(Length::Fill) .into() } fn firmware_dialog(&self) -> Element<'_, Message> { - let title = text("Firmware Update").size(22); + let title = text(t!("label.firmware_title")).size(22); let warning = container( - text("⚠ WARNING: Flashing incorrect firmware may brick your device. Ensure the device is in boot mode and the correct firmware file is selected.") + text(t!("label.firmware_warning")) .size(12), ) .padding(8) @@ -1138,10 +1210,10 @@ impl App { let boot_instructions = container( column![ - text("Switching to Boot Mode (DSO3D12)").size(13), + text(t!("label.boot_title")).size(13), Space::new().height(Length::Fixed(4.0)), - text("1. Connect the supplied USB cable to your PC while the oscilloscope is powered off.").size(11), - text("2. Press and hold the power button. The scope may enter a power-cycling loop — keep the button held down continuously until flashing reaches 100%.").size(11), + text(t!("label.boot_1")).size(11), + text(t!("label.boot_2")).size(11), ] .spacing(2), ) @@ -1157,15 +1229,15 @@ impl App { }, Message::FirmwarePortSelected, ) - .placeholder("Select port…") + .placeholder(t!("label.port")) .text_size(13); - let refresh_btn = button(text("Refresh").size(12)).on_press(Message::FirmwareRefreshPorts); + let refresh_btn = button(text(t!("btn.refresh")).size(12)).on_press(Message::FirmwareRefreshPorts); let file_label = match &self.firmware_file { Some(p) => text(p.file_name().and_then(|n| n.to_str()).unwrap_or("?")).size(13), - None => text("No file selected").size(13), + None => text(t!("label.no_file")).size(13), }; - let file_btn = button(text("Browse…").size(13)).on_press(Message::FirmwareFileClicked); + let file_btn = button(text(t!("btn.browse")).size(13)).on_press(Message::FirmwareFileClicked); let progress_bar_el: Element<'_, Message> = if let Some((cur, total)) = self.firmware_progress { @@ -1175,7 +1247,7 @@ impl App { 0.0 }; column![ - text(format!("{pct:.0}% ({cur}/{total} blocks)")).size(12), + text(t!("label.pct_progress", pct = format!("{:.0}", pct), cur = cur, total = total)).size(12), progress_bar(0.0..=total as f32, cur as f32), ] .spacing(2) @@ -1185,16 +1257,17 @@ impl App { }; let status_text = text(self.firmware_status.clone()).size(12); + // status_text is dynamically set, not translated here let is_flashing = self.firmware_handle.is_some(); let update_btn = if is_flashing { - button(text("Updating…").size(13)) + button(text(t!("btn.updating")).size(13)) } else if self.firmware_confirming { - button(text("Confirm Update").size(13)).on_press(Message::FirmwareStart) + button(text(t!("btn.confirm_update")).size(13)).on_press(Message::FirmwareStart) } else { - button(text("Update").size(13)).on_press(Message::FirmwareConfirmStart) + button(text(t!("btn.update")).size(13)).on_press(Message::FirmwareConfirmStart) }; - let close_btn = button(text("Close").size(13)).on_press(Message::CloseFirmware); + let close_btn = button(text(t!("btn.close")).size(13)).on_press(Message::CloseFirmware); container( column![ @@ -1204,10 +1277,10 @@ impl App { Space::new().height(Length::Fixed(8.0)), boot_instructions, Space::new().height(Length::Fixed(8.0)), - text("Port:").size(13), + text(t!("label.port")).size(13), row![port_picker, refresh_btn].spacing(8).align_y(iced::Alignment::Center), Space::new().height(Length::Fixed(8.0)), - text("Firmware file (.fls):").size(13), + text(t!("label.firmware_file")).size(13), row![file_btn, file_label].spacing(8).align_y(iced::Alignment::Center), Space::new().height(Length::Fixed(8.0)), progress_bar_el, @@ -1225,11 +1298,11 @@ impl App { } fn instructions_overlay(&self) -> Element<'_, Message> { - let title = text("Instructions").size(22); + let title = text(t!("label.instructions_title")).size(22); let content = instructions_panel(); - let close_btn = button(text("Close").size(13)).on_press(Message::CloseInstructions); + let close_btn = button(text(t!("btn.close")).size(13)).on_press(Message::CloseInstructions); container( column![ @@ -1251,13 +1324,13 @@ impl App { fn menu_bar(&self) -> Element<'_, Message> { let port_display = match &self.settings.serial_port { Some(p) => text(p.clone()).size(12), - None => text("No port").size(12), + None => text(t!("app.no_port")).size(12), }; let listen_btn = if self.listening { - button(text("Stop").size(12)).on_press(Message::StopListening) + button(text(t!("btn.stop")).size(12)).on_press(Message::StopListening) } else { - button(text("Listen").size(12)).on_press(Message::StartListening) + button(text(t!("btn.listen")).size(12)).on_press(Message::StartListening) }; let port_picker = pick_list( @@ -1265,13 +1338,13 @@ impl App { self.settings.serial_port.clone(), Message::PortSelected, ) - .placeholder("Port…") + .placeholder(t!("label.port")) .text_size(12); - let help_btn = button(text("Help").size(12)).on_press(Message::OpenInstructions); + let help_btn = button(text(t!("btn.help")).size(12)).on_press(Message::OpenInstructions); let status_row = row![ - text("DSO3D12").size(14), + text(t!("app.title")).size(14), Space::new().width(Length::Fixed(12.0)), port_picker, port_display, @@ -1395,14 +1468,14 @@ impl App { let total_time_ms = 12.0 * t_per_div_ms; let mut items: Vec> = vec![ - text("Measurement").size(13).into(), + text(t!("label.measurement")).size(13).into(), rule::horizontal(1).into(), ]; // ── X cursor (time) ── items.push( checkbox(self.settings.measurement_cursor_x_enabled) - .label("Cursor X (time)") + .label(t!("label.cursor_x")) .on_toggle(Message::ToggleMeasCursorX) .into(), ); @@ -1432,7 +1505,7 @@ impl App { // ── Y cursor (voltage) ── items.push( checkbox(self.settings.measurement_cursor_y_enabled) - .label("Cursor Y (voltage)") + .label(t!("label.cursor_y")) .on_toggle(Message::ToggleMeasCursorY) .into(), ); @@ -1447,9 +1520,9 @@ impl App { let dv_ch2 = (v_ch2_hi - v_ch2_lo).abs(); items.push( column![ - text(format!("CH1 ΔV = {dv_ch1:.3} V")).size(11), + text(t!("label.ch1_dv", v = format!("{:.3}", dv_ch1))).size(11), text(format!(" ({v_ch1_lo:.2}V … {v_ch1_hi:.2}V)")).size(10), - text(format!("CH2 ΔV = {dv_ch2:.3} V")).size(11), + text(t!("label.ch2_dv", v = format!("{:.3}", dv_ch2))).size(11), text(format!(" ({v_ch2_lo:.2}V … {v_ch2_hi:.2}V)")).size(10), ] .spacing(0) @@ -1475,7 +1548,7 @@ impl App { // V/div row items.push( row![ - text("V/div, V:").size(11).width(col_w), + text(t!("label.vdiv")).size(11).width(col_w), text_input("1.0", &self.v_per_div_ch1_input) .on_input(Message::VPerCellChangedCh1) .size(11) @@ -1505,7 +1578,7 @@ impl App { let p2 = if probe_ch2.is_empty() { "—".to_string() } else { probe_ch2 }; items.push( row![ - text("Probe:").size(11).width(col_w), + text(t!("label.probe")).size(11).width(col_w), text(p1).size(11).width(input_w), text(p2).size(11).width(input_w), ] @@ -1516,7 +1589,7 @@ impl App { // V/off row items.push( row![ - text("V/off, V:").size(11).width(col_w), + text(t!("label.voff")).size(11).width(col_w), text_input("0.0", &self.v_offset_ch1_input) .on_input(Message::VOffsetChangedCh1) .size(11) @@ -1533,7 +1606,7 @@ impl App { // t/div row (shared, spans both channels) items.push( row![ - text("t/div, ms:").size(11).width(col_w), + text(t!("label.tdiv")).size(11).width(col_w), text_input("1.0", &self.t_per_div_input) .on_input(Message::TPerCellChanged) .size(11) @@ -1543,7 +1616,7 @@ impl App { .align_y(iced::Alignment::Center) .into(), ); - items.push(text("AC mode: 8×V, 12×t div").size(9).into()); + items.push(text(t!("label.ac_mode")).size(9).into()); items.push(rule::horizontal(1).into()); // ── Signal information (per-channel) ── @@ -1596,7 +1669,7 @@ impl App { // ── Trigger + measurements from screenshot metadata ── if let Some(scope_state) = self.current_entry().and_then(|e| e.record.scope_state.as_ref()) { items.push(rule::horizontal(1).into()); - items.push(text("Trigger").size(11).into()); + items.push(text(t!("label.trigger")).size(11).into()); items.push( text(format!( "Mode: {} | Edge: {} | Src: {}", @@ -1639,7 +1712,7 @@ impl App { let has_m2 = scope_state.ch2.as_ref().and_then(|c| c.measurements.as_ref()); if has_m1.is_some() || has_m2.is_some() { items.push(rule::horizontal(1).into()); - items.push(text("Auto Measurements").size(11).into()); + items.push(text(t!("label.auto_meas")).size(11).into()); let fmt_v = |v: f64| -> String { if v.abs() >= 1.0 { format!("{v:.3} V") } @@ -1657,10 +1730,6 @@ impl App { }; // Build measurement rows: label | CH1 | CH2 - let labels = [ - "Freq", "PkPk", "Avg", "RMS", "Amp", "+Duty", "-Duty", - "+T", "-T", "T", "Max", "Min", "Top", "Base", - ]; let ch_val = |m: Option<&dso_parser::ChannelMeasurements>, idx: usize| -> String { match m { None => "—".to_string(), @@ -1694,19 +1763,41 @@ impl App { items.push( row![ text("").size(9).width(Length::Fixed(36.0)), - text("CH1").size(9).width(Length::Fixed(72.0)), - text("CH2").size(9).width(Length::Fixed(72.0)), + text(t!("label.ch1")).size(9).width(Length::Fixed(72.0)), + text(t!("label.ch2")).size(9).width(Length::Fixed(72.0)), ] .spacing(2) .into(), ); - for (idx, lbl) in labels.iter().enumerate() { - let v1 = ch_val(has_m1, idx); - let v2 = ch_val(has_m2, idx); + let label_data: &[(u8, &str)] = &[ + (0, "Freq"), (1, "PkPk"), (2, "Avg"), (3, "RMS"), (4, "Amp"), + (5, "+Duty"), (6, "-Duty"), (7, "+T"), (8, "-T"), (9, "T"), + (10, "Max"), (11, "Min"), (12, "Top"), (13, "Base"), + ]; + for &(idx, _key) in label_data { + let v1 = ch_val(has_m1, idx as usize); + let v2 = ch_val(has_m2, idx as usize); + let label = match idx { + 0 => t!("label.freq_name").to_string(), + 1 => t!("label.pkpk_name").to_string(), + 2 => t!("label.avg_name").to_string(), + 3 => t!("label.rms_name").to_string(), + 4 => t!("label.amp_name").to_string(), + 5 => t!("label.duty_plus_name").to_string(), + 6 => t!("label.duty_minus_name").to_string(), + 7 => t!("label.t_plus_name").to_string(), + 8 => t!("label.t_minus_name").to_string(), + 9 => t!("label.t_name").to_string(), + 10 => t!("label.max_name").to_string(), + 11 => t!("label.min_name").to_string(), + 12 => t!("label.top_name").to_string(), + 13 => t!("label.base_name").to_string(), + _ => unreachable!(), + }; items.push( row![ - text(*lbl).size(9).width(Length::Fixed(36.0)), + text(label).size(9).width(Length::Fixed(36.0)), text(v1).size(9).width(Length::Fixed(72.0)), text(v2).size(9).width(Length::Fixed(72.0)), ] @@ -1734,7 +1825,7 @@ impl App { let has_dev_y = self.capture_has_device_cursor_y(); let dev_x = { - let cb = checkbox(self.settings.device_cursor_x_enabled).label("Device X"); + let cb = checkbox(self.settings.device_cursor_x_enabled).label(t!("label.device_x")); if has_dev_x { cb.on_toggle(Message::ToggleDeviceCursorX) } else { @@ -1742,7 +1833,7 @@ impl App { } }; let dev_y = { - let cb = checkbox(self.settings.device_cursor_y_enabled).label("Device Y"); + let cb = checkbox(self.settings.device_cursor_y_enabled).label(t!("label.device_y")); if has_dev_y { cb.on_toggle(Message::ToggleDeviceCursorY) } else { @@ -1752,20 +1843,20 @@ impl App { row![ checkbox(self.settings.show_ch1) - .label("CH1") + .label(t!("label.ch1")) .on_toggle(Message::ToggleCh1), checkbox(self.settings.show_ch2) - .label("CH2") + .label(t!("label.ch2")) .on_toggle(Message::ToggleCh2), Space::new().width(Length::Fixed(16.0)), dev_x, dev_y, Space::new().width(Length::Fixed(16.0)), checkbox(self.settings.show_scales) - .label("Scales") + .label(t!("label.scales")) .on_toggle(Message::ToggleShowScales), Space::new().width(Length::Fixed(16.0)), - text("View:").size(13), + text(t!("label.view")).size(13), pick_list( ViewMode::ALL.as_slice(), Some(self.settings.view_mode), @@ -1781,21 +1872,21 @@ impl App { fn action_buttons(&self) -> Element<'_, Message> { row![ - button(text("Load Capture").size(12)).on_press(Message::LoadClicked), - button(text("Save Capture").size(12)).on_press(Message::SaveCaptureClicked), + button(text(t!("btn.load_capture")).size(12)).on_press(Message::LoadClicked), + button(text(t!("btn.save_capture")).size(12)).on_press(Message::SaveCaptureClicked), Space::new().width(Length::Fixed(12.0)), text("│").size(12), Space::new().width(Length::Fixed(12.0)), - button(text("Save PNG").size(12)).on_press(Message::SavePngClicked), + button(text(t!("btn.save_png")).size(12)).on_press(Message::SavePngClicked), checkbox(self.settings.split_png) - .label("Split CH1/CH2") + .label(t!("label.split_ch")) .on_toggle(Message::ToggleSplitPng), Space::new().width(Length::Fixed(12.0)), text("│").size(12), Space::new().width(Length::Fixed(12.0)), - button(text("Export Data").size(12)).on_press(Message::ExportCsvClicked), + button(text(t!("btn.export_data")).size(12)).on_press(Message::ExportCsvClicked), Space::new().width(Length::Fill), - button(text("Settings").size(12)).on_press(Message::OpenSettings), + button(text(t!("btn.settings")).size(12)).on_press(Message::OpenSettings), ] .spacing(6) .padding(4) @@ -1808,36 +1899,37 @@ impl App { fn instructions_panel<'a>() -> Element<'a, Message> { // Keep each complete instruction block in a separate string so it can be // moved into locale files later without restructuring the widget tree. - let standard_firmware_instructions = "\ -Standard firmware — raw debug dump - -1. Connect the oscilloscope to the computer using a USB Type-C cable. -2. Feed a signal into the oscilloscope. -3. Press Stop to freeze the sample buffer. -4. Press Menu → Stop and leave the Channel 1 Measurements menu open. -5. In the application, select the serial port and click Listen. -6. Long-press the Save button on the oscilloscope to start transmitting the debug data. -7. Wait for the transfer to complete. The oscilloscope sends approximately 300 kB at 115200 baud."; - - let customized_firmware_instructions = "\ -Customized firmware - -1. Connect the oscilloscope to the computer using a USB Type-C cable. -2. In the application, select the serial port and click Listen. -3. Capture a waveform on the oscilloscope by pressing Save while acquisition is running or stopped. -4. Press Menu → Gallery to open the saved screenshots. -5. Select the waveform that you want to transmit. -6. The oscilloscope automatically sends a 2048-byte binary packet over the USB serial connection. -7. The packet includes waveform data and metadata such as V/div, timebase, trigger settings, cursors, and measurements."; - - let application_instructions = "\ -Using the waveform viewer - -The application can receive oscilloscope data in both supported transmission modes. - -After a waveform is received, you can inspect it, measure voltage and time intervals with cursors, and export the result as a PNG image or CSV data. - -Before receiving data, connect the oscilloscope using a USB Type-C cable, select the correct serial port, and click Listen. Keep the application listening while starting the transmission from the oscilloscope."; + let standard_firmware_instructions = format!( + "{}\n\n{}\n{}\n{}\n{}\n{}\n{}\n{}", + t!("label.standard_fw_title"), + t!("label.standard_fw_1"), + t!("label.standard_fw_2"), + t!("label.standard_fw_3"), + t!("label.standard_fw_4"), + t!("label.standard_fw_5"), + t!("label.standard_fw_6"), + t!("label.standard_fw_7"), + ); + + let customized_firmware_instructions = format!( + "{}\n\n{}\n{}\n{}\n{}\n{}\n{}\n{}", + t!("label.customized_fw_title"), + t!("label.customized_fw_1"), + t!("label.customized_fw_2"), + t!("label.customized_fw_3"), + t!("label.customized_fw_4"), + t!("label.customized_fw_5"), + t!("label.customized_fw_6"), + t!("label.customized_fw_7"), + ); + + let application_instructions = format!( + "{}\n\n{}\n\n{}\n\n{}", + t!("label.using_viewer_title"), + t!("label.using_viewer_1"), + t!("label.using_viewer_2"), + t!("label.using_viewer_3"), + ); let standard_firmware = container( text(standard_firmware_instructions) @@ -1849,7 +1941,7 @@ Before receiving data, connect the oscilloscope using a USB Type-C cable, select let customized_firmware = container( column![ - button(text("ZeeTweak on GitHub").size(11)) + button(text(t!("btn.help")).size(11)) .on_press(Message::OpenUrl( "https://github.com/taligentx/ZeeTweak".to_owned(), )) @@ -1959,7 +2051,7 @@ fn thumbnails<'a>(dump: &'a Option, current: usize, can_undo: bool) if is_empty { if can_undo { return container( - button(text("Undo Delete").size(12)) + button(text(t!("btn.undo_delete")).size(12)) .on_press(Message::UndoDelete) .style(button::secondary), ) @@ -2009,22 +2101,22 @@ fn thumbnails<'a>(dump: &'a Option, current: usize, can_undo: bool) iced::widget::column![ text(menu_ts.clone()).size(10), rule::horizontal(1), - button(text("Export PNG…").size(12)) + button(text(t!("btn.export_png")).size(12)) .on_press(Message::ContextMenuExportPngAt(ctx_idx)) .width(Length::Fill) .style(button::text), - button(text("Export CSV…").size(12)) + button(text(t!("btn.export_csv")).size(12)) .on_press(Message::ContextMenuExportCsvAt(ctx_idx)) .width(Length::Fill) .style(button::text), rule::horizontal(1), - button(text("Delete").size(12)) + button(text(t!("btn.delete")).size(12)) .on_press(Message::ContextMenuDeleteAt(ctx_idx)) .width(Length::Fill) .style(button::danger), rule::horizontal(1), { - let undo_btn = button(text("Undo Delete").size(12)) + let undo_btn = button(text(t!("btn.undo_delete")).size(12)) .width(Length::Fill) .style(button::text); if can_undo { @@ -2057,8 +2149,8 @@ fn thumbnails<'a>(dump: &'a Option, current: usize, can_undo: bool) async fn pick_file() -> Option { rfd::AsyncFileDialog::new() - .add_filter("ZeeWeii Capture", &["zwcap", "bin", "dat", "raw"]) - .add_filter("All files", &["*"]) + .add_filter(t!("label.zwcap_filter").to_string(), &["zwcap", "bin", "dat", "raw"]) + .add_filter(t!("label.all_files").to_string(), &["*"]) .pick_file() .await .map(|h| h.path().to_path_buf()) @@ -2068,8 +2160,8 @@ async fn pick_save_bin() -> Option { let now = chrono::Local::now(); let name = format!("capture_{}.zwcap", now.format("%Y%m%d_%H%M%S")); rfd::AsyncFileDialog::new() - .add_filter("ZeeWeii Capture", &["zwcap"]) - .add_filter("All files", &["*"]) + .add_filter(t!("label.zwcap_filter").to_string(), &["zwcap"]) + .add_filter(t!("label.all_files").to_string(), &["*"]) .set_file_name(&name) .save_file() .await @@ -2080,7 +2172,7 @@ async fn pick_save_csv(ts: Option>) -> Option { let t = ts.unwrap_or_else(Local::now); let name = format!("capture_{}.csv", t.format("%Y%m%d_%H%M%S")); rfd::AsyncFileDialog::new() - .add_filter("CSV", &["csv"]) + .add_filter(t!("label.csv_filter").to_string(), &["csv"]) .set_file_name(&name) .save_file() .await @@ -2091,7 +2183,7 @@ async fn pick_save_png(ts: Option>) -> Option { let t = ts.unwrap_or_else(Local::now); let name = format!("capture_{}.png", t.format("%Y%m%d_%H%M%S")); rfd::AsyncFileDialog::new() - .add_filter("PNG Image", &["png"]) + .add_filter(t!("label.png_filter").to_string(), &["png"]) .set_file_name(&name) .save_file() .await @@ -2100,8 +2192,8 @@ async fn pick_save_png(ts: Option>) -> Option { async fn pick_firmware_file() -> Option { rfd::AsyncFileDialog::new() - .add_filter("Firmware", &["fls", "bin"]) - .add_filter("All files", &["*"]) + .add_filter(t!("label.fls_filter").to_string(), &["fls", "bin"]) + .add_filter(t!("label.all_files").to_string(), &["*"]) .pick_file() .await .map(|h| h.path().to_path_buf()) diff --git a/gui/src/canvas.rs b/gui/src/canvas.rs index 844a146..7ad1548 100644 --- a/gui/src/canvas.rs +++ b/gui/src/canvas.rs @@ -178,7 +178,7 @@ impl canvas::Program for Scope<'_, Message> { } } else { let text = Text { - content: "No capture loaded — use Load Capture…".to_string(), + content: crate::i18n::t!("app.no_capture_loaded").to_string(), position: Point::new(frame.width() / 2.0, frame.height() / 2.0), color: Color::from_rgb(0.7, 0.7, 0.7), size: 16.0.into(), diff --git a/gui/src/i18n.rs b/gui/src/i18n.rs new file mode 100644 index 0000000..1070a7a --- /dev/null +++ b/gui/src/i18n.rs @@ -0,0 +1,44 @@ +//! Tiny i18n layer. Locales live in `gui/locales/{en,ru,es,zh,hi}.json`. +//! Loaded at runtime via `rust-i18n`. Keys default to the English value. +//! +//! NB: the `rust_i18n::i18n!("locales", fallback = "en");` invocation lives +//! in `main.rs` (must be at the crate root for the macro to expand correctly). + +pub use rust_i18n::t; + + +pub fn set_language(lang: &str) { + rust_i18n::set_locale(lang); +} + +pub const LANGUAGES: &[(&str, &str, &str)] = &[ + ("en", "English", "English"), + ("ru", "Русский", "Russian"), + ("es", "Español", "Spanish"), + ("zh", "中文", "Chinese"), + ("hi", "हिन्दी", "Hindi"), +]; + +/// Returns display names for the language selector, including native names. +/// e.g. "Russian (Русский)", "English (English)" +pub fn language_display_names() -> Vec<(String, String)> { + LANGUAGES + .iter() + .map(|(code, native, english)| { + (code.to_string(), format!("{} ({})", english, native)) + }) + .collect() +} + +/// Detect the system locale and return a matching supported language code, +/// falling back to "en" if none matches. +pub fn detect_system_language() -> String { + if let Some(locale) = sys_locale::get_locale() { + // locale is like "en-US", "ru-RU", "zh-CN", "hi-IN", etc. + let lang = locale.split(['-', '_']).next().unwrap_or("en"); + if LANGUAGES.iter().any(|(code, _, _)| *code == lang) { + return lang.to_string(); + } + } + "en".into() +} diff --git a/gui/src/main.rs b/gui/src/main.rs index 4c371f5..2a0a29c 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -1,6 +1,11 @@ +// `rust_i18n::i18n!` MUST be invoked at the crate root so that the generated +// helper symbol `_rust_i18n_t` is available to callers of `rust_i18n::t!`. +rust_i18n::i18n!("locales", fallback = "en"); + mod app; mod canvas; mod flash; +mod i18n; mod logging; mod range_slider; mod serial; @@ -13,6 +18,19 @@ fn main() -> iced::Result { logging::init(); log::info!("starting dso3d12-gui"); + // Set language: use persisted if non-empty, otherwise detect from system. + { + let settings = crate::settings::Settings::load(); + let lang = if settings.language.is_empty() { + let detected = crate::i18n::detect_system_language(); + log::info!("detected system language: {detected}"); + detected + } else { + settings.language.clone() + }; + crate::i18n::set_language(&lang); + } + let icon = window::icon::from_file_data( include_bytes!("../img/dso3d12_ico.png"), None, diff --git a/gui/src/settings.rs b/gui/src/settings.rs index d8da80b..a259e51 100644 --- a/gui/src/settings.rs +++ b/gui/src/settings.rs @@ -8,7 +8,6 @@ pub struct Settings { /// Last-used serial port (persisted as default for next launch). pub serial_port: Option, pub baud_rate: u32, - pub show_instructions: bool, pub show_ch1: bool, pub show_ch2: bool, /// Show user-controlled measurement cursors on the X axis. @@ -46,6 +45,9 @@ pub struct Settings { /// Voltage offset for CH2 (zero-level of graph) in volts. Default 0.0 V. #[serde(default)] pub v_offset_ch2: f64, + /// Selected UI language code (e.g. "en", "ru", "es", "zh", "hi"). + #[serde(default = "default_language")] + pub language: String, /// Last window size (width, height) in logical pixels. #[serde(default = "default_window_size")] pub window_size: (f32, f32), @@ -59,6 +61,10 @@ fn default_t_per_div_ms() -> f64 { 1.0 } +fn default_language() -> String { + "en".to_string() +} + fn default_window_size() -> (f32, f32) { (800.0, 600.0) } @@ -76,11 +82,11 @@ impl ViewMode { impl std::fmt::Display for ViewMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - ViewMode::Line => "Line", - ViewMode::Dot => "Dot", - ViewMode::Smooth => "Smooth", - }) + match self { + ViewMode::Line => write!(f, "{}", crate::i18n::t!("label.view_mode_line")), + ViewMode::Dot => write!(f, "{}", crate::i18n::t!("label.view_mode_dot")), + ViewMode::Smooth => write!(f, "{}", crate::i18n::t!("label.view_mode_smooth")), + } } } @@ -89,7 +95,6 @@ impl Default for Settings { Self { serial_port: None, baud_rate: 115200, - show_instructions: true, show_ch1: true, show_ch2: true, measurement_cursor_x_enabled: false, @@ -108,6 +113,7 @@ impl Default for Settings { t_per_div_ms: 1.0, v_offset_ch1: 0.0, v_offset_ch2: 0.0, + language: default_language(), window_size: default_window_size(), } } From 1a473ce224f2b749365c2cbc1776ef0f53224eed Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Thu, 16 Jul 2026 19:48:03 +0300 Subject: [PATCH 15/23] GUI: fixed measurement cursors values calculation. Added fine drop mode with CTRL --- gui/locales/en.json | 1 + gui/locales/es.json | 1 + gui/locales/hi.json | 1 + gui/locales/ru.json | 1 + gui/locales/zh.json | 1 + gui/src/app.rs | 133 ++++++++++++++++++++++++++++++++++++++++---- gui/src/canvas.rs | 2 +- 7 files changed, 129 insertions(+), 11 deletions(-) diff --git a/gui/locales/en.json b/gui/locales/en.json index 77c91fb..f6ec2ea 100644 --- a/gui/locales/en.json +++ b/gui/locales/en.json @@ -148,6 +148,7 @@ "label.base_name": "Base", "label.cursor_x_range": "Cursor X (time)", "label.cursor_y_range": "Cursor Y (voltage)", + "label.ctrl_fine_drag": "Hold CTRL for fine drag", "label.view_mode_line": "Line", "label.view_mode_dot": "Dot", "label.view_mode_smooth": "Smooth" diff --git a/gui/locales/es.json b/gui/locales/es.json index 55f4b94..b9629d0 100644 --- a/gui/locales/es.json +++ b/gui/locales/es.json @@ -147,6 +147,7 @@ "label.base_name": "Base", "label.cursor_x_range": "Cursor X (tiempo)", "label.cursor_y_range": "Cursor Y (voltaje)", + "label.ctrl_fine_drag": "Mantén CTRL para arrastre fino", "label.view_mode_line": "Línea", "label.view_mode_dot": "Punto", "label.view_mode_smooth": "Suave" diff --git a/gui/locales/hi.json b/gui/locales/hi.json index fb9ac52..8c5ecf3 100644 --- a/gui/locales/hi.json +++ b/gui/locales/hi.json @@ -147,6 +147,7 @@ "label.base_name": "आधार", "label.cursor_x_range": "कर्सर X (समय)", "label.cursor_y_range": "कर्सर Y (वोल्टेज)", + "label.ctrl_fine_drag": "बारीक खींचने के लिए CTRL दबाएँ", "label.view_mode_line": "रेखा", "label.view_mode_dot": "बिंदु", "label.view_mode_smooth": "चिकना" diff --git a/gui/locales/ru.json b/gui/locales/ru.json index 299c1b7..22fb22a 100644 --- a/gui/locales/ru.json +++ b/gui/locales/ru.json @@ -147,6 +147,7 @@ "label.base_name": "Осн", "label.cursor_x_range": "Курсор X (время)", "label.cursor_y_range": "Курсор Y (напряжение)", + "label.ctrl_fine_drag": "Зажмите CTRL для точного перемещения", "label.view_mode_line": "Линия", "label.view_mode_dot": "Точки", "label.view_mode_smooth": "Сглаженный" diff --git a/gui/locales/zh.json b/gui/locales/zh.json index 25b744f..3ae10a0 100644 --- a/gui/locales/zh.json +++ b/gui/locales/zh.json @@ -147,6 +147,7 @@ "label.base_name": "底端", "label.cursor_x_range": "光标X (时间)", "label.cursor_y_range": "光标Y (电压)", + "label.ctrl_fine_drag": "按住CTRL进行精细拖动", "label.view_mode_line": "连线", "label.view_mode_dot": "散点", "label.view_mode_smooth": "平滑" diff --git a/gui/src/app.rs b/gui/src/app.rs index 2817712..2486c54 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -10,7 +10,13 @@ use iced::widget::{ button, canvas, checkbox, column, container, pick_list, progress_bar, row, rule, scrollable, text, text_input, Space, }; -use iced::{Element, Length, Subscription, Task, Theme}; +use iced::{ + event, + keyboard, + mouse, + Color, Event, + Element, Length, Subscription, Task, Theme, +}; use crate::canvas::{Scope, T_CELLS, Thumbnail, V_CELLS}; use crate::i18n::t; @@ -51,6 +57,8 @@ pub enum Message { ToggleMeasCursorX(bool), ToggleMeasCursorY(bool), MeasCursorXChanged(f32, f32), + ModifiersChanged(keyboard::Modifiers), + LeftMouseReleased, MeasCursorYChanged(f32, f32), // Scale inputs (V/div, time/div, V offset) VPerCellChangedCh1(String), @@ -129,6 +137,20 @@ impl std::fmt::Display for LangOption { } } +/// Factor applied to cursor movement when Ctrl is held (fine-drag mode). +const FINE_DRAG_FACTOR: f32 = 0.1; + +/// Anchor state for fine-dragging a measurement cursor range. +/// Stores the logical and raw slider values at the moment Ctrl+drag began, +/// so that only 1/10 of the raw delta is applied to the logical value. +#[derive(Debug, Clone, Copy)] +struct FineRangeDrag { + /// Slider values when the Ctrl-drag began. + logical_start: (f32, f32), + /// First raw values emitted by the range slider. + raw_start: (f32, f32), +} + // ── State ────────────────────────────────────────────────────────────────── pub struct App { @@ -166,6 +188,9 @@ pub struct App { deleted_capture: Option<(usize, CaptureEntry)>, /// Timestamp of the last deletion, for 30-second undo expiry. deleted_at: Option, + ctrl_pressed: bool, + measurement_cursor_x_fine_drag: Option, + measurement_cursor_y_fine_drag: Option, /// Text input state for V/div scale (CH1). v_per_div_ch1_input: String, /// Text input state for V/div scale (CH2). @@ -221,6 +246,9 @@ impl App { context_export_idx: None, deleted_capture: None, deleted_at: None, + ctrl_pressed: false, + measurement_cursor_x_fine_drag: None, + measurement_cursor_y_fine_drag: None, v_per_div_ch1_input: v_per_div_ch1_str, v_per_div_ch2_input: v_per_div_ch2_str, t_per_div_input: t_per_div_str, @@ -316,6 +344,17 @@ impl App { ); } + // Keyboard modifier and mouse-release events for fine-drag + subs.push(event::listen_with(|event, _status, _window| match event { + Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { + Some(Message::ModifiersChanged(modifiers)) + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + Some(Message::LeftMouseReleased) + } + _ => None, + })); + Subscription::batch(subs) } @@ -624,12 +663,31 @@ impl App { self.settings.save(); Task::none() } - Message::MeasCursorXChanged(lo, hi) => { - self.settings.cursor_x_range = (lo, hi); + Message::MeasCursorXChanged(raw_lo, raw_hi) => { + let ctrl = self.ctrl_pressed; + let anchor = &mut self.measurement_cursor_x_fine_drag; + let range = &mut self.settings.cursor_x_range; + App::apply_fine_drag(ctrl, anchor, range, raw_lo, raw_hi); Task::none() } - Message::MeasCursorYChanged(lo, hi) => { - self.settings.cursor_y_range = (lo, hi); + Message::ModifiersChanged(modifiers) => { + self.ctrl_pressed = modifiers.control(); + if !self.ctrl_pressed { + self.measurement_cursor_x_fine_drag = None; + self.measurement_cursor_y_fine_drag = None; + } + Task::none() + } + Message::LeftMouseReleased => { + self.measurement_cursor_x_fine_drag = None; + self.measurement_cursor_y_fine_drag = None; + Task::none() + } + Message::MeasCursorYChanged(raw_lo, raw_hi) => { + let ctrl = self.ctrl_pressed; + let anchor = &mut self.measurement_cursor_y_fine_drag; + let range = &mut self.settings.cursor_y_range; + App::apply_fine_drag(ctrl, anchor, range, raw_lo, raw_hi); Task::none() } Message::VPerCellChangedCh1(s) => { @@ -1390,6 +1448,33 @@ impl App { self.settings.save(); } + /// Apply fine-drag scaling when Ctrl is held: only 1/10 of the raw + /// slider delta is applied to the logical cursor range. + fn apply_fine_drag( + ctrl_pressed: bool, + drag_anchor: &mut Option, + target_range: &mut (f32, f32), + raw_lo: f32, + raw_hi: f32, + ) { + if ctrl_pressed { + let drag = drag_anchor.get_or_insert(FineRangeDrag { + logical_start: *target_range, + raw_start: (raw_lo, raw_hi), + }); + + let lo = + drag.logical_start.0 + (raw_lo - drag.raw_start.0) * FINE_DRAG_FACTOR; + let hi = + drag.logical_start.1 + (raw_hi - drag.raw_start.1) * FINE_DRAG_FACTOR; + + *target_range = (lo.clamp(0.0, 1.0), hi.clamp(0.0, 1.0)); + } else { + *drag_anchor = None; + *target_range = (raw_lo, raw_hi); + } + } + fn scope_widget(&self) -> Element<'_, Message> { let cap = self.current_capture(); log::trace!( @@ -1482,17 +1567,18 @@ impl App { if self.settings.measurement_cursor_x_enabled { items.push(range_slider::horizontal(xlo, xhi, Message::MeasCursorXChanged)); + items.push(text(t!("label.ctrl_fine_drag")).size(9).color(Color::from_rgb(0.6, 0.6, 0.6)).into()); let t_lo = xlo as f64 * total_time_ms; let t_hi = xhi as f64 * total_time_ms; let dt_ms = t_hi - t_lo; let freq = if dt_ms > 0.0 { - format!("{:.1} Hz", 1000.0 / dt_ms) + format_frequency_hz(1000.0 / dt_ms) } else { "—".to_string() }; items.push( column![ - text(format!("Δt = {dt_ms:.3} ms")).size(11), + text(format!("Δt = {}", format_duration_ms(dt_ms))).size(11), text(format!("≈ freq: {freq}")).size(11), ] .spacing(2) @@ -1512,6 +1598,7 @@ impl App { if self.settings.measurement_cursor_y_enabled { items.push(range_slider::horizontal(ylo, yhi, Message::MeasCursorYChanged)); + // items.push(text(t!("label.ctrl_fine_drag")).size(9).color(Color::from_rgb(0.6, 0.6, 0.6)).into()); let v_ch1_lo = frac_to_voltage(ylo as f64, v_per_div_ch1, v_offset_ch1); let v_ch1_hi = frac_to_voltage(yhi as f64, v_per_div_ch1, v_offset_ch1); let v_ch2_lo = frac_to_voltage(ylo as f64, v_per_div_ch2, v_offset_ch2); @@ -1721,7 +1808,8 @@ impl App { let fmt_t = |s: f64| -> String { if s >= 1.0 { format!("{s:.3} s") } else if s >= 0.001 { format!("{:.3} ms", s * 1000.0) } - else { format!("{:.2} µs", s * 1_000_000.0) } + else if s >= 0.000_001 { format!("{:.2} µs", s * 1_000_000.0) } + else { format!("{:.2} ns", s * 1_000_000_000.0) } }; let fmt_hz = |hz: f64| -> String { if hz >= 1_000_000.0 { format!("{:.3} MHz", hz / 1_000_000.0) } @@ -2023,14 +2111,39 @@ fn format_float(v: f64) -> String { } } +/// Format a time duration given in milliseconds into a human-readable string +/// with automatic unit switching (ms / µs / ns). +fn format_duration_ms(ms: f64) -> String { + if ms >= 1.0 { + format!("{ms:.3} ms") + } else if ms >= 0.001 { + format!("{:.3} µs", ms * 1000.0) + } else { + format!("{:.3} ns", ms * 1_000_000.0) + } +} + +/// Format a frequency in Hz into a human-readable string with automatic unit +/// switching (Hz / kHz / MHz). +fn format_frequency_hz(hz: f64) -> String { + if hz >= 1_000_000.0 { + format!("{:.3} MHz", hz / 1_000_000.0) + } else if hz >= 1000.0 { + format!("{:.3} kHz", hz / 1000.0) + } else { + format!("{hz:.2} Hz") + } +} + /// Grid constants from canvas.rs: 8 divs × 25 px/div = 200 px grid height. use crate::canvas::{GRID_HEIGHT_PX, PX_PER_DIV}; /// Convert a fractional Y position on the graph (0.0 = top, 1.0 = bottom) /// to voltage. Formula from pixel_to_volts: V = V_offset + (ADC_MID - v) × V/div / PX_PER_DIV, -/// where v = frac × GRID_HEIGHT_PX is the stored ADC value at that position. +/// where v = frac × GRID_HEIGHT_PX + PX_PER_DIV matches draw_trace's offset +/// (v=25 maps to y=0, the top of the visible grid). fn frac_to_voltage(frac: f64, v_per_div: f64, v_offset: f64) -> f64 { - let v = frac * GRID_HEIGHT_PX; + let v = frac * GRID_HEIGHT_PX + PX_PER_DIV; v_offset + (ADC_VALUE_MID as f64 - v) * v_per_div / PX_PER_DIV } diff --git a/gui/src/canvas.rs b/gui/src/canvas.rs index 7ad1548..79e858b 100644 --- a/gui/src/canvas.rs +++ b/gui/src/canvas.rs @@ -357,7 +357,7 @@ fn draw_scales( ) { let size = frame.size(); let rows = V_CELLS; - let cols = 10; // grid visual divisions (drawn grid is 10 columns) + let cols = T_CELLS; // grid visual divisions // X axis: time labels at each visual division. let total_time_ms = T_CELLS as f64 * t_per_div_ms; From 2c77ebd3a7662d08ba4fd5b45fa5886bea1395b6 Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Thu, 16 Jul 2026 20:18:42 +0300 Subject: [PATCH 16/23] GUI: fixed time scale for PNG --- gui/src/app.rs | 27 ++++++++++++++++++++++----- gui/src/canvas.rs | 19 ++++--------------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/gui/src/app.rs b/gui/src/app.rs index 2486c54..cd4ffa7 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -2113,7 +2113,7 @@ fn format_float(v: f64) -> String { /// Format a time duration given in milliseconds into a human-readable string /// with automatic unit switching (ms / µs / ns). -fn format_duration_ms(ms: f64) -> String { +pub fn format_duration_ms(ms: f64) -> String { if ms >= 1.0 { format!("{ms:.3} ms") } else if ms >= 0.001 { @@ -2123,6 +2123,17 @@ fn format_duration_ms(ms: f64) -> String { } } +/// Compact variant for scale labels: ms with 0.1 precision, µs/ns with .0. +pub fn format_duration_ms_scale(ms: f64) -> String { + if ms >= 1.0 { + format!("{ms:.1}ms") + } else if ms >= 0.001 { + format!("{:.0}µs", ms * 1000.0) + } else { + format!("{:.0}ns", ms * 1_000_000.0) + } +} + /// Format a frequency in Hz into a human-readable string with automatic unit /// switching (Hz / kHz / MHz). fn format_frequency_hz(hz: f64) -> String { @@ -2559,12 +2570,14 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any for i in 0..=cols { let frac = i as f64 / cols as f64; let time_ms = frac * total_time_ms; - let label = if time_ms >= 1.0 { - format!("{time_ms:.1}") + let label = format_duration_ms_scale(time_ms); + // Position: for i=0 left-aligned, for i=cols right-aligned + // so the last label doesn't overflow past the graph edge. + let x_pos = if i == cols && label.len() > 3 { + x_off + w - (label.len() as u32 * 4) } else { - format!("{:.0}u", time_ms * 1000.0) + x_off + i * w / cols + 2 }; - let x_pos = x_off + i * w / cols + 2; let y_pos = y_off + h + 2; draw_text_tiny(pixels, width, height, &label, x_pos, y_pos, PNG_SCALE_TEXT); } @@ -2804,6 +2817,10 @@ fn tiny_glyph(ch: char) -> [u8; 5] { '.' => [0b000, 0b000, 0b000, 0b000, 0b010], '-' => [0b000, 0b000, 0b111, 0b000, 0b000], 'V' => [0b101, 0b101, 0b101, 0b101, 0b010], + 'm' => [0b000, 0b110, 0b101, 0b101, 0b101], + 's' => [0b011, 0b100, 0b010, 0b001, 0b110], + 'n' => [0b000, 0b110, 0b101, 0b101, 0b101], + 'µ' => [0b000, 0b101, 0b101, 0b111, 0b100], ' ' => [0b000, 0b000, 0b000, 0b000, 0b000], _ => [0b000, 0b000, 0b000, 0b000, 0b000], } diff --git a/gui/src/canvas.rs b/gui/src/canvas.rs index 79e858b..fd3bd8b 100644 --- a/gui/src/canvas.rs +++ b/gui/src/canvas.rs @@ -1,5 +1,6 @@ //! Oscilloscope-style graph canvas (iced::widget::canvas). +use crate::app::{format_duration_ms_scale}; use crate::settings::ViewMode; use dso_parser::{ADC_VALUE_MAX, ADC_VALUE_MID, Capture}; use iced::widget::canvas::{self, Frame, Geometry, Path, Stroke, Text}; @@ -235,21 +236,13 @@ impl canvas::Program for Scope<'_, Message> { let total_time_ms = T_CELLS as f64 * self.t_per_div_ms; let fmt_time = |frac: f32| -> String { let t_ms = frac as f64 * total_time_ms; - if t_ms >= 1.0 { - format!("{:.1}ms", t_ms) - } else { - format!("{:.0}µs", t_ms * 1000.0) - } + format_duration_ms_scale(t_ms) }; frame.fill_text(dev_label(fmt_time(a), Point::new(a.clamp(0.0, 1.0) * w + 2.0, 12.0))); frame.fill_text(dev_label(fmt_time(b), Point::new(b.clamp(0.0, 1.0) * w + 2.0, 12.0))); // Also show Δt between cursors. let dt_ms = (b - a).abs() as f64 * total_time_ms; - let dt_label = if dt_ms >= 1.0 { - format!("Δt={:.1}ms", dt_ms) - } else { - format!("Δt={:.0}µs", dt_ms * 1000.0) - }; + let dt_label = format!("Δt={}", format_duration_ms_scale(dt_ms)); frame.fill_text(dev_label(dt_label, Point::new(4.0, 22.0))); } if let Some((a, b)) = self.device_cursor_y { @@ -366,11 +359,7 @@ fn draw_scales( let frac = i as f64 / cols as f64; let time_ms = frac * total_time_ms; let x = i as f32 * dx; - let content = if time_ms >= 1.0 { - format!("{time_ms:.1}ms") - } else { - format!("{:.0}µs", time_ms * 1000.0) - }; + let content = format_duration_ms_scale(time_ms); let label = Text { content, position: Point::new(x + 2.0, size.height - 12.0), From caafa04969db1d92e96156a078c897eeefde678a Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Fri, 17 Jul 2026 11:14:16 +0300 Subject: [PATCH 17/23] GUI: fixed device cursor values. Added margin for the main graph V-scale. --- dso-parser/src/lib.rs | 2 +- gui/src/canvas.rs | 91 ++++++++++++++++++++++++++++--------------- 2 files changed, 60 insertions(+), 33 deletions(-) diff --git a/dso-parser/src/lib.rs b/dso-parser/src/lib.rs index 50a73af..43dfe4a 100644 --- a/dso-parser/src/lib.rs +++ b/dso-parser/src/lib.rs @@ -51,7 +51,7 @@ pub const ADC_VALUE_MAX: u8 = 255; pub const ADC_VALUE_MID: u8 = 128; /// Device display vertical pixels per division (200 px ÷ 8 div = 25 px/div). -pub const PIXELS_PER_DIV: f64 = 25.0; +pub const PIXELS_PER_DIV: f64 = 25.0; // corresponds to snapshots // ── Capture struct ──────────────────────────────────────────────────────── /// diff --git a/gui/src/canvas.rs b/gui/src/canvas.rs index fd3bd8b..3418bbb 100644 --- a/gui/src/canvas.rs +++ b/gui/src/canvas.rs @@ -2,7 +2,7 @@ use crate::app::{format_duration_ms_scale}; use crate::settings::ViewMode; -use dso_parser::{ADC_VALUE_MAX, ADC_VALUE_MID, Capture}; +use dso_parser::{ADC_VALUE_MID, Capture}; use iced::widget::canvas::{self, Frame, Geometry, Path, Stroke, Text}; use iced::{mouse, Color, Point, Rectangle, Renderer, Size, Theme}; @@ -92,7 +92,17 @@ impl canvas::Program for Scope<'_, Message> { ) -> Option> { let on_click = self.on_click?; let pos = cursor.position_in(bounds)?; - let frac_x = (pos.x / bounds.width).clamp(0.0, 1.0); + let gx = if self.show_scales { + match (self.has_ch1, self.has_ch2) { + (true, true) => SCALE_MARGIN_LEFT * 2.0, + (true, false) | (false, true) => SCALE_MARGIN_LEFT, + (false, false) => 0.0, + } + } else { + 0.0 + }; + let gw = bounds.width - gx; + let frac_x = ((pos.x - gx) / gw).clamp(0.0, 1.0); let frac_y = (pos.y / bounds.height).clamp(0.0, 1.0); match event { @@ -164,7 +174,18 @@ impl canvas::Program for Scope<'_, Message> { let area = Path::rectangle(Point::ORIGIN, frame.size()); frame.fill(&area, BG); - draw_grid(&mut frame, bounds.size()); + let gx = if self.show_scales { + match (self.has_ch1, self.has_ch2) { + (true, true) => SCALE_MARGIN_LEFT * 2.0, + (true, false) | (false, true) => SCALE_MARGIN_LEFT, + (false, false) => 0.0, + } + } else { + 0.0 + }; + let gw = frame.width() - gx; + + draw_grid(&mut frame, bounds.size(), gx); if let Some(cap) = self.capture { // Draw traces for each enabled channel. @@ -174,13 +195,13 @@ impl canvas::Program for Scope<'_, Message> { ]; for &(show, data, color) in &channels { if let Some(samples) = show.then_some(data).flatten() { - draw_trace(&mut frame, samples, color, self.view_mode); + draw_trace(&mut frame, samples, color, self.view_mode, gx); } } } else { let text = Text { content: crate::i18n::t!("app.no_capture_loaded").to_string(), - position: Point::new(frame.width() / 2.0, frame.height() / 2.0), + position: Point::new(gx + gw / 2.0, frame.height() / 2.0), color: Color::from_rgb(0.7, 0.7, 0.7), size: 16.0.into(), align_x: iced::alignment::Horizontal::Center.into(), @@ -192,10 +213,9 @@ impl canvas::Program for Scope<'_, Message> { // Measurement cursors (user-controlled, with shaded range). if let Some((xl, xr)) = self.meas_cursor_x { - let w = frame.width(); let h = frame.height(); - let px_l = xl.clamp(0.0, 1.0) * w; - let px_r = xr.clamp(0.0, 1.0) * w; + let px_l = gx + xl.clamp(0.0, 1.0) * gw; + let px_r = gx + xr.clamp(0.0, 1.0) * gw; let region = Path::rectangle( Point::new(px_l, 0.0), Size::new((px_r - px_l).max(0.0), h), @@ -223,10 +243,9 @@ impl canvas::Program for Scope<'_, Message> { // Device cursors (read-only, dashed-look using thinner line, different colour). let dev_stroke = Stroke::default().with_color(DEV_CURSOR).with_width(1.0); if let Some((a, b)) = self.device_cursor_x { - let w = frame.width(); let h = frame.height(); for x in [a, b] { - let px = x.clamp(0.0, 1.0) * w; + let px = gx + x.clamp(0.0, 1.0) * gw; frame.stroke( &Path::line(Point::new(px, 0.0), Point::new(px, h)), dev_stroke, @@ -238,12 +257,12 @@ impl canvas::Program for Scope<'_, Message> { let t_ms = frac as f64 * total_time_ms; format_duration_ms_scale(t_ms) }; - frame.fill_text(dev_label(fmt_time(a), Point::new(a.clamp(0.0, 1.0) * w + 2.0, 12.0))); - frame.fill_text(dev_label(fmt_time(b), Point::new(b.clamp(0.0, 1.0) * w + 2.0, 12.0))); + frame.fill_text(dev_label(fmt_time(a), Point::new(gx + a.clamp(0.0, 1.0) * gw + 2.0, 12.0))); + frame.fill_text(dev_label(fmt_time(b), Point::new(gx + b.clamp(0.0, 1.0) * gw + 2.0, 12.0))); // Also show Δt between cursors. let dt_ms = (b - a).abs() as f64 * total_time_ms; let dt_label = format!("Δt={}", format_duration_ms_scale(dt_ms)); - frame.fill_text(dev_label(dt_label, Point::new(4.0, 22.0))); + frame.fill_text(dev_label(dt_label, Point::new(gx + 4.0, 22.0))); } if let Some((a, b)) = self.device_cursor_y { let w = frame.width(); @@ -263,15 +282,15 @@ impl canvas::Program for Scope<'_, Message> { (self.v_per_div_ch2, self.v_offset_ch2) }; let cursor_to_voltage = |frac: f32| -> f64 { - // Map fraction (0=top, 1=bottom) to pixel_val (255=top, 0=bottom) - // using the full 0..255 ADC range to match pixel_to_volts. - let pixel_val = (1.0 - frac as f64) * ADC_VALUE_MAX as f64; - v_offset + (pixel_val - ADC_VALUE_MID as f64) * v_per_div / PX_PER_DIV + // Match pixel_to_volts / draw_scales: v = frac × GRID_HEIGHT_PX + PX_PER_DIV + // so that frac=0 (top) maps to v=PX_PER_DIV, aligning with the grid labels. + let v = frac as f64 * GRID_HEIGHT_PX + PX_PER_DIV; + v_offset + (ADC_VALUE_MID as f64 - v) * v_per_div / PX_PER_DIV }; let v_a = cursor_to_voltage(a); let v_b = cursor_to_voltage(b); - frame.fill_text(dev_label(format!("{v_a:.2}V"), Point::new(4.0, a.clamp(0.0, 1.0) * h + 2.0))); - frame.fill_text(dev_label(format!("{v_b:.2}V"), Point::new(4.0, b.clamp(0.0, 1.0) * h + 2.0))); + frame.fill_text(dev_label(format!("{v_a:.2}V"), Point::new(gx + 4.0, a.clamp(0.0, 1.0) * h + 2.0))); + frame.fill_text(dev_label(format!("{v_b:.2}V"), Point::new(gx + 4.0, b.clamp(0.0, 1.0) * h + 2.0))); } // Axis scales (sample numbers on X, voltage on Y per-channel) @@ -286,6 +305,7 @@ impl canvas::Program for Scope<'_, Message> { self.t_per_div_ms, self.v_offset_ch1, self.v_offset_ch2, + gx, ); } @@ -293,16 +313,17 @@ impl canvas::Program for Scope<'_, Message> { } } -fn draw_grid(frame: &mut Frame, size: Size) { +fn draw_grid(frame: &mut Frame, size: Size, margin_left: f32) { // 12 horizontal x 8 vertical divisions, like a real scope. let cols = T_CELLS; let rows = V_CELLS; - let dx = size.width / cols as f32; + let gw = size.width - margin_left; + let dx = gw / cols as f32; let dy = size.height / rows as f32; let stroke = Stroke::default().with_color(GRID).with_width(1.0); - for i in 1..cols { - let x = i as f32 * dx; + for i in 0..cols { + let x = margin_left + i as f32 * dx; let p = Path::line(Point::new(x, 0.0), Point::new(x, size.height)); frame.stroke(&p, stroke); } @@ -313,7 +334,7 @@ fn draw_grid(frame: &mut Frame, size: Size) { } // Centerlines. let axis = Stroke::default().with_color(GRID_AXIS).with_width(1.5); - let cx = size.width / 2.0; + let cx = margin_left + gw / 2.0; let cy = size.height / 2.0; frame.stroke( &Path::line(Point::new(cx, 0.0), Point::new(cx, size.height)), @@ -325,6 +346,9 @@ fn draw_grid(frame: &mut Frame, size: Size) { ); } +/// Left margin reserved for voltage scale labels (pixels). +const SCALE_MARGIN_LEFT: f32 = 40.0; + const SCALE_COLOR: Color = Color::from_rgb(0.60, 0.65, 0.70); /// Number of vertical divisions on the oscilloscope screen for voltage. @@ -334,7 +358,7 @@ pub const T_CELLS: usize = 12; /// Device pixel density: 25 px/division. Re-exported from dso_parser. pub use dso_parser::PIXELS_PER_DIV as PX_PER_DIV; /// Total pixels for the full grid height: V_CELLS × PX_PER_DIV. -pub const GRID_HEIGHT_PX: f64 = V_CELLS as f64 * PX_PER_DIV; +pub const GRID_HEIGHT_PX: f64 = (V_CELLS * PX_PER_DIV as usize) as f64; #[allow(clippy::too_many_arguments)] fn draw_scales( @@ -347,18 +371,20 @@ fn draw_scales( t_per_div_ms: f64, v_offset_ch1: f64, v_offset_ch2: f64, + margin_left: f32, ) { let size = frame.size(); let rows = V_CELLS; let cols = T_CELLS; // grid visual divisions + let gw = size.width - margin_left; // X axis: time labels at each visual division. let total_time_ms = T_CELLS as f64 * t_per_div_ms; - let dx = size.width / cols as f32; + let dx = gw / cols as f32; for i in 0..=cols { let frac = i as f64 / cols as f64; let time_ms = frac * total_time_ms; - let x = i as f32 * dx; + let x = margin_left + i as f32 * dx; let content = format_duration_ms_scale(time_ms); let label = Text { content, @@ -400,7 +426,7 @@ fn draw_scales( } } -fn draw_trace(frame: &mut Frame, samples: &[u8], color: Color, mode: ViewMode) { +fn draw_trace(frame: &mut Frame, samples: &[u8], color: Color, mode: ViewMode, margin_left: f32) { if samples.is_empty() { return; } @@ -409,7 +435,8 @@ fn draw_trace(frame: &mut Frame, samples: &[u8], color: Color, mode: ViewMode) { let stroke = Stroke::default().with_color(color).with_width(1.2); // x maps sample i -> pixel; downsample so we never draw more vertices than pixels. - let max_pts = (size.width as usize).max(2); + let gw = size.width - margin_left; + let max_pts = (gw as usize).max(2); let step = n.div_ceil(max_pts).max(1); let value_at = |i: usize| -> f32 { @@ -427,11 +454,11 @@ fn draw_trace(frame: &mut Frame, samples: &[u8], color: Color, mode: ViewMode) { }; let to_xy = |i: usize, v: f32| -> Point { - let x = (i as f32 / (n - 1).max(1) as f32) * size.width; + let x = margin_left + (i as f32 / (n - 1).max(1) as f32) * gw; // Capture values use screen-coordinate convention: low value = top // of screen = positive voltage, high value = bottom = negative voltage. // Use device pixel density: GRID_HEIGHT_PX = V_CELLS × PX_PER_DIV. - let y = ((v - PX_PER_DIV as f32) / GRID_HEIGHT_PX as f32) * size.height; + let y = (((v - PX_PER_DIV as f32) as f64 / GRID_HEIGHT_PX) * size.height as f64) as f32; Point::new(x, y) }; @@ -509,7 +536,7 @@ impl canvas::Program for Thumbnail<'_> { let mut started = false; for i in (0..n).step_by(step) { let x = (i as f32 / (n - 1).max(1) as f32) * size.width; - let y = ((samples[i] - PX_PER_DIV as u8) as f32 / GRID_HEIGHT_PX as f32) * size.height; + let y = (((samples[i] as f64 - PX_PER_DIV) / GRID_HEIGHT_PX) * size.height as f64) as f32; let p = Point::new(x, y); if started { builder.line_to(p); From dfa23415bce2cca9b48d1bab9124a07d630a3305 Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Tue, 21 Jul 2026 19:08:04 +0300 Subject: [PATCH 18/23] GUI: added an additional bottom margin for t-axis --- gui/src/canvas.rs | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/gui/src/canvas.rs b/gui/src/canvas.rs index 3418bbb..39a48ed 100644 --- a/gui/src/canvas.rs +++ b/gui/src/canvas.rs @@ -102,8 +102,10 @@ impl canvas::Program for Scope<'_, Message> { 0.0 }; let gw = bounds.width - gx; + let gy = if self.show_scales { SCALE_MARGIN_BOTTOM } else { 0.0 }; + let gh = bounds.height - gy; let frac_x = ((pos.x - gx) / gw).clamp(0.0, 1.0); - let frac_y = (pos.y / bounds.height).clamp(0.0, 1.0); + let frac_y = (pos.y / gh).clamp(0.0, 1.0); match event { canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { @@ -183,9 +185,11 @@ impl canvas::Program for Scope<'_, Message> { } else { 0.0 }; + let gy = if self.show_scales { SCALE_MARGIN_BOTTOM } else { 0.0 }; let gw = frame.width() - gx; + let gh = frame.height() - gy; - draw_grid(&mut frame, bounds.size(), gx); + draw_grid(&mut frame, bounds.size(), gx, gy); if let Some(cap) = self.capture { // Draw traces for each enabled channel. @@ -195,13 +199,13 @@ impl canvas::Program for Scope<'_, Message> { ]; for &(show, data, color) in &channels { if let Some(samples) = show.then_some(data).flatten() { - draw_trace(&mut frame, samples, color, self.view_mode, gx); + draw_trace(&mut frame, samples, color, self.view_mode, gx, gh); } } } else { let text = Text { content: crate::i18n::t!("app.no_capture_loaded").to_string(), - position: Point::new(gx + gw / 2.0, frame.height() / 2.0), + position: Point::new(gx + gw / 2.0, gh / 2.0), color: Color::from_rgb(0.7, 0.7, 0.7), size: 16.0.into(), align_x: iced::alignment::Horizontal::Center.into(), @@ -213,7 +217,7 @@ impl canvas::Program for Scope<'_, Message> { // Measurement cursors (user-controlled, with shaded range). if let Some((xl, xr)) = self.meas_cursor_x { - let h = frame.height(); + let h = gh; let px_l = gx + xl.clamp(0.0, 1.0) * gw; let px_r = gx + xr.clamp(0.0, 1.0) * gw; let region = Path::rectangle( @@ -227,7 +231,7 @@ impl canvas::Program for Scope<'_, Message> { } if let Some((yu, yl)) = self.meas_cursor_y { let w = frame.width(); - let h = frame.height(); + let h = gh; let py_u = yu.clamp(0.0, 1.0) * h; let py_l = yl.clamp(0.0, 1.0) * h; let region = Path::rectangle( @@ -243,7 +247,7 @@ impl canvas::Program for Scope<'_, Message> { // Device cursors (read-only, dashed-look using thinner line, different colour). let dev_stroke = Stroke::default().with_color(DEV_CURSOR).with_width(1.0); if let Some((a, b)) = self.device_cursor_x { - let h = frame.height(); + let h = gh; for x in [a, b] { let px = gx + x.clamp(0.0, 1.0) * gw; frame.stroke( @@ -266,7 +270,7 @@ impl canvas::Program for Scope<'_, Message> { } if let Some((a, b)) = self.device_cursor_y { let w = frame.width(); - let h = frame.height(); + let h = gh; for y in [a, b] { let py = y.clamp(0.0, 1.0) * h; frame.stroke( @@ -306,6 +310,7 @@ impl canvas::Program for Scope<'_, Message> { self.v_offset_ch1, self.v_offset_ch2, gx, + gh, ); } @@ -313,13 +318,13 @@ impl canvas::Program for Scope<'_, Message> { } } -fn draw_grid(frame: &mut Frame, size: Size, margin_left: f32) { +fn draw_grid(frame: &mut Frame, size: Size, margin_left: f32, margin_bottom: f32) { // 12 horizontal x 8 vertical divisions, like a real scope. let cols = T_CELLS; let rows = V_CELLS; let gw = size.width - margin_left; let dx = gw / cols as f32; - let dy = size.height / rows as f32; + let dy = (size.height - margin_bottom) / rows as f32; let stroke = Stroke::default().with_color(GRID).with_width(1.0); for i in 0..cols { @@ -327,7 +332,7 @@ fn draw_grid(frame: &mut Frame, size: Size, margin_left: f32) { let p = Path::line(Point::new(x, 0.0), Point::new(x, size.height)); frame.stroke(&p, stroke); } - for i in 1..rows { + for i in 1..rows+1 { let y = i as f32 * dy; let p = Path::line(Point::new(0.0, y), Point::new(size.width, y)); frame.stroke(&p, stroke); @@ -348,6 +353,7 @@ fn draw_grid(frame: &mut Frame, size: Size, margin_left: f32) { /// Left margin reserved for voltage scale labels (pixels). const SCALE_MARGIN_LEFT: f32 = 40.0; +const SCALE_MARGIN_BOTTOM: f32 = 15.0; const SCALE_COLOR: Color = Color::from_rgb(0.60, 0.65, 0.70); @@ -372,13 +378,14 @@ fn draw_scales( v_offset_ch1: f64, v_offset_ch2: f64, margin_left: f32, + graph_height: f32, ) { let size = frame.size(); let rows = V_CELLS; let cols = T_CELLS; // grid visual divisions let gw = size.width - margin_left; - // X axis: time labels at each visual division. + // X axis: time labels at each visual division, in the bottom margin. let total_time_ms = T_CELLS as f64 * t_per_div_ms; let dx = gw / cols as f32; for i in 0..=cols { @@ -388,7 +395,7 @@ fn draw_scales( let content = format_duration_ms_scale(time_ms); let label = Text { content, - position: Point::new(x + 2.0, size.height - 12.0), + position: Point::new(x + 2.0, graph_height + 2.0), color: SCALE_COLOR, size: 9.0.into(), ..Text::default() @@ -406,7 +413,7 @@ fn draw_scales( for i in 0..=rows { let v = ((i + 1) as f64 / rows as f64) * GRID_HEIGHT_PX; let voltage = v_offset + (ADC_VALUE_MID as f64 - v) * v_per_div / PX_PER_DIV; - let y = (i as f32 / rows as f32) * size.height; + let y = (i as f32 / rows as f32) * graph_height; let label = Text { content: format!("{voltage:.2}V"), position: Point::new(x_off, y + 2.0), @@ -426,7 +433,7 @@ fn draw_scales( } } -fn draw_trace(frame: &mut Frame, samples: &[u8], color: Color, mode: ViewMode, margin_left: f32) { +fn draw_trace(frame: &mut Frame, samples: &[u8], color: Color, mode: ViewMode, margin_left: f32, graph_height: f32) { if samples.is_empty() { return; } @@ -458,7 +465,7 @@ fn draw_trace(frame: &mut Frame, samples: &[u8], color: Color, mode: ViewMode, m // Capture values use screen-coordinate convention: low value = top // of screen = positive voltage, high value = bottom = negative voltage. // Use device pixel density: GRID_HEIGHT_PX = V_CELLS × PX_PER_DIV. - let y = (((v - PX_PER_DIV as f32) as f64 / GRID_HEIGHT_PX) * size.height as f64) as f32; + let y = (((v - PX_PER_DIV as f32) as f64 / GRID_HEIGHT_PX) * graph_height as f64) as f32; Point::new(x, y) }; From bd7addcc1bab116c344314d99af1e169ceca67c6 Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Tue, 21 Jul 2026 19:10:23 +0300 Subject: [PATCH 19/23] GUI: refactored PNG generation. Partially fixed scales overlap --- Cargo.lock | 16 +- Cargo.toml | 1 + README.md | 24 ++- gui/Cargo.toml | 1 + gui/locales/en.json | 1 + gui/locales/es.json | 1 + gui/locales/hi.json | 1 + gui/locales/ru.json | 1 + gui/locales/zh.json | 1 + gui/src/app.rs | 363 +++++++++++++++++++++----------------------- gui/src/settings.rs | 8 + 11 files changed, 228 insertions(+), 190 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6584a02..b8d35d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1181,6 +1181,7 @@ dependencies = [ "iced_aw", "log", "open", + "png 0.17.16", "rfd", "rust-i18n", "serde", @@ -2276,7 +2277,7 @@ dependencies = [ "image-webp", "moxcms", "num-traits", - "png", + "png 0.18.1", "qoi", "ravif", "rayon", @@ -3587,6 +3588,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "png" version = "0.18.1" diff --git a/Cargo.toml b/Cargo.toml index 169e103..daf4d6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,4 @@ anyhow = "1.0" thiserror = "2.0" log = "0.4" rust-i18n = "3" +png = "0.17" diff --git a/README.md b/README.md index 6ac318c..8d7c052 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ https://github.com/winnermicro/wm_iot_sdk/tree/master ## Features +### CLI flasher (`flash`) + - Auto-detects the highest supported baud rate (up to 2 Mbps) - XMODEM-1K protocol with CRC-16 verification - Progress bar with ETA during flashing @@ -14,6 +16,14 @@ https://github.com/winnermicro/wm_iot_sdk/tree/master - Optional flash erase before writing - DTR/RTS-based automatic device reset +### GUI viewer (`dso3d12-gui`) + +A graphical application that allows you to visualize and save dumps and screenshots from the ZeeWeii DSO3D12 oscilloscope. + +Once connected, the application automatically retrieves debug dumps and snapshot view data from the device, provided these features have been activated via the device buttons. These graphs can be saved as PNG files or exported as CSV data. + +To use the snapshot view feature, you must install the modified firmware. For more details, visit [ZeeTweak](https://github.com/taligentx/ZeeTweak). The debug dump export feature is available by default. + ## Requirements - Rust toolchain (stable, 2021 edition or later) @@ -25,7 +35,19 @@ https://github.com/winnermicro/wm_iot_sdk/tree/master cargo build --release ``` -The binary is produced at `target/release/flash`. +The binaries are produced at: + +| Binary | Path | +|--------|------| +| CLI flasher | `target/release/flash` | +| CLI capture | `target/release/capture` | +| GUI viewer | `target/release/dso3d12-gui` | + +### Run the GUI from source + +```bash +cargo run --bin dso3d12-gui +``` ## How to use diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 70c9949..341e9ec 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -28,6 +28,7 @@ tokio = { version = "1", features = ["sync", "time"] } crc = "3.2" open = "5" ab_glyph = "0.2" +png = { workspace = true } font-kit = "0.14" rust-i18n = { workspace = true } sys-locale = "0.3" diff --git a/gui/locales/en.json b/gui/locales/en.json index f6ec2ea..11829a6 100644 --- a/gui/locales/en.json +++ b/gui/locales/en.json @@ -86,6 +86,7 @@ "label.beep_on_capture": "Beep on new capture", "label.display_section": "Display", "label.language": "Language", + "label.png_width": "PNG width, px (0=small):", "label.about_section": "About", "label.repository": "Repository:", "label.config_path": "Config path: ", diff --git a/gui/locales/es.json b/gui/locales/es.json index b9629d0..13a8006 100644 --- a/gui/locales/es.json +++ b/gui/locales/es.json @@ -85,6 +85,7 @@ "label.beep_on_capture": "Sonido al recibir captura", "label.display_section": "Pantalla", "label.language": "Idioma", + "label.png_width": "Ancho PNG, px (0=peq.):", "label.about_section": "Acerca de", "label.repository": "Repositorio:", "label.config_path": "Config: ", diff --git a/gui/locales/hi.json b/gui/locales/hi.json index 8c5ecf3..86d3a6c 100644 --- a/gui/locales/hi.json +++ b/gui/locales/hi.json @@ -85,6 +85,7 @@ "label.beep_on_capture": "नए कैप्चर पर बीप", "label.display_section": "प्रदर्शन", "label.language": "भाषा", + "label.png_width": "PNG चौड़ाई, px (0=छोटा):", "label.about_section": "के बारे में", "label.repository": "भंडार:", "label.config_path": "कॉन्फ़िग: ", diff --git a/gui/locales/ru.json b/gui/locales/ru.json index 22fb22a..e8e1207 100644 --- a/gui/locales/ru.json +++ b/gui/locales/ru.json @@ -85,6 +85,7 @@ "label.beep_on_capture": "Звуковой сигнал при захвате", "label.display_section": "Экран", "label.language": "Язык", + "label.png_width": "Ширина PNG, px (0=мал.):", "label.about_section": "О программе", "label.repository": "Репозиторий:", "label.config_path": "Конфиг: ", diff --git a/gui/locales/zh.json b/gui/locales/zh.json index 3ae10a0..a78445e 100644 --- a/gui/locales/zh.json +++ b/gui/locales/zh.json @@ -85,6 +85,7 @@ "label.beep_on_capture": "新捕获时发出提示音", "label.display_section": "显示", "label.language": "语言", + "label.png_width": "PNG宽度,像素(0=小):", "label.about_section": "关于", "label.repository": "仓库:", "label.config_path": "配置路径: ", diff --git a/gui/src/app.rs b/gui/src/app.rs index cd4ffa7..8fefe20 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -78,6 +78,7 @@ pub enum Message { CloseSettings, SettingsBaudChanged(String), SettingsPortChanged(String), + SettingsPngWidthChanged(String), // Firmware dialog OpenFirmware, CloseFirmware, @@ -171,6 +172,8 @@ pub struct App { /// Editable fields in settings dialog. settings_baud_input: String, settings_port_input: String, + /// Editable PNG export width in settings dialog. + png_width_input: String, /// Firmware dialog state. show_firmware: bool, firmware_port: String, @@ -219,6 +222,7 @@ impl App { let t_per_div_str = format_float(settings.t_per_div_ms); let v_offset_ch1_str = format_float(settings.v_offset_ch1); let v_offset_ch2_str = format_float(settings.v_offset_ch2); + let png_width_str = settings.png_export_width.to_string(); ( Self { settings, @@ -234,6 +238,7 @@ impl App { show_instructions: false, settings_baud_input: baud_str, settings_port_input: port_str, + png_width_input: png_width_str, show_firmware: false, firmware_port: String::new(), firmware_file: None, @@ -778,6 +783,7 @@ impl App { self.show_settings = true; self.settings_baud_input = self.settings.baud_rate.to_string(); self.settings_port_input = self.settings.serial_port.clone().unwrap_or_default(); + self.png_width_input = self.settings.png_export_width.to_string(); Task::none() } Message::CloseSettings => { @@ -789,6 +795,12 @@ impl App { } let p = self.settings_port_input.trim().to_string(); self.settings.serial_port = if p.is_empty() { None } else { Some(p) }; + // PNG export width: minimum 300 px, 0 = default small. + if let Ok(w) = self.png_width_input.trim().parse::() { + if w == 0 || w >= 300 { + self.settings.png_export_width = w; + } + } self.settings.save(); self.show_settings = false; self.status = t!("app.settings_saved").to_string(); @@ -802,6 +814,10 @@ impl App { self.settings_port_input = s; Task::none() } + Message::SettingsPngWidthChanged(s) => { + self.png_width_input = s; + Task::none() + } Message::ToggleShowScales(v) => { self.settings.show_scales = v; self.settings.save(); @@ -1138,6 +1154,16 @@ impl App { lang_pick, ] .align_y(iced::Alignment::Center), + Space::new().height(10.0), + row![ + text(t!("label.png_width")).size(13), + Space::new().width(Length::Fill), + text_input("0", &self.png_width_input) + .on_input(Message::SettingsPngWidthChanged) + .size(13) + .width(Length::Fixed(70.0)), + ] + .align_y(iced::Alignment::Center), ] .spacing(8), ) @@ -2470,12 +2496,68 @@ const PNG_CH1: [u8; 3] = [255, 217, 25]; const PNG_CH2: [u8; 3] = [242, 25, 242]; const PNG_SCALE_TEXT: [u8; 3] = [153, 166, 179]; +/// Configuration for PNG export resolution and rendering quality. +pub struct PngExportConfig { + /// Target graph area width in pixels. 0 = default small (600 px). + pub target_width: u32, +} + +impl PngExportConfig { + fn scale(&self) -> f64 { + const BASE: u32 = T_CELLS as u32 * 50; + if self.target_width > 0 { + self.target_width as f64 / BASE as f64 + } else { + 1.0 + } + } + + fn graph_w(&self) -> u32 { + const BASE: u32 = T_CELLS as u32 * 50; + (BASE as f64 * self.scale()).round() as u32 + } + + fn graph_h(&self) -> u32 { + const BASE: u32 = V_CELLS as u32 * 50; + (BASE as f64 * self.scale()).round() as u32 + } + + fn scale_margin_left(&self, show: bool) -> u32 { + if show { (65.0 * self.scale()).round() as u32 } else { 0 } + } + + fn scale_margin_bottom(&self, show: bool) -> u32 { + if show { (16.0 * self.scale()).round() as u32 } else { 0 } + } + + fn grid_line_width(&self) -> u32 { + let s = self.scale(); + if s > 2.0 { 2 } else { 1 } + } + + /// Half-width of the trace line (pixels above/below centre). + /// Scales proportionally to image width, ceiled to whole pixels. + fn trace_half_width(&self) -> i32 { + (self.scale() * 0.8).ceil() as i32 + } + + fn use_system_font(&self) -> bool { + self.target_width > 0 + } + + fn font_size(&self) -> f32 { + (11.0 * self.scale() as f32).max(11.0) + } +} + /// Export the current capture as a PNG image. fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> anyhow::Result<()> { - let graph_w: u32 = T_CELLS as u32 * 50; - let graph_h: u32 = V_CELLS as u32 * 50; - let scale_margin_left: u32 = if settings.show_scales { 50 } else { 0 }; - let scale_margin_bottom: u32 = if settings.show_scales { 16 } else { 0 }; + let config = PngExportConfig { target_width: settings.png_export_width }; + + let graph_w = config.graph_w(); + let graph_h = config.graph_h(); + let scale_margin_left = config.scale_margin_left(settings.show_scales); + let scale_margin_bottom = config.scale_margin_bottom(settings.show_scales); let has_ch2 = cap.ch2.is_some(); let do_split = settings.split_png && settings.show_ch1 && settings.show_ch2 && has_ch2; @@ -2492,26 +2574,43 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any chunk.copy_from_slice(&PNG_BG); } + let use_system_font = config.use_system_font(); + let grid_lw = config.grid_line_width(); + let trace_hw = config.trace_half_width(); + let font_size = config.font_size(); + // Helper to draw a grid into a sub-region of the pixel buffer. let draw_grid = |pixels: &mut Vec, x_off: u32, y_off: u32, w: u32, h: u32| { let grid_color = PNG_GRID; + // Vertical grid lines. for col in 1..T_CELLS as u32 { - let x = x_off + col * w / T_CELLS as u32; - for y in y_off..y_off + h { - let idx = ((y * width + x) * 3) as usize; - pixels[idx..idx + 3].copy_from_slice(&grid_color); + let cx = x_off + col * w / T_CELLS as u32; + for dx in 0..grid_lw { + let x = cx + dx - grid_lw / 2; + if x >= x_off && x < x_off + w { + for y in y_off..y_off + h { + let idx = ((y * width + x) * 3) as usize; + pixels[idx..idx + 3].copy_from_slice(&grid_color); + } + } } } + // Horizontal grid lines. for r in 1..V_CELLS as u32 { - let y = y_off + r * h / V_CELLS as u32; - for x in x_off..x_off + w { - let idx = ((y * width + x) * 3) as usize; - pixels[idx..idx + 3].copy_from_slice(&grid_color); + let cy = y_off + r * h / V_CELLS as u32; + for dy in 0..grid_lw { + let y = cy + dy - grid_lw / 2; + if y >= y_off && y < y_off + h { + for x in x_off..x_off + w { + let idx = ((y * width + x) * 3) as usize; + pixels[idx..idx + 3].copy_from_slice(&grid_color); + } + } } } }; - // Helper to draw a trace into a sub-region. + // Helper to draw a trace into a sub-region with scaled line thickness. let draw_ch = |pixels: &mut Vec, samples: &[u8], color: [u8; 3], x_off: u32, y_off: u32, w: u32, h: u32| { let n = samples.len(); @@ -2526,60 +2625,54 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any yf.round().max(0.0).min((h - 1) as f64) as u32 }; - let mut prev_y = sample_y(0); - let idx = (((y_off + prev_y) * width + x_off) * 3) as usize; - if idx + 2 < pixels.len() { - pixels[idx..idx + 3].copy_from_slice(&color); + let mut set_pixel = |px: u32, py: u32| { + if py < h { + let idx = (((y_off + py) * width + x_off + px) * 3) as usize; + if idx + 2 < pixels.len() { + pixels[idx..idx + 3].copy_from_slice(&color); + } + } + }; + + // Draw first point with thickness. + let first_y = sample_y(0); + for dy in -trace_hw..=trace_hw { + let yy = (first_y as i32 + dy).max(0) as u32; + set_pixel(0, yy); } + let mut prev_y = first_y; for px in 1..w { let cur_y = sample_y(px); let y_start = prev_y.min(cur_y); let y_end = prev_y.max(cur_y); + // Fill the connecting line with thickness. for y in y_start..=y_end { - let idx = (((y_off + y) * width + x_off + px) * 3) as usize; - if idx + 2 < pixels.len() { - pixels[idx..idx + 3].copy_from_slice(&color); - } - } - if cur_y > 0 { - let idx = (((y_off + cur_y - 1) * width + x_off + px) * 3) as usize; - if idx + 2 < pixels.len() { - pixels[idx..idx + 3].copy_from_slice(&color); - } - } - if cur_y + 1 < h { - let idx = (((y_off + cur_y + 1) * width + x_off + px) * 3) as usize; - if idx + 2 < pixels.len() { - pixels[idx..idx + 3].copy_from_slice(&color); + for dy in -trace_hw..=trace_hw { + let yy = (y as i32 + dy).max(0) as u32; + set_pixel(px, yy); } } prev_y = cur_y; } }; - // Helper to draw scale labels (simple 3x5 digit font). + // Helper to draw scale labels (system font when scaled, bitmap glyphs otherwise). let t_per_div_ms = settings.t_per_div_ms; let draw_scales_on_region = |pixels: &mut Vec, x_off: u32, y_off: u32, w: u32, h: u32, _n_samples: usize, vpd_ch1: f64, vo_ch1: f64, ch1_color: Option<[u8; 3]>, vpd_ch2: f64, vo_ch2: f64, ch2_color: Option<[u8; 3]>| { - // X axis: time labels at bottom + // X axis: time labels at bottom (skip last label to avoid overlap). let cols = T_CELLS as u32; let total_time_ms = T_CELLS as f64 * t_per_div_ms; - for i in 0..=cols { + for i in 0..cols { let frac = i as f64 / cols as f64; let time_ms = frac * total_time_ms; let label = format_duration_ms_scale(time_ms); - // Position: for i=0 left-aligned, for i=cols right-aligned - // so the last label doesn't overflow past the graph edge. - let x_pos = if i == cols && label.len() > 3 { - x_off + w - (label.len() as u32 * 4) - } else { - x_off + i * w / cols + 2 - }; + let x_pos = x_off + i * w / cols + 2; let y_pos = y_off + h + 2; - draw_text_tiny(pixels, width, height, &label, x_pos, y_pos, PNG_SCALE_TEXT); + draw_text_tiny(pixels, width, height, &label, x_pos, y_pos, PNG_SCALE_TEXT, use_system_font, font_size); } // Y axis: voltage labels on left margin, per-channel. @@ -2591,12 +2684,12 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any let v = ((i + 1) as f64 / rows as f64) * GRID_HEIGHT_PX; let voltage = vo + (ADC_VALUE_MID as f64 - v) * vpd / PX_PER_DIV; let y_pos = y_off + (i * h / rows); - draw_text_tiny(pixels, width, height, &format!("{voltage:.1}"), x_lbl, y_pos, color); + draw_text_tiny(pixels, width, height, &format!("{voltage:.1}"), x_lbl, y_pos, color, use_system_font, font_size); } }; let x_ch1 = 2u32; - let x_ch2 = if ch1_color.is_some() { 26u32 } else { 2u32 }; + let x_ch2 = if ch1_color.is_some() { (scale_margin_left as f64 * 0.4).round() as u32 } else { 2u32 }; if let Some(c) = ch1_color { draw_voltage_label(vpd_ch1, vo_ch1, c, x_ch1); } if let Some(c) = ch2_color { draw_voltage_label(vpd_ch2, vo_ch2, c, x_ch2); } }; @@ -2653,18 +2746,31 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any Ok(()) } -/// Threshold below which the tiny (3×5) bitmap font is used instead of a system font. -const TINY_FONT_MAX_HEIGHT: u32 = 1000; +/// Threshold below which the tiny (3×5) bitmap font is used instead of a system font +/// (only when `force_system_font` is false). +const TINY_FONT_MAX_HEIGHT: u32 = 400; /// Draw text onto a raw RGB pixel buffer. /// -/// Uses the built-in 3×5 bitmap font when `img_height ≤ TINY_FONT_MAX_HEIGHT`, -/// otherwise renders with the system monospace font via `ab_glyph`. -fn draw_text_tiny(pixels: &mut [u8], img_width: u32, img_height: u32, text: &str, x: u32, y: u32, color: [u8; 3]) { - if img_height <= TINY_FONT_MAX_HEIGHT { - draw_tiny_glyph(pixels, img_width, text, x, y, color); +/// Uses the built-in 3×5 bitmap font when `force_system_font` is false and +/// `img_height ≤ TINY_FONT_MAX_HEIGHT`, otherwise renders with the system +/// monospace font via `ab_glyph` at the given `font_size`. +#[allow(clippy::too_many_arguments)] +fn draw_text_tiny( + pixels: &mut [u8], + img_width: u32, + img_height: u32, + text: &str, + x: u32, + y: u32, + color: [u8; 3], + force_system_font: bool, + font_size: f32, +) { + if force_system_font || img_height > TINY_FONT_MAX_HEIGHT { + draw_system_font(pixels, img_width, text, x, y, color, font_size); } else { - draw_system_font(pixels, img_width, text, x, y, color); + draw_tiny_glyph(pixels, img_width, text, x, y, color); } } @@ -2692,6 +2798,7 @@ fn draw_tiny_glyph(pixels: &mut [u8], img_width: u32, text: &str, x: u32, y: u32 /// Render text using a system monospace font via `ab_glyph`. /// Render text into an RGB pixel buffer using the operating system's /// default monospace font family. +#[allow(clippy::too_many_arguments)] fn draw_system_font( pixels: &mut [u8], img_width: u32, @@ -2699,6 +2806,7 @@ fn draw_system_font( x: u32, y: u32, color: [u8; 3], + font_size: f32, ) { use ab_glyph::{Font, FontArc, PxScale, ScaleFont}; use font_kit::{ @@ -2738,9 +2846,7 @@ fn draw_system_font( return; }; - const FONT_SIZE: f32 = 11.0; - - let scaled_font = font.as_scaled(PxScale::from(FONT_SIZE)); + let scaled_font = font.as_scaled(PxScale::from(font_size)); let baseline = y as f32 + scaled_font.ascent(); let mut cursor_x = x as f32; @@ -2749,7 +2855,7 @@ fn draw_system_font( let glyph_id = font.glyph_id(ch); let glyph = glyph_id.with_scale_and_position( - FONT_SIZE, + font_size, ab_glyph::point(cursor_x, baseline), ); @@ -2817,7 +2923,7 @@ fn tiny_glyph(ch: char) -> [u8; 5] { '.' => [0b000, 0b000, 0b000, 0b000, 0b010], '-' => [0b000, 0b000, 0b111, 0b000, 0b000], 'V' => [0b101, 0b101, 0b101, 0b101, 0b010], - 'm' => [0b000, 0b110, 0b101, 0b101, 0b101], + 'm' => [0b101, 0b111, 0b101, 0b101, 0b101], 's' => [0b011, 0b100, 0b010, 0b001, 0b110], 'n' => [0b000, 0b110, 0b101, 0b101, 0b101], 'µ' => [0b000, 0b101, 0b101, 0b111, 0b100], @@ -2826,129 +2932,24 @@ fn tiny_glyph(ch: char) -> [u8; 5] { } } -/// Write an RGB pixel buffer as a PNG file. -/// Minimal implementation using uncompressed deflate (store blocks). +/// Write an RGB pixel buffer as a PNG file using the `png` crate with +/// maximum compression (no quality loss). fn write_png(path: &std::path::Path, width: u32, height: u32, rgb: &[u8]) -> anyhow::Result<()> { - use std::io::Write; - - let mut file = std::fs::File::create(path)?; - - // PNG signature - file.write_all(&[137, 80, 78, 71, 13, 10, 26, 10])?; - - // IHDR chunk - let mut ihdr = Vec::with_capacity(13); - ihdr.extend_from_slice(&width.to_be_bytes()); - ihdr.extend_from_slice(&height.to_be_bytes()); - ihdr.push(8); // bit depth - ihdr.push(2); // color type: RGB - ihdr.push(0); // compression - ihdr.push(0); // filter - ihdr.push(0); // interlace - write_png_chunk(&mut file, b"IHDR", &ihdr)?; - - // IDAT chunk — raw image data compressed with deflate (store only). - // Each row is: filter_byte(0) + RGB pixels. - let row_len = 1 + width as usize * 3; - let raw_size = row_len * height as usize; - - // Build the unfiltered image data - let mut raw = Vec::with_capacity(raw_size); - for y in 0..height as usize { - raw.push(0); // filter byte: None - let start = y * width as usize * 3; - let end = start + width as usize * 3; - raw.extend_from_slice(&rgb[start..end]); - } + use std::io::BufWriter; - // Wrap in zlib format (store blocks, no compression) - let deflated = zlib_store(&raw); - write_png_chunk(&mut file, b"IDAT", &deflated)?; + let file = std::fs::File::create(path)?; + let w = BufWriter::new(file); - // IEND chunk - write_png_chunk(&mut file, b"IEND", &[])?; - - Ok(()) -} + let mut encoder = png::Encoder::new(w, width, height); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_compression(png::Compression::Best); -fn write_png_chunk(w: &mut dyn std::io::Write, chunk_type: &[u8; 4], data: &[u8]) -> std::io::Result<()> { - let len = data.len() as u32; - w.write_all(&len.to_be_bytes())?; - w.write_all(chunk_type)?; - w.write_all(data)?; - // CRC32 over type + data - let crc = png_crc32(chunk_type, data); - w.write_all(&crc.to_be_bytes())?; + let mut writer = encoder.write_header()?; + writer.write_image_data(rgb)?; Ok(()) } -fn png_crc32(chunk_type: &[u8; 4], data: &[u8]) -> u32 { - let mut crc: u32 = 0xFFFF_FFFF; - for &b in chunk_type.iter().chain(data.iter()) { - let idx = ((crc ^ b as u32) & 0xFF) as usize; - crc = CRC_TABLE[idx] ^ (crc >> 8); - } - crc ^ 0xFFFF_FFFF -} - -/// Wraps data in zlib format using only store (no compression) deflate blocks. -fn zlib_store(data: &[u8]) -> Vec { - let mut out = Vec::with_capacity(data.len() + 64); - // Zlib header: CM=8 (deflate), CINFO=7 (32K window), FCHECK so header%31==0 - out.push(0x78); // CMF - out.push(0x01); // FLG (no dict, level 0; 0x7801 % 31 == 0) - - // Deflate store blocks — max 65535 bytes per block - let mut remaining = data; - while !remaining.is_empty() { - let block_size = remaining.len().min(65535); - let is_last = block_size == remaining.len(); - out.push(if is_last { 0x01 } else { 0x00 }); // BFINAL + BTYPE=00 - let len = block_size as u16; - let nlen = !len; - out.extend_from_slice(&len.to_le_bytes()); - out.extend_from_slice(&nlen.to_le_bytes()); - out.extend_from_slice(&remaining[..block_size]); - remaining = &remaining[block_size..]; - } - - // Adler-32 checksum - let adler = adler32(data); - out.extend_from_slice(&adler.to_be_bytes()); - out -} - -fn adler32(data: &[u8]) -> u32 { - let mut a: u32 = 1; - let mut b: u32 = 0; - for &byte in data { - a = (a + byte as u32) % 65521; - b = (b + a) % 65521; - } - (b << 16) | a -} - -// CRC32 lookup table for PNG -const CRC_TABLE: [u32; 256] = { - let mut table = [0u32; 256]; - let mut n = 0usize; - while n < 256 { - let mut c = n as u32; - let mut k = 0; - while k < 8 { - if c & 1 != 0 { - c = 0xEDB8_8320 ^ (c >> 1); - } else { - c >>= 1; - } - k += 1; - } - table[n] = c; - n += 1; - } - table -}; - // ── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -3055,20 +3056,6 @@ mod tests { assert!((ms - 1.0).abs() < 0.01); } - #[test] - fn png_crc32_is_deterministic() { - let crc1 = png_crc32(b"IHDR", &[0u8; 13]); - let crc2 = png_crc32(b"IHDR", &[0u8; 13]); - assert_eq!(crc1, crc2); - } - - #[test] - fn adler32_known_value() { - // adler32 of "Wikipedia" = 0x11E60398 - let result = adler32(b"Wikipedia"); - assert_eq!(result, 0x11E6_0398); - } - #[test] fn settings_dialog_opens_and_closes() { let (mut app, _) = App::new(); diff --git a/gui/src/settings.rs b/gui/src/settings.rs index a259e51..a7c8fd8 100644 --- a/gui/src/settings.rs +++ b/gui/src/settings.rs @@ -51,6 +51,9 @@ pub struct Settings { /// Last window size (width, height) in logical pixels. #[serde(default = "default_window_size")] pub window_size: (f32, f32), + /// Target graph width for PNG export in pixels. 0 = small (600 px), 800+ = high-res. + #[serde(default = "default_png_width")] + pub png_export_width: u32, } fn default_v_per_div() -> f64 { @@ -69,6 +72,10 @@ fn default_window_size() -> (f32, f32) { (800.0, 600.0) } +fn default_png_width() -> u32 { + 800 +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ViewMode { Line, @@ -115,6 +122,7 @@ impl Default for Settings { v_offset_ch2: 0.0, language: default_language(), window_size: default_window_size(), + png_export_width: 800, } } } From 15d4b845e4203b0ef8cab00f2ac20838a78cd0d5 Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Wed, 22 Jul 2026 09:34:44 +0300 Subject: [PATCH 20/23] GUI: canvas: fixed central line position --- gui/src/canvas.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/gui/src/canvas.rs b/gui/src/canvas.rs index 39a48ed..a1c65d4 100644 --- a/gui/src/canvas.rs +++ b/gui/src/canvas.rs @@ -324,12 +324,13 @@ fn draw_grid(frame: &mut Frame, size: Size, margin_left: f32, margin_bottom: f32 let rows = V_CELLS; let gw = size.width - margin_left; let dx = gw / cols as f32; - let dy = (size.height - margin_bottom) / rows as f32; + let graph_height = size.height - margin_bottom; + let dy = graph_height / rows as f32; let stroke = Stroke::default().with_color(GRID).with_width(1.0); for i in 0..cols { let x = margin_left + i as f32 * dx; - let p = Path::line(Point::new(x, 0.0), Point::new(x, size.height)); + let p = Path::line(Point::new(x, 0.0), Point::new(x, graph_height)); frame.stroke(&p, stroke); } for i in 1..rows+1 { @@ -340,9 +341,9 @@ fn draw_grid(frame: &mut Frame, size: Size, margin_left: f32, margin_bottom: f32 // Centerlines. let axis = Stroke::default().with_color(GRID_AXIS).with_width(1.5); let cx = margin_left + gw / 2.0; - let cy = size.height / 2.0; + let cy = graph_height / 2.0; frame.stroke( - &Path::line(Point::new(cx, 0.0), Point::new(cx, size.height)), + &Path::line(Point::new(cx, 0.0), Point::new(cx, graph_height)), axis, ); frame.stroke( From c0d654f6eed51c6376c81530eb48c7467b59a15d Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Wed, 22 Jul 2026 10:16:56 +0300 Subject: [PATCH 21/23] GUI: added antialiasing for PNG and proper processing for low/high resolution --- Cargo.lock | 33 +++- Cargo.toml | 1 + gui/Cargo.toml | 1 + gui/src/app.rs | 418 ++++++++++++++++++++++++++++++++++--------------- 4 files changed, 320 insertions(+), 133 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b8d35d4..c095b85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1189,6 +1189,7 @@ dependencies = [ "serialport", "sys-locale", "thiserror 2.0.18", + "tiny-skia 0.12.0", "tokio", ] @@ -2081,7 +2082,7 @@ dependencies = [ "log", "rustc-hash 2.1.2", "softbuffer", - "tiny-skia", + "tiny-skia 0.11.4", ] [[package]] @@ -4184,7 +4185,7 @@ dependencies = [ "log", "memmap2", "smithay-client-toolkit 0.19.2", - "tiny-skia", + "tiny-skia 0.11.4", ] [[package]] @@ -4692,7 +4693,22 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "tiny-skia-path", + "tiny-skia-path 0.11.4", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.18.1", + "tiny-skia-path 0.12.0", ] [[package]] @@ -4706,6 +4722,17 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tiny-xlib" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index daf4d6d..7ad725f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,3 +19,4 @@ thiserror = "2.0" log = "0.4" rust-i18n = "3" png = "0.17" +tiny-skia = "0.12" diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 341e9ec..214f819 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -29,6 +29,7 @@ crc = "3.2" open = "5" ab_glyph = "0.2" png = { workspace = true } +tiny-skia = { workspace = true } font-kit = "0.14" rust-i18n = { workspace = true } sys-locale = "0.3" diff --git a/gui/src/app.rs b/gui/src/app.rs index 8fefe20..03285bd 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -2523,22 +2523,24 @@ impl PngExportConfig { } fn scale_margin_left(&self, show: bool) -> u32 { - if show { (65.0 * self.scale()).round() as u32 } else { 0 } + if show { + ((65.0 * self.scale()).round() as u32).max(52) + } else { + 0 + } } fn scale_margin_bottom(&self, show: bool) -> u32 { if show { (16.0 * self.scale()).round() as u32 } else { 0 } } - fn grid_line_width(&self) -> u32 { - let s = self.scale(); - if s > 2.0 { 2 } else { 1 } + fn grid_width(&self) -> f32 { + (0.75 * self.scale().sqrt() as f32).clamp(0.75, 2.0) } - /// Half-width of the trace line (pixels above/below centre). - /// Scales proportionally to image width, ceiled to whole pixels. - fn trace_half_width(&self) -> i32 { - (self.scale() * 0.8).ceil() as i32 + /// Full line width for the trace in pixels. + fn trace_width(&self) -> f32 { + (1.25 * self.scale().sqrt() as f32).clamp(1.0, 4.0) } fn use_system_font(&self) -> bool { @@ -2569,101 +2571,50 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any graph_h + scale_margin_bottom }; - let mut pixels = vec![0u8; (width * height * 3) as usize]; - for chunk in pixels.chunks_exact_mut(3) { - chunk.copy_from_slice(&PNG_BG); + // RGBA pixel buffer for tiny-skia. + let mut pixels = vec![0u8; (width * height * 4) as usize]; + for chunk in pixels.chunks_exact_mut(4) { + chunk.copy_from_slice(&[PNG_BG[0], PNG_BG[1], PNG_BG[2], 255]); } - let use_system_font = config.use_system_font(); - let grid_lw = config.grid_line_width(); - let trace_hw = config.trace_half_width(); + // Disable anti-aliasing at very low resolution to keep a crisp look. + let antialias = graph_w >= 400; + // Tie system-font choice to antialias: use the aliased bitmap glyph when + // anti-aliasing is off, so text stays crisp and fits the narrow margins. + let use_system_font = config.use_system_font() && antialias; let font_size = config.font_size(); - - // Helper to draw a grid into a sub-region of the pixel buffer. - let draw_grid = |pixels: &mut Vec, x_off: u32, y_off: u32, w: u32, h: u32| { - let grid_color = PNG_GRID; - // Vertical grid lines. - for col in 1..T_CELLS as u32 { - let cx = x_off + col * w / T_CELLS as u32; - for dx in 0..grid_lw { - let x = cx + dx - grid_lw / 2; - if x >= x_off && x < x_off + w { - for y in y_off..y_off + h { - let idx = ((y * width + x) * 3) as usize; - pixels[idx..idx + 3].copy_from_slice(&grid_color); - } - } - } - } - // Horizontal grid lines. - for r in 1..V_CELLS as u32 { - let cy = y_off + r * h / V_CELLS as u32; - for dy in 0..grid_lw { - let y = cy + dy - grid_lw / 2; - if y >= y_off && y < y_off + h { - for x in x_off..x_off + w { - let idx = ((y * width + x) * 3) as usize; - pixels[idx..idx + 3].copy_from_slice(&grid_color); - } - } - } - } - }; - - // Helper to draw a trace into a sub-region with scaled line thickness. - let draw_ch = - |pixels: &mut Vec, samples: &[u8], color: [u8; 3], x_off: u32, y_off: u32, w: u32, h: u32| { - let n = samples.len(); - if n == 0 { - return; - } - let sample_y = |px: u32| -> u32 { - let i = (px as f64 / w as f64 * (n - 1) as f64) as usize; - let v = samples[i.min(n - 1)]; - // Match draw_trace: subtract PX_PER_DIV so v=25 maps to y=0 (top). - let yf = ((v as f64 - PX_PER_DIV) / GRID_HEIGHT_PX) * (h - 1) as f64; - yf.round().max(0.0).min((h - 1) as f64) as u32 - }; - - let mut set_pixel = |px: u32, py: u32| { - if py < h { - let idx = (((y_off + py) * width + x_off + px) * 3) as usize; - if idx + 2 < pixels.len() { - pixels[idx..idx + 3].copy_from_slice(&color); - } - } - }; - - // Draw first point with thickness. - let first_y = sample_y(0); - for dy in -trace_hw..=trace_hw { - let yy = (first_y as i32 + dy).max(0) as u32; - set_pixel(0, yy); - } - - let mut prev_y = first_y; - for px in 1..w { - let cur_y = sample_y(px); - let y_start = prev_y.min(cur_y); - let y_end = prev_y.max(cur_y); - // Fill the connecting line with thickness. - for y in y_start..=y_end { - for dy in -trace_hw..=trace_hw { - let yy = (y as i32 + dy).max(0) as u32; - set_pixel(px, yy); - } - } - prev_y = cur_y; - } + let trace_w = config.trace_width(); + // When anti-aliasing is off, sub-pixel strokes may not render; ensure a + // minimum visible width. + let grid_w = if antialias { config.grid_width() } else { config.grid_width().max(1.0) }; + let smooth = settings.view_mode == ViewMode::Smooth; + let dot_mode = settings.view_mode == ViewMode::Dot; + let dot_radius = (trace_w * 0.6).max(1.0); + + // ── Grid helper via tiny-skia ──────────────────────────────────── + let draw_grid_on = |pixels: &mut [u8], y_off: u32| { + let Some(grid_path) = build_grid_path(scale_margin_left, y_off, graph_w, graph_h) else { + return; + }; + let Some(mut pixmap) = tiny_skia::PixmapMut::from_bytes(pixels, width, height) else { + return; }; + let mut paint = tiny_skia::Paint::default(); + paint.set_color(rgb_color(PNG_GRID)); + paint.anti_alias = antialias; + let stroke = tiny_skia::Stroke { + width: grid_w, + ..tiny_skia::Stroke::default() + }; + pixmap.stroke_path(&grid_path, &paint, &stroke, tiny_skia::Transform::identity(), None); + }; - // Helper to draw scale labels (system font when scaled, bitmap glyphs otherwise). + // ── Scale labels (unchanged, uses existing text renderer) ───────── let t_per_div_ms = settings.t_per_div_ms; let draw_scales_on_region = - |pixels: &mut Vec, x_off: u32, y_off: u32, w: u32, h: u32, _n_samples: usize, + |pixels: &mut [u8], x_off: u32, y_off: u32, w: u32, h: u32, _n_samples: usize, vpd_ch1: f64, vo_ch1: f64, ch1_color: Option<[u8; 3]>, vpd_ch2: f64, vo_ch2: f64, ch2_color: Option<[u8; 3]>| { - // X axis: time labels at bottom (skip last label to avoid overlap). let cols = T_CELLS as u32; let total_time_ms = T_CELLS as f64 * t_per_div_ms; for i in 0..cols { @@ -2672,19 +2623,17 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any let label = format_duration_ms_scale(time_ms); let x_pos = x_off + i * w / cols + 2; let y_pos = y_off + h + 2; - draw_text_tiny(pixels, width, height, &label, x_pos, y_pos, PNG_SCALE_TEXT, use_system_font, font_size); + draw_text_tiny(pixels, width, &label, x_pos, y_pos, PNG_SCALE_TEXT, use_system_font, font_size); } // Y axis: voltage labels on left margin, per-channel. - // Formula from pixel_to_volts: V = V_offset + (ADC_MID - v) × V/div / PX_PER_DIV. - // Match draw_scales: use (i+1) offset so v=25 maps to top label. let rows = V_CELLS as u32; let mut draw_voltage_label = |vpd: f64, vo: f64, color: [u8; 3], x_lbl: u32| { for i in 0..=rows { let v = ((i + 1) as f64 / rows as f64) * GRID_HEIGHT_PX; let voltage = vo + (ADC_VALUE_MID as f64 - v) * vpd / PX_PER_DIV; let y_pos = y_off + (i * h / rows); - draw_text_tiny(pixels, width, height, &format!("{voltage:.1}"), x_lbl, y_pos, color, use_system_font, font_size); + draw_text_tiny(pixels, width, &format!("{voltage:.1}"), x_lbl, y_pos, color, use_system_font, font_size); } }; @@ -2694,41 +2643,57 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any if let Some(c) = ch2_color { draw_voltage_label(vpd_ch2, vo_ch2, c, x_ch2); } }; - // Draw one channel into a region: grid + trace + optional scale labels. - let mut draw_one = |samples: &[u8], trace_color: [u8; 3], + // ── Draw one subimage region ───────────────────────────────────── + let draw_one = |pixels: &mut [u8], samples: &[u8], trace_color: [u8; 3], y_off: u32, vpd: f64, vo: f64, ch_col: Option<[u8; 3]>| { - draw_grid(&mut pixels, scale_margin_left, y_off, graph_w, graph_h); + draw_grid_on(pixels, y_off); if !samples.is_empty() { - draw_ch(&mut pixels, samples, trace_color, scale_margin_left, y_off, graph_w, graph_h); + if dot_mode { + draw_dots(pixels, width, height, samples, trace_color, + scale_margin_left, y_off, graph_w, graph_h, dot_radius, antialias); + } else { + draw_trace(pixels, width, height, samples, trace_color, + scale_margin_left, y_off, graph_w, graph_h, trace_w, smooth, antialias); + } } if let Some(c) = ch_col { draw_scales_on_region( - &mut pixels, scale_margin_left, y_off, graph_w, graph_h, samples.len(), + pixels, scale_margin_left, y_off, graph_w, graph_h, samples.len(), vpd, vo, Some(c), 0.0, 0.0, None, ); } }; if do_split { - // Upper subimage: CH1 let ch1_col = settings.show_scales.then_some(PNG_CH1); - draw_one(&cap.ch1, PNG_CH1, 0, settings.v_per_div_ch1, settings.v_offset_ch1, ch1_col); + draw_one(&mut pixels, &cap.ch1, PNG_CH1, 0, + settings.v_per_div_ch1, settings.v_offset_ch1, ch1_col); - // Lower subimage: CH2 let ch2_col = settings.show_scales.then_some(PNG_CH2); if let Some(ch2) = &cap.ch2 { - draw_one(ch2, PNG_CH2, graph_h + scale_margin_bottom, + draw_one(&mut pixels, ch2, PNG_CH2, graph_h + scale_margin_bottom, settings.v_per_div_ch2, settings.v_offset_ch2, ch2_col); } } else { - // Single combined image - draw_grid(&mut pixels, scale_margin_left, 0, graph_w, graph_h); + draw_grid_on(&mut pixels, 0); if settings.show_ch1 { - draw_ch(&mut pixels, &cap.ch1, PNG_CH1, scale_margin_left, 0, graph_w, graph_h); + if dot_mode { + draw_dots(&mut pixels, width, height, &cap.ch1, PNG_CH1, + scale_margin_left, 0, graph_w, graph_h, dot_radius, antialias); + } else { + draw_trace(&mut pixels, width, height, &cap.ch1, PNG_CH1, + scale_margin_left, 0, graph_w, graph_h, trace_w, smooth, antialias); + } } if settings.show_ch2 { if let Some(ch2) = &cap.ch2 { - draw_ch(&mut pixels, ch2, PNG_CH2, scale_margin_left, 0, graph_w, graph_h); + if dot_mode { + draw_dots(&mut pixels, width, height, ch2, PNG_CH2, + scale_margin_left, 0, graph_w, graph_h, dot_radius, antialias); + } else { + draw_trace(&mut pixels, width, height, ch2, PNG_CH2, + scale_margin_left, 0, graph_w, graph_h, trace_w, smooth, antialias); + } } } if settings.show_scales { @@ -2746,20 +2711,210 @@ fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> any Ok(()) } -/// Threshold below which the tiny (3×5) bitmap font is used instead of a system font -/// (only when `force_system_font` is false). -const TINY_FONT_MAX_HEIGHT: u32 = 400; +// ── tiny-skia helper functions ───────────────────────────────────────────── + +fn rgb_color(rgb: [u8; 3]) -> tiny_skia::Color { + tiny_skia::Color::from_rgba8(rgb[0], rgb[1], rgb[2], 255) +} + +/// Resample waveform samples into a sequence of (x, y) points for the output +/// graph region. Upscaling uses linear interpolation; downscaling preserves +/// the min/max envelope per output column. +fn resample_waveform( + samples: &[u8], + output_width: u32, + output_height: u32, +) -> Vec<(f32, f32)> { + let n = samples.len(); + if n == 0 || output_width == 0 || output_height == 0 { + return Vec::new(); + } + + let y_scale = (output_height - 1) as f64 / GRID_HEIGHT_PX; + + let map_y = |sample: f64| -> f32 { + let y = ((sample - PX_PER_DIV) * y_scale) + .clamp(0.0, (output_height - 1) as f64); + y as f32 + }; + + // Upscaling: linear interpolation between source samples. + if output_width as usize >= n { + let mut points = Vec::with_capacity(output_width as usize); + for x in 0..output_width { + let source_pos = x as f64 * (n - 1) as f64 / (output_width - 1).max(1) as f64; + let left = source_pos.floor() as usize; + let right = (left + 1).min(n - 1); + let t = source_pos - left as f64; + let sample = samples[left] as f64 * (1.0 - t) + samples[right] as f64 * t; + points.push((x as f32, map_y(sample))); + } + return points; + } + + // Downscaling: min/max envelope per column. + let mut points = Vec::with_capacity(output_width as usize * 2); + for x in 0..output_width { + let start = x as usize * n / output_width as usize; + let end = ((x + 1) as usize * n / output_width as usize) + .max(start + 1) + .min(n); + let col = &samples[start..end]; + let min = *col.iter().min().unwrap() as f64; + let max = *col.iter().max().unwrap() as f64; + let y_min = map_y(min); + let y_max = map_y(max); + // Preserve temporal direction. + if col.first() <= col.last() { + points.push((x as f32, y_min)); + points.push((x as f32, y_max)); + } else { + points.push((x as f32, y_max)); + points.push((x as f32, y_min)); + } + } + points +} + +/// Build an anti-aliased grid path via tiny-skia. +fn build_grid_path( + x_off: u32, + y_off: u32, + width: u32, + height: u32, +) -> Option { + let mut path = tiny_skia::PathBuilder::new(); + for col in 1..T_CELLS as u32 { + let x = x_off as f32 + col as f32 * width as f32 / T_CELLS as f32; + path.move_to(x, y_off as f32); + path.line_to(x, (y_off + height) as f32); + } + for row in 1..V_CELLS as u32 { + let y = y_off as f32 + row as f32 * height as f32 / V_CELLS as f32; + path.move_to(x_off as f32, y); + path.line_to((x_off + width) as f32, y); + } + path.finish() +} + +/// Append a smooth Catmull–Rom curve segment (converted to cubic Bézier) +/// for each consecutive pair of points. +fn append_catmull_rom_path( + path: &mut tiny_skia::PathBuilder, + points: &[(f32, f32)], + x_off: u32, + y_off: u32, +) { + let ox = x_off as f32; + let oy = y_off as f32; + for i in 0..points.len() - 1 { + let p0 = if i == 0 { points[i] } else { points[i - 1] }; + let p1 = points[i]; + let p2 = points[i + 1]; + let p3 = if i + 2 < points.len() { points[i + 2] } else { p2 }; + // Catmull-Rom → cubic Bézier with tension 0.5. + let c1 = (p1.0 + (p2.0 - p0.0) / 6.0, p1.1 + (p2.1 - p0.1) / 6.0); + let c2 = (p2.0 - (p3.0 - p1.0) / 6.0, p2.1 - (p3.1 - p1.1) / 6.0); + path.cubic_to(ox + c1.0, oy + c1.1, ox + c2.0, oy + c2.1, ox + p2.0, oy + p2.1); + } +} + +/// Draw an anti-aliased trace via tiny-skia. +#[allow(clippy::too_many_arguments)] +fn draw_trace( + pixels: &mut [u8], + image_width: u32, + image_height: u32, + samples: &[u8], + color: [u8; 3], + x_off: u32, + y_off: u32, + width: u32, + height: u32, + line_width: f32, + smooth: bool, + antialias: bool, +) { + if samples.len() < 2 || width < 2 || height < 2 { + return; + } + let Some(mut pixmap) = tiny_skia::PixmapMut::from_bytes(pixels, image_width, image_height) else { + return; + }; + let points = resample_waveform(samples, width, height); + if points.len() < 2 { + return; + } + let mut path_builder = tiny_skia::PathBuilder::new(); + let first = points[0]; + path_builder.move_to(x_off as f32 + first.0, y_off as f32 + first.1); + if smooth && points.len() >= 4 { + append_catmull_rom_path(&mut path_builder, &points, x_off, y_off); + } else { + for &(x, y) in &points[1..] { + path_builder.line_to(x_off as f32 + x, y_off as f32 + y); + } + } + let Some(path) = path_builder.finish() else { return; }; + let mut paint = tiny_skia::Paint::default(); + paint.set_color(rgb_color(color)); + paint.anti_alias = antialias; + let stroke = tiny_skia::Stroke { + width: line_width, + line_cap: tiny_skia::LineCap::Round, + line_join: tiny_skia::LineJoin::Round, + ..tiny_skia::Stroke::default() + }; + pixmap.stroke_path(&path, &paint, &stroke, tiny_skia::Transform::identity(), None); +} + +/// Draw dots via tiny-skia (Dot mode). Each dot is a small filled circle. +#[allow(clippy::too_many_arguments)] +fn draw_dots( + pixels: &mut [u8], + image_width: u32, + image_height: u32, + samples: &[u8], + color: [u8; 3], + x_off: u32, + y_off: u32, + width: u32, + height: u32, + dot_radius: f32, + antialias: bool, +) { + let n = samples.len(); + if n == 0 || width < 2 || height < 2 { + return; + } + let Some(mut pixmap) = tiny_skia::PixmapMut::from_bytes(pixels, image_width, image_height) else { + return; + }; + let y_scale = (height - 1) as f64 / GRID_HEIGHT_PX; + let step = n.div_ceil(width.max(1) as usize).max(1); + let mut paint = tiny_skia::Paint::default(); + paint.set_color(rgb_color(color)); + paint.anti_alias = antialias; + for i in (0..n).step_by(step) { + let x = x_off as f32 + (i as f64 / (n - 1).max(1) as f64 * width as f64) as f32; + let sample = samples[i] as f64; + let y = y_off as f32 + ((sample - PX_PER_DIV) * y_scale) + .clamp(0.0, (height - 1) as f64) as f32; + let Some(circle) = tiny_skia::PathBuilder::from_circle(x, y, dot_radius) else { continue; }; + pixmap.fill_path(&circle, &paint, tiny_skia::FillRule::Winding, + tiny_skia::Transform::identity(), None); + } +} -/// Draw text onto a raw RGB pixel buffer. +/// Draw text onto a raw RGBA pixel buffer. /// -/// Uses the built-in 3×5 bitmap font when `force_system_font` is false and -/// `img_height ≤ TINY_FONT_MAX_HEIGHT`, otherwise renders with the system -/// monospace font via `ab_glyph` at the given `font_size`. +/// Uses the built-in 3×5 bitmap font when `force_system_font` is false, +/// otherwise renders with the system monospace font via `ab_glyph` at the +/// given `font_size`. #[allow(clippy::too_many_arguments)] fn draw_text_tiny( pixels: &mut [u8], img_width: u32, - img_height: u32, text: &str, x: u32, y: u32, @@ -2767,14 +2922,15 @@ fn draw_text_tiny( force_system_font: bool, font_size: f32, ) { - if force_system_font || img_height > TINY_FONT_MAX_HEIGHT { + if force_system_font { draw_system_font(pixels, img_width, text, x, y, color, font_size); } else { draw_tiny_glyph(pixels, img_width, text, x, y, color); } } -/// Render text with the built-in 3×5 bitmap font. +/// Render text with the built-in 3×5 bitmap font. Each font pixel maps to +/// exactly one output pixel — no scaling, no anti-aliasing. fn draw_tiny_glyph(pixels: &mut [u8], img_width: u32, text: &str, x: u32, y: u32, color: [u8; 3]) { let mut cx = x; for ch in text.chars() { @@ -2784,9 +2940,10 @@ fn draw_tiny_glyph(pixels: &mut [u8], img_width: u32, text: &str, x: u32, y: u32 if glyph[row as usize] & (1 << (2 - col)) != 0 { let px = cx + col; let py = y + row; - let idx = ((py * img_width + px) * 3) as usize; - if idx + 2 < pixels.len() { + let idx = ((py * img_width + px) * 4) as usize; + if idx + 3 < pixels.len() { pixels[idx..idx + 3].copy_from_slice(&color); + pixels[idx + 3] = 255; } } } @@ -2878,9 +3035,9 @@ fn draw_system_font( } let index = - (pixel_y as usize * img_width as usize + pixel_x as usize) * 3; + (pixel_y as usize * img_width as usize + pixel_x as usize) * 4; - let Some(destination) = pixels.get_mut(index..index + 3) else { + let Some(destination) = pixels.get_mut(index..index + 4) else { return; }; @@ -2892,7 +3049,7 @@ fn draw_system_font( } } -/// Alpha-blend one RGB color over another. +/// Alpha-blend one RGB color over an RGBA destination pixel. fn blend_rgb(destination: &mut [u8], foreground: [u8; 3], alpha: f32) { let alpha = alpha.clamp(0.0, 1.0); let inverse_alpha = 1.0 - alpha; @@ -2905,6 +3062,7 @@ fn blend_rgb(destination: &mut [u8], foreground: [u8; 3], alpha: f32) { .round() .clamp(0.0, 255.0) as u8; } + destination[3] = 255; } /// Returns a 5-row bitmask (3 bits wide) for a character. @@ -2932,21 +3090,21 @@ fn tiny_glyph(ch: char) -> [u8; 5] { } } -/// Write an RGB pixel buffer as a PNG file using the `png` crate with +/// Write an RGBA pixel buffer as a PNG file using the `png` crate with /// maximum compression (no quality loss). -fn write_png(path: &std::path::Path, width: u32, height: u32, rgb: &[u8]) -> anyhow::Result<()> { +fn write_png(path: &std::path::Path, width: u32, height: u32, rgba: &[u8]) -> anyhow::Result<()> { use std::io::BufWriter; let file = std::fs::File::create(path)?; let w = BufWriter::new(file); let mut encoder = png::Encoder::new(w, width, height); - encoder.set_color(png::ColorType::Rgb); + encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); encoder.set_compression(png::Compression::Best); let mut writer = encoder.write_header()?; - writer.write_image_data(rgb)?; + writer.write_image_data(rgba)?; Ok(()) } From c476e19873f844943892c2c75c9121af301d67f7 Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Wed, 22 Jul 2026 10:49:15 +0300 Subject: [PATCH 22/23] GUI: refactored code for PNG generation. Now everything related to PNG is located in a separate file. --- gui/src/app.rs | 633 +------------------------------------- gui/src/canvas.rs | 16 +- gui/src/export_image.rs | 663 ++++++++++++++++++++++++++++++++++++++++ gui/src/main.rs | 1 + 4 files changed, 680 insertions(+), 633 deletions(-) create mode 100644 gui/src/export_image.rs diff --git a/gui/src/app.rs b/gui/src/app.rs index 03285bd..80e1883 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -18,7 +18,8 @@ use iced::{ Element, Length, Subscription, Task, Theme, }; -use crate::canvas::{Scope, T_CELLS, Thumbnail, V_CELLS}; +use crate::canvas::{Scope, Thumbnail}; +use crate::export_image::export_png; use crate::i18n::t; use crate::range_slider; use crate::serial::{self, SerialConfig, SerialEvent, SerialHandle}; @@ -2149,17 +2150,6 @@ pub fn format_duration_ms(ms: f64) -> String { } } -/// Compact variant for scale labels: ms with 0.1 precision, µs/ns with .0. -pub fn format_duration_ms_scale(ms: f64) -> String { - if ms >= 1.0 { - format!("{ms:.1}ms") - } else if ms >= 0.001 { - format!("{:.0}µs", ms * 1000.0) - } else { - format!("{:.0}ns", ms * 1_000_000.0) - } -} - /// Format a frequency in Hz into a human-readable string with automatic unit /// switching (Hz / kHz / MHz). fn format_frequency_hz(hz: f64) -> String { @@ -2489,625 +2479,6 @@ fn export_csv(path: &std::path::Path, cap: &Capture) -> anyhow::Result<()> { Ok(()) } -// ── PNG export colour palette (mirrors canvas.rs constants) ──────────────── -const PNG_BG: [u8; 3] = [10, 15, 26]; -const PNG_GRID: [u8; 3] = [51, 61, 77]; -const PNG_CH1: [u8; 3] = [255, 217, 25]; -const PNG_CH2: [u8; 3] = [242, 25, 242]; -const PNG_SCALE_TEXT: [u8; 3] = [153, 166, 179]; - -/// Configuration for PNG export resolution and rendering quality. -pub struct PngExportConfig { - /// Target graph area width in pixels. 0 = default small (600 px). - pub target_width: u32, -} - -impl PngExportConfig { - fn scale(&self) -> f64 { - const BASE: u32 = T_CELLS as u32 * 50; - if self.target_width > 0 { - self.target_width as f64 / BASE as f64 - } else { - 1.0 - } - } - - fn graph_w(&self) -> u32 { - const BASE: u32 = T_CELLS as u32 * 50; - (BASE as f64 * self.scale()).round() as u32 - } - - fn graph_h(&self) -> u32 { - const BASE: u32 = V_CELLS as u32 * 50; - (BASE as f64 * self.scale()).round() as u32 - } - - fn scale_margin_left(&self, show: bool) -> u32 { - if show { - ((65.0 * self.scale()).round() as u32).max(52) - } else { - 0 - } - } - - fn scale_margin_bottom(&self, show: bool) -> u32 { - if show { (16.0 * self.scale()).round() as u32 } else { 0 } - } - - fn grid_width(&self) -> f32 { - (0.75 * self.scale().sqrt() as f32).clamp(0.75, 2.0) - } - - /// Full line width for the trace in pixels. - fn trace_width(&self) -> f32 { - (1.25 * self.scale().sqrt() as f32).clamp(1.0, 4.0) - } - - fn use_system_font(&self) -> bool { - self.target_width > 0 - } - - fn font_size(&self) -> f32 { - (11.0 * self.scale() as f32).max(11.0) - } -} - -/// Export the current capture as a PNG image. -fn export_png(path: &std::path::Path, cap: &Capture, settings: &Settings) -> anyhow::Result<()> { - let config = PngExportConfig { target_width: settings.png_export_width }; - - let graph_w = config.graph_w(); - let graph_h = config.graph_h(); - let scale_margin_left = config.scale_margin_left(settings.show_scales); - let scale_margin_bottom = config.scale_margin_bottom(settings.show_scales); - - let has_ch2 = cap.ch2.is_some(); - let do_split = settings.split_png && settings.show_ch1 && settings.show_ch2 && has_ch2; - - let width = graph_w + scale_margin_left; - let height = if do_split { - (graph_h + scale_margin_bottom) * 2 - } else { - graph_h + scale_margin_bottom - }; - - // RGBA pixel buffer for tiny-skia. - let mut pixels = vec![0u8; (width * height * 4) as usize]; - for chunk in pixels.chunks_exact_mut(4) { - chunk.copy_from_slice(&[PNG_BG[0], PNG_BG[1], PNG_BG[2], 255]); - } - - // Disable anti-aliasing at very low resolution to keep a crisp look. - let antialias = graph_w >= 400; - // Tie system-font choice to antialias: use the aliased bitmap glyph when - // anti-aliasing is off, so text stays crisp and fits the narrow margins. - let use_system_font = config.use_system_font() && antialias; - let font_size = config.font_size(); - let trace_w = config.trace_width(); - // When anti-aliasing is off, sub-pixel strokes may not render; ensure a - // minimum visible width. - let grid_w = if antialias { config.grid_width() } else { config.grid_width().max(1.0) }; - let smooth = settings.view_mode == ViewMode::Smooth; - let dot_mode = settings.view_mode == ViewMode::Dot; - let dot_radius = (trace_w * 0.6).max(1.0); - - // ── Grid helper via tiny-skia ──────────────────────────────────── - let draw_grid_on = |pixels: &mut [u8], y_off: u32| { - let Some(grid_path) = build_grid_path(scale_margin_left, y_off, graph_w, graph_h) else { - return; - }; - let Some(mut pixmap) = tiny_skia::PixmapMut::from_bytes(pixels, width, height) else { - return; - }; - let mut paint = tiny_skia::Paint::default(); - paint.set_color(rgb_color(PNG_GRID)); - paint.anti_alias = antialias; - let stroke = tiny_skia::Stroke { - width: grid_w, - ..tiny_skia::Stroke::default() - }; - pixmap.stroke_path(&grid_path, &paint, &stroke, tiny_skia::Transform::identity(), None); - }; - - // ── Scale labels (unchanged, uses existing text renderer) ───────── - let t_per_div_ms = settings.t_per_div_ms; - let draw_scales_on_region = - |pixels: &mut [u8], x_off: u32, y_off: u32, w: u32, h: u32, _n_samples: usize, - vpd_ch1: f64, vo_ch1: f64, ch1_color: Option<[u8; 3]>, - vpd_ch2: f64, vo_ch2: f64, ch2_color: Option<[u8; 3]>| { - let cols = T_CELLS as u32; - let total_time_ms = T_CELLS as f64 * t_per_div_ms; - for i in 0..cols { - let frac = i as f64 / cols as f64; - let time_ms = frac * total_time_ms; - let label = format_duration_ms_scale(time_ms); - let x_pos = x_off + i * w / cols + 2; - let y_pos = y_off + h + 2; - draw_text_tiny(pixels, width, &label, x_pos, y_pos, PNG_SCALE_TEXT, use_system_font, font_size); - } - - // Y axis: voltage labels on left margin, per-channel. - let rows = V_CELLS as u32; - let mut draw_voltage_label = |vpd: f64, vo: f64, color: [u8; 3], x_lbl: u32| { - for i in 0..=rows { - let v = ((i + 1) as f64 / rows as f64) * GRID_HEIGHT_PX; - let voltage = vo + (ADC_VALUE_MID as f64 - v) * vpd / PX_PER_DIV; - let y_pos = y_off + (i * h / rows); - draw_text_tiny(pixels, width, &format!("{voltage:.1}"), x_lbl, y_pos, color, use_system_font, font_size); - } - }; - - let x_ch1 = 2u32; - let x_ch2 = if ch1_color.is_some() { (scale_margin_left as f64 * 0.4).round() as u32 } else { 2u32 }; - if let Some(c) = ch1_color { draw_voltage_label(vpd_ch1, vo_ch1, c, x_ch1); } - if let Some(c) = ch2_color { draw_voltage_label(vpd_ch2, vo_ch2, c, x_ch2); } - }; - - // ── Draw one subimage region ───────────────────────────────────── - let draw_one = |pixels: &mut [u8], samples: &[u8], trace_color: [u8; 3], - y_off: u32, vpd: f64, vo: f64, ch_col: Option<[u8; 3]>| { - draw_grid_on(pixels, y_off); - if !samples.is_empty() { - if dot_mode { - draw_dots(pixels, width, height, samples, trace_color, - scale_margin_left, y_off, graph_w, graph_h, dot_radius, antialias); - } else { - draw_trace(pixels, width, height, samples, trace_color, - scale_margin_left, y_off, graph_w, graph_h, trace_w, smooth, antialias); - } - } - if let Some(c) = ch_col { - draw_scales_on_region( - pixels, scale_margin_left, y_off, graph_w, graph_h, samples.len(), - vpd, vo, Some(c), 0.0, 0.0, None, - ); - } - }; - - if do_split { - let ch1_col = settings.show_scales.then_some(PNG_CH1); - draw_one(&mut pixels, &cap.ch1, PNG_CH1, 0, - settings.v_per_div_ch1, settings.v_offset_ch1, ch1_col); - - let ch2_col = settings.show_scales.then_some(PNG_CH2); - if let Some(ch2) = &cap.ch2 { - draw_one(&mut pixels, ch2, PNG_CH2, graph_h + scale_margin_bottom, - settings.v_per_div_ch2, settings.v_offset_ch2, ch2_col); - } - } else { - draw_grid_on(&mut pixels, 0); - if settings.show_ch1 { - if dot_mode { - draw_dots(&mut pixels, width, height, &cap.ch1, PNG_CH1, - scale_margin_left, 0, graph_w, graph_h, dot_radius, antialias); - } else { - draw_trace(&mut pixels, width, height, &cap.ch1, PNG_CH1, - scale_margin_left, 0, graph_w, graph_h, trace_w, smooth, antialias); - } - } - if settings.show_ch2 { - if let Some(ch2) = &cap.ch2 { - if dot_mode { - draw_dots(&mut pixels, width, height, ch2, PNG_CH2, - scale_margin_left, 0, graph_w, graph_h, dot_radius, antialias); - } else { - draw_trace(&mut pixels, width, height, ch2, PNG_CH2, - scale_margin_left, 0, graph_w, graph_h, trace_w, smooth, antialias); - } - } - } - if settings.show_scales { - let ch1_col = if settings.show_ch1 { Some(PNG_CH1) } else { None }; - let ch2_col = if settings.show_ch2 && has_ch2 { Some(PNG_CH2) } else { None }; - draw_scales_on_region( - &mut pixels, scale_margin_left, 0, graph_w, graph_h, cap.ch1.len(), - settings.v_per_div_ch1, settings.v_offset_ch1, ch1_col, - settings.v_per_div_ch2, settings.v_offset_ch2, ch2_col, - ); - } - } - - write_png(path, width, height, &pixels)?; - Ok(()) -} - -// ── tiny-skia helper functions ───────────────────────────────────────────── - -fn rgb_color(rgb: [u8; 3]) -> tiny_skia::Color { - tiny_skia::Color::from_rgba8(rgb[0], rgb[1], rgb[2], 255) -} - -/// Resample waveform samples into a sequence of (x, y) points for the output -/// graph region. Upscaling uses linear interpolation; downscaling preserves -/// the min/max envelope per output column. -fn resample_waveform( - samples: &[u8], - output_width: u32, - output_height: u32, -) -> Vec<(f32, f32)> { - let n = samples.len(); - if n == 0 || output_width == 0 || output_height == 0 { - return Vec::new(); - } - - let y_scale = (output_height - 1) as f64 / GRID_HEIGHT_PX; - - let map_y = |sample: f64| -> f32 { - let y = ((sample - PX_PER_DIV) * y_scale) - .clamp(0.0, (output_height - 1) as f64); - y as f32 - }; - - // Upscaling: linear interpolation between source samples. - if output_width as usize >= n { - let mut points = Vec::with_capacity(output_width as usize); - for x in 0..output_width { - let source_pos = x as f64 * (n - 1) as f64 / (output_width - 1).max(1) as f64; - let left = source_pos.floor() as usize; - let right = (left + 1).min(n - 1); - let t = source_pos - left as f64; - let sample = samples[left] as f64 * (1.0 - t) + samples[right] as f64 * t; - points.push((x as f32, map_y(sample))); - } - return points; - } - - // Downscaling: min/max envelope per column. - let mut points = Vec::with_capacity(output_width as usize * 2); - for x in 0..output_width { - let start = x as usize * n / output_width as usize; - let end = ((x + 1) as usize * n / output_width as usize) - .max(start + 1) - .min(n); - let col = &samples[start..end]; - let min = *col.iter().min().unwrap() as f64; - let max = *col.iter().max().unwrap() as f64; - let y_min = map_y(min); - let y_max = map_y(max); - // Preserve temporal direction. - if col.first() <= col.last() { - points.push((x as f32, y_min)); - points.push((x as f32, y_max)); - } else { - points.push((x as f32, y_max)); - points.push((x as f32, y_min)); - } - } - points -} - -/// Build an anti-aliased grid path via tiny-skia. -fn build_grid_path( - x_off: u32, - y_off: u32, - width: u32, - height: u32, -) -> Option { - let mut path = tiny_skia::PathBuilder::new(); - for col in 1..T_CELLS as u32 { - let x = x_off as f32 + col as f32 * width as f32 / T_CELLS as f32; - path.move_to(x, y_off as f32); - path.line_to(x, (y_off + height) as f32); - } - for row in 1..V_CELLS as u32 { - let y = y_off as f32 + row as f32 * height as f32 / V_CELLS as f32; - path.move_to(x_off as f32, y); - path.line_to((x_off + width) as f32, y); - } - path.finish() -} - -/// Append a smooth Catmull–Rom curve segment (converted to cubic Bézier) -/// for each consecutive pair of points. -fn append_catmull_rom_path( - path: &mut tiny_skia::PathBuilder, - points: &[(f32, f32)], - x_off: u32, - y_off: u32, -) { - let ox = x_off as f32; - let oy = y_off as f32; - for i in 0..points.len() - 1 { - let p0 = if i == 0 { points[i] } else { points[i - 1] }; - let p1 = points[i]; - let p2 = points[i + 1]; - let p3 = if i + 2 < points.len() { points[i + 2] } else { p2 }; - // Catmull-Rom → cubic Bézier with tension 0.5. - let c1 = (p1.0 + (p2.0 - p0.0) / 6.0, p1.1 + (p2.1 - p0.1) / 6.0); - let c2 = (p2.0 - (p3.0 - p1.0) / 6.0, p2.1 - (p3.1 - p1.1) / 6.0); - path.cubic_to(ox + c1.0, oy + c1.1, ox + c2.0, oy + c2.1, ox + p2.0, oy + p2.1); - } -} - -/// Draw an anti-aliased trace via tiny-skia. -#[allow(clippy::too_many_arguments)] -fn draw_trace( - pixels: &mut [u8], - image_width: u32, - image_height: u32, - samples: &[u8], - color: [u8; 3], - x_off: u32, - y_off: u32, - width: u32, - height: u32, - line_width: f32, - smooth: bool, - antialias: bool, -) { - if samples.len() < 2 || width < 2 || height < 2 { - return; - } - let Some(mut pixmap) = tiny_skia::PixmapMut::from_bytes(pixels, image_width, image_height) else { - return; - }; - let points = resample_waveform(samples, width, height); - if points.len() < 2 { - return; - } - let mut path_builder = tiny_skia::PathBuilder::new(); - let first = points[0]; - path_builder.move_to(x_off as f32 + first.0, y_off as f32 + first.1); - if smooth && points.len() >= 4 { - append_catmull_rom_path(&mut path_builder, &points, x_off, y_off); - } else { - for &(x, y) in &points[1..] { - path_builder.line_to(x_off as f32 + x, y_off as f32 + y); - } - } - let Some(path) = path_builder.finish() else { return; }; - let mut paint = tiny_skia::Paint::default(); - paint.set_color(rgb_color(color)); - paint.anti_alias = antialias; - let stroke = tiny_skia::Stroke { - width: line_width, - line_cap: tiny_skia::LineCap::Round, - line_join: tiny_skia::LineJoin::Round, - ..tiny_skia::Stroke::default() - }; - pixmap.stroke_path(&path, &paint, &stroke, tiny_skia::Transform::identity(), None); -} - -/// Draw dots via tiny-skia (Dot mode). Each dot is a small filled circle. -#[allow(clippy::too_many_arguments)] -fn draw_dots( - pixels: &mut [u8], - image_width: u32, - image_height: u32, - samples: &[u8], - color: [u8; 3], - x_off: u32, - y_off: u32, - width: u32, - height: u32, - dot_radius: f32, - antialias: bool, -) { - let n = samples.len(); - if n == 0 || width < 2 || height < 2 { - return; - } - let Some(mut pixmap) = tiny_skia::PixmapMut::from_bytes(pixels, image_width, image_height) else { - return; - }; - let y_scale = (height - 1) as f64 / GRID_HEIGHT_PX; - let step = n.div_ceil(width.max(1) as usize).max(1); - let mut paint = tiny_skia::Paint::default(); - paint.set_color(rgb_color(color)); - paint.anti_alias = antialias; - for i in (0..n).step_by(step) { - let x = x_off as f32 + (i as f64 / (n - 1).max(1) as f64 * width as f64) as f32; - let sample = samples[i] as f64; - let y = y_off as f32 + ((sample - PX_PER_DIV) * y_scale) - .clamp(0.0, (height - 1) as f64) as f32; - let Some(circle) = tiny_skia::PathBuilder::from_circle(x, y, dot_radius) else { continue; }; - pixmap.fill_path(&circle, &paint, tiny_skia::FillRule::Winding, - tiny_skia::Transform::identity(), None); - } -} - -/// Draw text onto a raw RGBA pixel buffer. -/// -/// Uses the built-in 3×5 bitmap font when `force_system_font` is false, -/// otherwise renders with the system monospace font via `ab_glyph` at the -/// given `font_size`. -#[allow(clippy::too_many_arguments)] -fn draw_text_tiny( - pixels: &mut [u8], - img_width: u32, - text: &str, - x: u32, - y: u32, - color: [u8; 3], - force_system_font: bool, - font_size: f32, -) { - if force_system_font { - draw_system_font(pixels, img_width, text, x, y, color, font_size); - } else { - draw_tiny_glyph(pixels, img_width, text, x, y, color); - } -} - -/// Render text with the built-in 3×5 bitmap font. Each font pixel maps to -/// exactly one output pixel — no scaling, no anti-aliasing. -fn draw_tiny_glyph(pixels: &mut [u8], img_width: u32, text: &str, x: u32, y: u32, color: [u8; 3]) { - let mut cx = x; - for ch in text.chars() { - let glyph = tiny_glyph(ch); - for row in 0..5u32 { - for col in 0..3u32 { - if glyph[row as usize] & (1 << (2 - col)) != 0 { - let px = cx + col; - let py = y + row; - let idx = ((py * img_width + px) * 4) as usize; - if idx + 3 < pixels.len() { - pixels[idx..idx + 3].copy_from_slice(&color); - pixels[idx + 3] = 255; - } - } - } - } - cx += 4; - } -} - -/// Render text using a system monospace font via `ab_glyph`. -/// Render text into an RGB pixel buffer using the operating system's -/// default monospace font family. -#[allow(clippy::too_many_arguments)] -fn draw_system_font( - pixels: &mut [u8], - img_width: u32, - text: &str, - x: u32, - y: u32, - color: [u8; 3], - font_size: f32, -) { - use ab_glyph::{Font, FontArc, PxScale, ScaleFont}; - use font_kit::{ - family_name::FamilyName, - properties::Properties, - source::SystemSource, - }; - use std::sync::OnceLock; - - /// Resolve the default system monospace family and convert it to an - /// `ab_glyph` font. - fn load_system_monospace() -> Option { - let handle = SystemSource::new() - .select_best_match( - &[FamilyName::Monospace], - &Properties::new(), - ) - .ok()?; - - // Extract font data and index from the handle, then load via ab_glyph. - let (font_data, font_index) = match &handle { - font_kit::handle::Handle::Memory { bytes, font_index } => { - ((**bytes).clone(), *font_index) - } - font_kit::handle::Handle::Path { path, font_index } => { - (std::fs::read(path).ok()?, *font_index) - } - }; - - let font = ab_glyph::FontVec::try_from_vec_and_index(font_data, font_index).ok()?; - Some(FontArc::new(font)) - } - - static FONT: OnceLock> = OnceLock::new(); - - let Some(font) = FONT.get_or_init(load_system_monospace).as_ref() else { - return; - }; - - let scaled_font = font.as_scaled(PxScale::from(font_size)); - let baseline = y as f32 + scaled_font.ascent(); - - let mut cursor_x = x as f32; - - for ch in text.chars() { - let glyph_id = font.glyph_id(ch); - - let glyph = glyph_id.with_scale_and_position( - font_size, - ab_glyph::point(cursor_x, baseline), - ); - - if let Some(outlined) = font.outline_glyph(glyph) { - let bounds = outlined.px_bounds(); - - outlined.draw(|local_x, local_y, coverage| { - let pixel_x = bounds.min.x.floor() as i32 + local_x as i32; - let pixel_y = bounds.min.y.floor() as i32 + local_y as i32; - - if pixel_x < 0 || pixel_y < 0 { - return; - } - - let pixel_x = pixel_x as u32; - let pixel_y = pixel_y as u32; - - if pixel_x >= img_width { - return; - } - - let index = - (pixel_y as usize * img_width as usize + pixel_x as usize) * 4; - - let Some(destination) = pixels.get_mut(index..index + 4) else { - return; - }; - - blend_rgb(destination, color, coverage); - }); - } - - cursor_x += scaled_font.h_advance(glyph_id); - } -} - -/// Alpha-blend one RGB color over an RGBA destination pixel. -fn blend_rgb(destination: &mut [u8], foreground: [u8; 3], alpha: f32) { - let alpha = alpha.clamp(0.0, 1.0); - let inverse_alpha = 1.0 - alpha; - - for channel in 0..3 { - destination[channel] = ( - foreground[channel] as f32 * alpha - + destination[channel] as f32 * inverse_alpha - ) - .round() - .clamp(0.0, 255.0) as u8; - } - destination[3] = 255; -} - -/// Returns a 5-row bitmask (3 bits wide) for a character. -fn tiny_glyph(ch: char) -> [u8; 5] { - match ch { - '0' => [0b111, 0b101, 0b101, 0b101, 0b111], - '1' => [0b010, 0b110, 0b010, 0b010, 0b111], - '2' => [0b111, 0b001, 0b111, 0b100, 0b111], - '3' => [0b111, 0b001, 0b111, 0b001, 0b111], - '4' => [0b101, 0b101, 0b111, 0b001, 0b001], - '5' => [0b111, 0b100, 0b111, 0b001, 0b111], - '6' => [0b111, 0b100, 0b111, 0b101, 0b111], - '7' => [0b111, 0b001, 0b001, 0b001, 0b001], - '8' => [0b111, 0b101, 0b111, 0b101, 0b111], - '9' => [0b111, 0b101, 0b111, 0b001, 0b111], - '.' => [0b000, 0b000, 0b000, 0b000, 0b010], - '-' => [0b000, 0b000, 0b111, 0b000, 0b000], - 'V' => [0b101, 0b101, 0b101, 0b101, 0b010], - 'm' => [0b101, 0b111, 0b101, 0b101, 0b101], - 's' => [0b011, 0b100, 0b010, 0b001, 0b110], - 'n' => [0b000, 0b110, 0b101, 0b101, 0b101], - 'µ' => [0b000, 0b101, 0b101, 0b111, 0b100], - ' ' => [0b000, 0b000, 0b000, 0b000, 0b000], - _ => [0b000, 0b000, 0b000, 0b000, 0b000], - } -} - -/// Write an RGBA pixel buffer as a PNG file using the `png` crate with -/// maximum compression (no quality loss). -fn write_png(path: &std::path::Path, width: u32, height: u32, rgba: &[u8]) -> anyhow::Result<()> { - use std::io::BufWriter; - - let file = std::fs::File::create(path)?; - let w = BufWriter::new(file); - - let mut encoder = png::Encoder::new(w, width, height); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - encoder.set_compression(png::Compression::Best); - - let mut writer = encoder.write_header()?; - writer.write_image_data(rgba)?; - Ok(()) -} - // ── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/gui/src/canvas.rs b/gui/src/canvas.rs index a1c65d4..3b8369f 100644 --- a/gui/src/canvas.rs +++ b/gui/src/canvas.rs @@ -1,6 +1,4 @@ //! Oscilloscope-style graph canvas (iced::widget::canvas). - -use crate::app::{format_duration_ms_scale}; use crate::settings::ViewMode; use dso_parser::{ADC_VALUE_MID, Capture}; use iced::widget::canvas::{self, Frame, Geometry, Path, Stroke, Text}; @@ -578,3 +576,17 @@ impl canvas::Program for Thumbnail<'_> { } } } + +// ── Time formatting utilities ────────────────────────────────────────────── + +/// Compact time-duration formatting for scale labels: ms with 0.1 precision, +/// µs/ns with .0. +pub fn format_duration_ms_scale(ms: f64) -> String { + if ms >= 1.0 { + format!("{ms:.1}ms") + } else if ms >= 0.001 { + format!("{:.0}µs", ms * 1000.0) + } else { + format!("{:.0}ns", ms * 1_000_000.0) + } +} diff --git a/gui/src/export_image.rs b/gui/src/export_image.rs new file mode 100644 index 0000000..5f8e5f3 --- /dev/null +++ b/gui/src/export_image.rs @@ -0,0 +1,663 @@ +//! PNG image export for the oscilloscope capture viewer. +//! +//! Generates high-quality anti-aliased PNG images via `tiny-skia` for vector +//! paths (grid, traces) and renders text either with a built-in 3×5 bitmap +//! font or via system monospace fonts (`ab_glyph` + `font-kit`). + +use std::path::Path; + +use dso_parser::{ADC_VALUE_MID, Capture}; +use crate::canvas::format_duration_ms_scale; +use crate::canvas::{GRID_HEIGHT_PX, PX_PER_DIV, T_CELLS, V_CELLS}; +use crate::settings::{Settings, ViewMode}; + +// ── Colour palette (mirrors canvas.rs constants) ─────────────────────────── + +const PNG_BG: [u8; 3] = [10, 15, 26]; +const PNG_GRID: [u8; 3] = [51, 61, 77]; +const PNG_CH1: [u8; 3] = [255, 217, 25]; +const PNG_CH2: [u8; 3] = [242, 25, 242]; +const PNG_SCALE_TEXT: [u8; 3] = [153, 166, 179]; + +// ── Configuration ────────────────────────────────────────────────────────── + +/// Configuration for PNG export resolution and rendering quality. +pub struct PngExportConfig { + /// Target graph area width in pixels. 0 = default small (600 px). + pub target_width: u32, +} + +impl PngExportConfig { + fn scale(&self) -> f64 { + const BASE: u32 = T_CELLS as u32 * 50; + if self.target_width > 0 { + self.target_width as f64 / BASE as f64 + } else { + 1.0 + } + } + + fn graph_w(&self) -> u32 { + const BASE: u32 = T_CELLS as u32 * 50; + (BASE as f64 * self.scale()).round() as u32 + } + + fn graph_h(&self) -> u32 { + const BASE: u32 = V_CELLS as u32 * 50; + (BASE as f64 * self.scale()).round() as u32 + } + + fn scale_margin_left(&self, show: bool) -> u32 { + if show { + ((65.0 * self.scale()).round() as u32).max(52) + } else { + 0 + } + } + + fn scale_margin_bottom(&self, show: bool) -> u32 { + if show { (16.0 * self.scale()).round() as u32 } else { 0 } + } + + fn grid_width(&self) -> f32 { + (0.75 * self.scale().sqrt() as f32).clamp(0.75, 2.0) + } + + /// Full line width for the trace in pixels. + fn trace_width(&self) -> f32 { + (1.25 * self.scale().sqrt() as f32).clamp(1.0, 4.0) + } + + fn use_system_font(&self) -> bool { + self.target_width > 0 + } + + fn font_size(&self) -> f32 { + (11.0 * self.scale() as f32).max(11.0) + } +} + +// ── Main entry point ─────────────────────────────────────────────────────── + +/// Export the current capture as a PNG image. +pub fn export_png(path: &Path, cap: &Capture, settings: &Settings) -> anyhow::Result<()> { + let config = PngExportConfig { target_width: settings.png_export_width }; + + let graph_w = config.graph_w(); + let graph_h = config.graph_h(); + let scale_margin_left = config.scale_margin_left(settings.show_scales); + let scale_margin_bottom = config.scale_margin_bottom(settings.show_scales); + + let has_ch2 = cap.ch2.is_some(); + let do_split = settings.split_png && settings.show_ch1 && settings.show_ch2 && has_ch2; + + let width = graph_w + scale_margin_left; + let height = if do_split { + (graph_h + scale_margin_bottom) * 2 + } else { + graph_h + scale_margin_bottom + }; + + // RGBA pixel buffer for tiny-skia. + let mut pixels = vec![0u8; (width * height * 4) as usize]; + for chunk in pixels.chunks_exact_mut(4) { + chunk.copy_from_slice(&[PNG_BG[0], PNG_BG[1], PNG_BG[2], 255]); + } + + // Disable anti-aliasing at very low resolution to keep a crisp look. + let antialias = graph_w >= 400; + // Tie system-font choice to antialias: use the aliased bitmap glyph when + // anti-aliasing is off, so text stays crisp and fits the narrow margins. + let use_system_font = config.use_system_font() && antialias; + let font_size = config.font_size(); + let trace_w = config.trace_width(); + // When anti-aliasing is off, sub-pixel strokes may not render; ensure a + // minimum visible width. + let grid_w = if antialias { config.grid_width() } else { config.grid_width().max(1.0) }; + let smooth = settings.view_mode == ViewMode::Smooth; + let dot_mode = settings.view_mode == ViewMode::Dot; + let dot_radius = (trace_w * 0.6).max(1.0); + + // ── Grid helper via tiny-skia ──────────────────────────────────── + let draw_grid_on = |pixels: &mut [u8], y_off: u32| { + let Some(grid_path) = build_grid_path(scale_margin_left, y_off, graph_w, graph_h) else { + return; + }; + let Some(mut pixmap) = tiny_skia::PixmapMut::from_bytes(pixels, width, height) else { + return; + }; + let mut paint = tiny_skia::Paint::default(); + paint.set_color(rgb_color(PNG_GRID)); + paint.anti_alias = antialias; + let stroke = tiny_skia::Stroke { + width: grid_w, + ..tiny_skia::Stroke::default() + }; + pixmap.stroke_path(&grid_path, &paint, &stroke, tiny_skia::Transform::identity(), None); + }; + + // ── Scale labels ───────────────────────────────────────────────── + let t_per_div_ms = settings.t_per_div_ms; + let draw_scales_on_region = + |pixels: &mut [u8], x_off: u32, y_off: u32, w: u32, h: u32, _n_samples: usize, + vpd_ch1: f64, vo_ch1: f64, ch1_color: Option<[u8; 3]>, + vpd_ch2: f64, vo_ch2: f64, ch2_color: Option<[u8; 3]>| { + let cols = T_CELLS as u32; + let total_time_ms = T_CELLS as f64 * t_per_div_ms; + for i in 0..cols { + let frac = i as f64 / cols as f64; + let time_ms = frac * total_time_ms; + let label = format_duration_ms_scale(time_ms); + let x_pos = x_off + i * w / cols + 2; + let y_pos = y_off + h + 2; + draw_text_tiny(pixels, width, &label, x_pos, y_pos, PNG_SCALE_TEXT, use_system_font, font_size); + } + + // Y axis: voltage labels on left margin, per-channel. + let rows = V_CELLS as u32; + let mut draw_voltage_label = |vpd: f64, vo: f64, color: [u8; 3], x_lbl: u32| { + for i in 0..=rows { + let v = ((i + 1) as f64 / rows as f64) * GRID_HEIGHT_PX; + let voltage = vo + (ADC_VALUE_MID as f64 - v) * vpd / PX_PER_DIV; + let y_pos = y_off + (i * h / rows); + draw_text_tiny(pixels, width, &format!("{voltage:.1}"), x_lbl, y_pos, color, use_system_font, font_size); + } + }; + + let x_ch1 = 2u32; + let x_ch2 = if ch1_color.is_some() { (scale_margin_left as f64 * 0.4).round() as u32 } else { 2u32 }; + if let Some(c) = ch1_color { draw_voltage_label(vpd_ch1, vo_ch1, c, x_ch1); } + if let Some(c) = ch2_color { draw_voltage_label(vpd_ch2, vo_ch2, c, x_ch2); } + }; + + // ── Draw one subimage region ───────────────────────────────────── + let draw_one = |pixels: &mut [u8], samples: &[u8], trace_color: [u8; 3], + y_off: u32, vpd: f64, vo: f64, ch_col: Option<[u8; 3]>| { + draw_grid_on(pixels, y_off); + if !samples.is_empty() { + if dot_mode { + draw_dots(pixels, width, height, samples, trace_color, + scale_margin_left, y_off, graph_w, graph_h, dot_radius, antialias); + } else { + draw_trace(pixels, width, height, samples, trace_color, + scale_margin_left, y_off, graph_w, graph_h, trace_w, smooth, antialias); + } + } + if let Some(c) = ch_col { + draw_scales_on_region( + pixels, scale_margin_left, y_off, graph_w, graph_h, samples.len(), + vpd, vo, Some(c), 0.0, 0.0, None, + ); + } + }; + + if do_split { + let ch1_col = settings.show_scales.then_some(PNG_CH1); + draw_one(&mut pixels, &cap.ch1, PNG_CH1, 0, + settings.v_per_div_ch1, settings.v_offset_ch1, ch1_col); + + let ch2_col = settings.show_scales.then_some(PNG_CH2); + if let Some(ch2) = &cap.ch2 { + draw_one(&mut pixels, ch2, PNG_CH2, graph_h + scale_margin_bottom, + settings.v_per_div_ch2, settings.v_offset_ch2, ch2_col); + } + } else { + draw_grid_on(&mut pixels, 0); + if settings.show_ch1 { + if dot_mode { + draw_dots(&mut pixels, width, height, &cap.ch1, PNG_CH1, + scale_margin_left, 0, graph_w, graph_h, dot_radius, antialias); + } else { + draw_trace(&mut pixels, width, height, &cap.ch1, PNG_CH1, + scale_margin_left, 0, graph_w, graph_h, trace_w, smooth, antialias); + } + } + if settings.show_ch2 { + if let Some(ch2) = &cap.ch2 { + if dot_mode { + draw_dots(&mut pixels, width, height, ch2, PNG_CH2, + scale_margin_left, 0, graph_w, graph_h, dot_radius, antialias); + } else { + draw_trace(&mut pixels, width, height, ch2, PNG_CH2, + scale_margin_left, 0, graph_w, graph_h, trace_w, smooth, antialias); + } + } + } + if settings.show_scales { + let ch1_col = if settings.show_ch1 { Some(PNG_CH1) } else { None }; + let ch2_col = if settings.show_ch2 && has_ch2 { Some(PNG_CH2) } else { None }; + draw_scales_on_region( + &mut pixels, scale_margin_left, 0, graph_w, graph_h, cap.ch1.len(), + settings.v_per_div_ch1, settings.v_offset_ch1, ch1_col, + settings.v_per_div_ch2, settings.v_offset_ch2, ch2_col, + ); + } + } + + write_png(path, width, height, &pixels)?; + Ok(()) +} + +// ── tiny-skia helper functions ───────────────────────────────────────────── + +fn rgb_color(rgb: [u8; 3]) -> tiny_skia::Color { + tiny_skia::Color::from_rgba8(rgb[0], rgb[1], rgb[2], 255) +} + +/// Resample waveform samples into a sequence of (x, y) points for the output +/// graph region. Upscaling uses linear interpolation; downscaling preserves +/// the min/max envelope per output column. +fn resample_waveform( + samples: &[u8], + output_width: u32, + output_height: u32, +) -> Vec<(f32, f32)> { + let n = samples.len(); + if n == 0 || output_width == 0 || output_height == 0 { + return Vec::new(); + } + + let y_scale = (output_height - 1) as f64 / GRID_HEIGHT_PX; + + let map_y = |sample: f64| -> f32 { + let y = ((sample - PX_PER_DIV) * y_scale) + .clamp(0.0, (output_height - 1) as f64); + y as f32 + }; + + // Upscaling: linear interpolation between source samples. + if output_width as usize >= n { + let mut points = Vec::with_capacity(output_width as usize); + for x in 0..output_width { + let source_pos = x as f64 * (n - 1) as f64 / (output_width - 1).max(1) as f64; + let left = source_pos.floor() as usize; + let right = (left + 1).min(n - 1); + let t = source_pos - left as f64; + let sample = samples[left] as f64 * (1.0 - t) + samples[right] as f64 * t; + points.push((x as f32, map_y(sample))); + } + return points; + } + + // Downscaling: min/max envelope per column. + let mut points = Vec::with_capacity(output_width as usize * 2); + for x in 0..output_width { + let start = x as usize * n / output_width as usize; + let end = ((x + 1) as usize * n / output_width as usize) + .max(start + 1) + .min(n); + let col = &samples[start..end]; + let min = *col.iter().min().unwrap() as f64; + let max = *col.iter().max().unwrap() as f64; + let y_min = map_y(min); + let y_max = map_y(max); + // Preserve temporal direction. + if col.first() <= col.last() { + points.push((x as f32, y_min)); + points.push((x as f32, y_max)); + } else { + points.push((x as f32, y_max)); + points.push((x as f32, y_min)); + } + } + points +} + +/// Build an anti-aliased grid path via tiny-skia. +fn build_grid_path( + x_off: u32, + y_off: u32, + width: u32, + height: u32, +) -> Option { + let mut path = tiny_skia::PathBuilder::new(); + for col in 1..T_CELLS as u32 { + let x = x_off as f32 + col as f32 * width as f32 / T_CELLS as f32; + path.move_to(x, y_off as f32); + path.line_to(x, (y_off + height) as f32); + } + for row in 1..V_CELLS as u32 { + let y = y_off as f32 + row as f32 * height as f32 / V_CELLS as f32; + path.move_to(x_off as f32, y); + path.line_to((x_off + width) as f32, y); + } + path.finish() +} + +/// Append a smooth Catmull–Rom curve segment (converted to cubic Bézier) +/// for each consecutive pair of points. +fn append_catmull_rom_path( + path: &mut tiny_skia::PathBuilder, + points: &[(f32, f32)], + x_off: u32, + y_off: u32, +) { + let ox = x_off as f32; + let oy = y_off as f32; + for i in 0..points.len() - 1 { + let p0 = if i == 0 { points[i] } else { points[i - 1] }; + let p1 = points[i]; + let p2 = points[i + 1]; + let p3 = if i + 2 < points.len() { points[i + 2] } else { p2 }; + // Catmull-Rom → cubic Bézier with tension 0.5. + let c1 = (p1.0 + (p2.0 - p0.0) / 6.0, p1.1 + (p2.1 - p0.1) / 6.0); + let c2 = (p2.0 - (p3.0 - p1.0) / 6.0, p2.1 - (p3.1 - p1.1) / 6.0); + path.cubic_to(ox + c1.0, oy + c1.1, ox + c2.0, oy + c2.1, ox + p2.0, oy + p2.1); + } +} + +/// Draw an anti-aliased trace via tiny-skia. +#[allow(clippy::too_many_arguments)] +fn draw_trace( + pixels: &mut [u8], + image_width: u32, + image_height: u32, + samples: &[u8], + color: [u8; 3], + x_off: u32, + y_off: u32, + width: u32, + height: u32, + line_width: f32, + smooth: bool, + antialias: bool, +) { + if samples.len() < 2 || width < 2 || height < 2 { + return; + } + let Some(mut pixmap) = tiny_skia::PixmapMut::from_bytes(pixels, image_width, image_height) else { + return; + }; + let points = resample_waveform(samples, width, height); + if points.len() < 2 { + return; + } + let mut path_builder = tiny_skia::PathBuilder::new(); + let first = points[0]; + path_builder.move_to(x_off as f32 + first.0, y_off as f32 + first.1); + if smooth && points.len() >= 4 { + append_catmull_rom_path(&mut path_builder, &points, x_off, y_off); + } else { + for &(x, y) in &points[1..] { + path_builder.line_to(x_off as f32 + x, y_off as f32 + y); + } + } + let Some(path) = path_builder.finish() else { return; }; + let mut paint = tiny_skia::Paint::default(); + paint.set_color(rgb_color(color)); + paint.anti_alias = antialias; + let stroke = tiny_skia::Stroke { + width: line_width, + line_cap: tiny_skia::LineCap::Round, + line_join: tiny_skia::LineJoin::Round, + ..tiny_skia::Stroke::default() + }; + pixmap.stroke_path(&path, &paint, &stroke, tiny_skia::Transform::identity(), None); +} + +/// Draw dots via tiny-skia (Dot mode). Each dot is a small filled circle. +/// +/// When anti-aliasing is disabled, dots are drawn as individual pixels +/// directly into the RGBA buffer — no scaling, no smoothing, no sub-pixel +/// artefacts — to keep the dot series crisp at low resolution. +#[allow(clippy::too_many_arguments)] +fn draw_dots( + pixels: &mut [u8], + image_width: u32, + image_height: u32, + samples: &[u8], + color: [u8; 3], + x_off: u32, + y_off: u32, + width: u32, + height: u32, + dot_radius: f32, + antialias: bool, +) { + let n = samples.len(); + if n == 0 || width < 2 || height < 2 { + return; + } + let y_scale = (height - 1) as f64 / GRID_HEIGHT_PX; + let step = n.div_ceil(width.max(1) as usize).max(1); + + if !antialias { + // Aliased path: set individual pixels directly — no circles, no + // sub-pixel blending, no scaling. + for i in (0..n).step_by(step) { + let x = x_off + + (i as f64 / (n - 1).max(1) as f64 * width as f64).round() as u32; + let sample = samples[i] as f64; + let y = y_off + + ((sample - PX_PER_DIV) * y_scale) + .round() + .clamp(0.0, (height - 1) as f64) as u32; + let idx = (y as usize * image_width as usize + x as usize) * 4; + if idx + 3 < pixels.len() { + pixels[idx..idx + 3].copy_from_slice(&color); + pixels[idx + 3] = 255; + } + } + return; + } + + let Some(mut pixmap) = tiny_skia::PixmapMut::from_bytes(pixels, image_width, image_height) else { + return; + }; + let mut paint = tiny_skia::Paint::default(); + paint.set_color(rgb_color(color)); + paint.anti_alias = antialias; + for i in (0..n).step_by(step) { + let x = x_off as f32 + (i as f64 / (n - 1).max(1) as f64 * width as f64) as f32; + let sample = samples[i] as f64; + let y = y_off as f32 + ((sample - PX_PER_DIV) * y_scale) + .clamp(0.0, (height - 1) as f64) as f32; + let Some(circle) = tiny_skia::PathBuilder::from_circle(x, y, dot_radius) else { continue; }; + pixmap.fill_path(&circle, &paint, tiny_skia::FillRule::Winding, + tiny_skia::Transform::identity(), None); + } +} + +// ── Text rendering ───────────────────────────────────────────────────────── + +/// Draw text onto a raw RGBA pixel buffer. +/// +/// Uses the built-in 3×5 bitmap font when `force_system_font` is false, +/// otherwise renders with the system monospace font via `ab_glyph` at the +/// given `font_size`. +#[allow(clippy::too_many_arguments)] +fn draw_text_tiny( + pixels: &mut [u8], + img_width: u32, + text: &str, + x: u32, + y: u32, + color: [u8; 3], + force_system_font: bool, + font_size: f32, +) { + if force_system_font { + draw_system_font(pixels, img_width, text, x, y, color, font_size); + } else { + draw_tiny_glyph(pixels, img_width, text, x, y, color); + } +} + +/// Render text with the built-in 3×5 bitmap font. Each font pixel maps to +/// exactly one output pixel — no scaling, no anti-aliasing. +fn draw_tiny_glyph(pixels: &mut [u8], img_width: u32, text: &str, x: u32, y: u32, color: [u8; 3]) { + let mut cx = x; + for ch in text.chars() { + let glyph = tiny_glyph(ch); + for row in 0..5u32 { + for col in 0..3u32 { + if glyph[row as usize] & (1 << (2 - col)) != 0 { + let px = cx + col; + let py = y + row; + let idx = ((py * img_width + px) * 4) as usize; + if idx + 3 < pixels.len() { + pixels[idx..idx + 3].copy_from_slice(&color); + pixels[idx + 3] = 255; + } + } + } + } + cx += 4; + } +} + +/// Render text using a system monospace font via `ab_glyph`. +/// Render text into an RGB pixel buffer using the operating system's +/// default monospace font family. +#[allow(clippy::too_many_arguments)] +fn draw_system_font( + pixels: &mut [u8], + img_width: u32, + text: &str, + x: u32, + y: u32, + color: [u8; 3], + font_size: f32, +) { + use ab_glyph::{Font, FontArc, PxScale, ScaleFont}; + use font_kit::{ + family_name::FamilyName, + properties::Properties, + source::SystemSource, + }; + use std::sync::OnceLock; + + /// Resolve the default system monospace family and convert it to an + /// `ab_glyph` font. + fn load_system_monospace() -> Option { + let handle = SystemSource::new() + .select_best_match( + &[FamilyName::Monospace], + &Properties::new(), + ) + .ok()?; + + // Extract font data and index from the handle, then load via ab_glyph. + let (font_data, font_index) = match &handle { + font_kit::handle::Handle::Memory { bytes, font_index } => { + ((**bytes).clone(), *font_index) + } + font_kit::handle::Handle::Path { path, font_index } => { + (std::fs::read(path).ok()?, *font_index) + } + }; + + let font = ab_glyph::FontVec::try_from_vec_and_index(font_data, font_index).ok()?; + Some(FontArc::new(font)) + } + + static FONT: OnceLock> = OnceLock::new(); + + let Some(font) = FONT.get_or_init(load_system_monospace).as_ref() else { + return; + }; + + let scaled_font = font.as_scaled(PxScale::from(font_size)); + let baseline = y as f32 + scaled_font.ascent(); + + let mut cursor_x = x as f32; + + for ch in text.chars() { + let glyph_id = font.glyph_id(ch); + + let glyph = glyph_id.with_scale_and_position( + font_size, + ab_glyph::point(cursor_x, baseline), + ); + + if let Some(outlined) = font.outline_glyph(glyph) { + let bounds = outlined.px_bounds(); + + outlined.draw(|local_x, local_y, coverage| { + let pixel_x = bounds.min.x.floor() as i32 + local_x as i32; + let pixel_y = bounds.min.y.floor() as i32 + local_y as i32; + + if pixel_x < 0 || pixel_y < 0 { + return; + } + + let pixel_x = pixel_x as u32; + let pixel_y = pixel_y as u32; + + if pixel_x >= img_width { + return; + } + + let index = + (pixel_y as usize * img_width as usize + pixel_x as usize) * 4; + + let Some(destination) = pixels.get_mut(index..index + 4) else { + return; + }; + + blend_rgb(destination, color, coverage); + }); + } + + cursor_x += scaled_font.h_advance(glyph_id); + } +} + +/// Alpha-blend one RGB color over an RGBA destination pixel. +fn blend_rgb(destination: &mut [u8], foreground: [u8; 3], alpha: f32) { + let alpha = alpha.clamp(0.0, 1.0); + let inverse_alpha = 1.0 - alpha; + + for channel in 0..3 { + destination[channel] = ( + foreground[channel] as f32 * alpha + + destination[channel] as f32 * inverse_alpha + ) + .round() + .clamp(0.0, 255.0) as u8; + } + destination[3] = 255; +} + +/// Returns a 5-row bitmask (3 bits wide) for a character. +fn tiny_glyph(ch: char) -> [u8; 5] { + match ch { + '0' => [0b111, 0b101, 0b101, 0b101, 0b111], + '1' => [0b010, 0b110, 0b010, 0b010, 0b111], + '2' => [0b111, 0b001, 0b111, 0b100, 0b111], + '3' => [0b111, 0b001, 0b111, 0b001, 0b111], + '4' => [0b101, 0b101, 0b111, 0b001, 0b001], + '5' => [0b111, 0b100, 0b111, 0b001, 0b111], + '6' => [0b111, 0b100, 0b111, 0b101, 0b111], + '7' => [0b111, 0b001, 0b001, 0b001, 0b001], + '8' => [0b111, 0b101, 0b111, 0b101, 0b111], + '9' => [0b111, 0b101, 0b111, 0b001, 0b111], + '.' => [0b000, 0b000, 0b000, 0b000, 0b010], + '-' => [0b000, 0b000, 0b111, 0b000, 0b000], + 'V' => [0b101, 0b101, 0b101, 0b101, 0b010], + 'm' => [0b101, 0b111, 0b101, 0b101, 0b101], + 's' => [0b011, 0b100, 0b010, 0b001, 0b110], + 'n' => [0b000, 0b110, 0b101, 0b101, 0b101], + 'µ' => [0b000, 0b101, 0b101, 0b111, 0b100], + ' ' => [0b000, 0b000, 0b000, 0b000, 0b000], + _ => [0b000, 0b000, 0b000, 0b000, 0b000], + } +} + +/// Write an RGBA pixel buffer as a PNG file using the `png` crate with +/// maximum compression (no quality loss). +fn write_png(path: &Path, width: u32, height: u32, rgba: &[u8]) -> anyhow::Result<()> { + use std::io::BufWriter; + + let file = std::fs::File::create(path)?; + let w = BufWriter::new(file); + + let mut encoder = png::Encoder::new(w, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_compression(png::Compression::Best); + + let mut writer = encoder.write_header()?; + writer.write_image_data(rgba)?; + Ok(()) +} diff --git a/gui/src/main.rs b/gui/src/main.rs index 2a0a29c..28dc97d 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -4,6 +4,7 @@ rust_i18n::i18n!("locales", fallback = "en"); mod app; mod canvas; +mod export_image; mod flash; mod i18n; mod logging; From 2eb723c2e7f4e788b363efa13de78b392cb94617 Mon Sep 17 00:00:00 2001 From: Roman S Samarev Date: Mon, 27 Jul 2026 17:59:46 +0300 Subject: [PATCH 23/23] GUI: png export: added logic to hide any lines outside the scope --- gui/src/export_image.rs | 175 +++++++++++++++++++++++++++++----------- 1 file changed, 127 insertions(+), 48 deletions(-) diff --git a/gui/src/export_image.rs b/gui/src/export_image.rs index 5f8e5f3..fe34542 100644 --- a/gui/src/export_image.rs +++ b/gui/src/export_image.rs @@ -244,9 +244,32 @@ fn rgb_color(rgb: [u8; 3]) -> tiny_skia::Color { tiny_skia::Color::from_rgba8(rgb[0], rgb[1], rgb[2], 255) } -/// Resample waveform samples into a sequence of (x, y) points for the output -/// graph region. Upscaling uses linear interpolation; downscaling preserves -/// the min/max envelope per output column. +#[inline] +fn waveform_y(sample: f64, height: u32) -> f32 { + let y_scale = (height - 1) as f64 / GRID_HEIGHT_PX; + ((sample - PX_PER_DIV) * y_scale) as f32 +} + +#[inline] +fn is_visible_y(y: f32, height: u32) -> bool { + (0.0..=(height - 1) as f32).contains(&y) +} + +#[inline] +fn border_y(y: f32, height: u32) -> f32 { + if y < 0.0 { + 0.0 + } else { + (height - 1) as f32 + } +} + +/// Resample waveform samples into unclamped `(x, y)` points for the output +/// graph region. Keeping Y unclamped allows each renderer to distinguish real +/// border values from samples that are outside the visible voltage range. +/// +/// Upscaling uses linear interpolation. Downscaling preserves the min/max +/// envelope per output column. fn resample_waveform( samples: &[u8], output_width: u32, @@ -257,13 +280,9 @@ fn resample_waveform( return Vec::new(); } - let y_scale = (output_height - 1) as f64 / GRID_HEIGHT_PX; - - let map_y = |sample: f64| -> f32 { - let y = ((sample - PX_PER_DIV) * y_scale) - .clamp(0.0, (output_height - 1) as f64); - y as f32 - }; + // Keep the unclamped Y coordinate here. draw_trace() needs to know whether + // each resampled point is genuinely inside or outside the visible mesh. + let map_y = |sample: f64| waveform_y(sample, output_height); // Upscaling: linear interpolation between source samples. if output_width as usize >= n { @@ -291,6 +310,7 @@ fn resample_waveform( let max = *col.iter().max().unwrap() as f64; let y_min = map_y(min); let y_max = map_y(max); + // Preserve temporal direction. if col.first() <= col.last() { points.push((x as f32, y_min)); @@ -372,17 +392,23 @@ fn draw_trace( if points.len() < 2 { return; } - let mut path_builder = tiny_skia::PathBuilder::new(); - let first = points[0]; - path_builder.move_to(x_off as f32 + first.0, y_off as f32 + first.1); - if smooth && points.len() >= 4 { - append_catmull_rom_path(&mut path_builder, &points, x_off, y_off); - } else { - for &(x, y) in &points[1..] { - path_builder.line_to(x_off as f32 + x, y_off as f32 + y); + + // An outside point is retained only when it is adjacent to an inside + // point. In that case it is moved onto the corresponding top/bottom mesh + // border. Outside runs therefore remain hidden, while the visible line or + // smooth curve still reaches the border before disappearing/reappearing. + let clipped_points = points.iter().enumerate().map(|(i, &(x, y))| { + if is_visible_y(y, height) { + return Some((x, y)); } - } - let Some(path) = path_builder.finish() else { return; }; + + let previous_inside = i > 0 && is_visible_y(points[i - 1].1, height); + let next_inside = i + 1 < points.len() + && is_visible_y(points[i + 1].1, height); + + (previous_inside || next_inside).then(|| (x, border_y(y, height))) + }); + let mut paint = tiny_skia::Paint::default(); paint.set_color(rgb_color(color)); paint.anti_alias = antialias; @@ -392,10 +418,51 @@ fn draw_trace( line_join: tiny_skia::LineJoin::Round, ..tiny_skia::Stroke::default() }; - pixmap.stroke_path(&path, &paint, &stroke, tiny_skia::Transform::identity(), None); + + let draw_run = |pixmap: &mut tiny_skia::PixmapMut<'_>, run: &[(f32, f32)]| { + if run.len() < 2 { + return; + } + + let mut path_builder = tiny_skia::PathBuilder::new(); + let first = run[0]; + path_builder.move_to(x_off as f32 + first.0, y_off as f32 + first.1); + + if smooth && run.len() >= 4 { + append_catmull_rom_path(&mut path_builder, run, x_off, y_off); + } else { + for &(x, y) in &run[1..] { + path_builder.line_to(x_off as f32 + x, y_off as f32 + y); + } + } + + if let Some(path) = path_builder.finish() { + pixmap.stroke_path( + &path, + &paint, + &stroke, + tiny_skia::Transform::identity(), + None, + ); + } + }; + + let mut run = Vec::new(); + for point in clipped_points { + match point { + Some(point) => run.push(point), + None => { + draw_run(&mut pixmap, &run); + run.clear(); + } + } + } + draw_run(&mut pixmap, &run); } -/// Draw dots via tiny-skia (Dot mode). Each dot is a small filled circle. +/// Draw dots via tiny-skia (Dot mode). Each visible sample is rendered as a +/// small filled circle. Samples outside the vertical mesh range are skipped; +/// unlike line/smooth modes, dot mode never creates synthetic border points. /// /// When anti-aliasing is disabled, dots are drawn as individual pixels /// directly into the RGBA buffer — no scaling, no smoothing, no sub-pixel @@ -418,43 +485,55 @@ fn draw_dots( if n == 0 || width < 2 || height < 2 { return; } - let y_scale = (height - 1) as f64 / GRID_HEIGHT_PX; - let step = n.div_ceil(width.max(1) as usize).max(1); + + let step = n.div_ceil(width as usize).max(1); + let sample_points = (0..n).step_by(step).filter_map(|i| { + let y = waveform_y(samples[i] as f64, height); + is_visible_y(y, height).then(|| { + let x = i as f64 / (n - 1).max(1) as f64 * width as f64; + (x as f32, y) + }) + }); if !antialias { - // Aliased path: set individual pixels directly — no circles, no - // sub-pixel blending, no scaling. - for i in (0..n).step_by(step) { - let x = x_off - + (i as f64 / (n - 1).max(1) as f64 * width as f64).round() as u32; - let sample = samples[i] as f64; - let y = y_off - + ((sample - PX_PER_DIV) * y_scale) - .round() - .clamp(0.0, (height - 1) as f64) as u32; - let idx = (y as usize * image_width as usize + x as usize) * 4; - if idx + 3 < pixels.len() { - pixels[idx..idx + 3].copy_from_slice(&color); - pixels[idx + 3] = 255; + for (x, y) in sample_points { + let px = x_off + x.round() as u32; + let py = y_off + y.round() as u32; + let idx = (py as usize * image_width as usize + px as usize) * 4; + if let Some(pixel) = pixels.get_mut(idx..idx + 4) { + pixel[..3].copy_from_slice(&color); + pixel[3] = 255; } } return; } - let Some(mut pixmap) = tiny_skia::PixmapMut::from_bytes(pixels, image_width, image_height) else { + let Some(mut pixmap) = tiny_skia::PixmapMut::from_bytes( + pixels, + image_width, + image_height, + ) else { return; }; let mut paint = tiny_skia::Paint::default(); paint.set_color(rgb_color(color)); - paint.anti_alias = antialias; - for i in (0..n).step_by(step) { - let x = x_off as f32 + (i as f64 / (n - 1).max(1) as f64 * width as f64) as f32; - let sample = samples[i] as f64; - let y = y_off as f32 + ((sample - PX_PER_DIV) * y_scale) - .clamp(0.0, (height - 1) as f64) as f32; - let Some(circle) = tiny_skia::PathBuilder::from_circle(x, y, dot_radius) else { continue; }; - pixmap.fill_path(&circle, &paint, tiny_skia::FillRule::Winding, - tiny_skia::Transform::identity(), None); + paint.anti_alias = true; + + for (x, y) in sample_points { + let Some(circle) = tiny_skia::PathBuilder::from_circle( + x_off as f32 + x, + y_off as f32 + y, + dot_radius, + ) else { + continue; + }; + pixmap.fill_path( + &circle, + &paint, + tiny_skia::FillRule::Winding, + tiny_skia::Transform::identity(), + None, + ); } }