Dashboard + palette + analyze screen; consolidated categorization rules.

UI restructuring:

- Dashboard (`screen_dashboard.cpp`, `screen_idx = 6`) is the new home
  screen at boot. Reads Overview / Health / Analysis / Modules from
  the current System every frame; per-module rows list parts grouped
  by `connector_type` and a Power/Gnd inference summary (yellow when
  any name-Power signal is refuted). Scrollable via PgUp/PgDn/Home/End.
  Letter shortcuts: `c`=console, `s`=search, `p`=plug (alias of
  connect), `t`=set-type, `e`=explore, `n`=net, `a`=analyze, `q`=quit.
- Global Ctrl-P palette (`screen_palette.cpp`) — fuzzy-finds over
  registered commands + module / signal names. Activation runs the
  bare command or jumps to the matching screen with state seeded.
- Unified analyze screen (`screen_analyze.cpp`, `screen_idx = 7`):
  tabbed layout (`Issues / Groups / Types`), Tab or ←→ to switch
  tabs, ↑/↓ to navigate the focused list. Replaces the previous
  shell-bouncing `[v]erify` shortcut — `verify` content is now in
  the Issues tab. Types tab attaches the decision rationale to each
  signal row (fan-out / voltage / hard floor).
- Context help panel: `RenderHelpPanel(title, entries)` in
  `tui_helpers.{hpp,cpp}` rendered on the right of every screen.
- Console (former "log") rename: screen 0 is `[c]onsole` in the UI
  and "console" in its help-panel title. The underlying screen and
  the shell prompt are unchanged.
- Esc from any non-home screen returns to the dashboard. The
  dashboard itself swallows Esc; quit via `q` / the `quit` command.
  `quit` now calls `screen_ptr->Exit()` directly so it works from
  any screen including via the palette.

Signal type inference:

- `Signal::type` defaults to `Other` — auto-inference no longer
  happens at construction.
- `infer_signal_types(System*)` is called at the end of every load.
  Three rules: GndShield from name alone; Power requires name match
  + a hard fan-out floor (< 3 pins = always Other, regardless of
  name or voltage) + at least one positive structural signal
  (fan-out ≥ 4 OR voltage pattern in the name like `3V3`, `5V`).
- Thresholds exposed in `analysis.hpp` (`POWER_FANOUT_HARD_FLOOR`,
  `POWER_FANOUT_CONFIRM_MIN`, `has_voltage_pattern`) so the analyze
  screen can render the same rationale without duplicating logic.
- `set-signal-type` still wins; save/restore round-trips the type.

Analysis groups & anomalies:

- New `GroupKind::DiffBus` — ≥ 2 diff pairs sharing the same
  outer-stem with consecutive integer indices are aggregated into a
  single bus (`MDI[0..3]_P/N`). `MDI0` and `PCIE_TX_0` index forms
  both accepted. Solo pairs under a bus-able stem fall back to
  `DiffPair`.
- New `AnomalyKind::DiffBusGap` for missing lanes.

Documentation:

- `DESIGN.md`: dedicated "Categorization rules (normative)" section
  consolidating signal type, NC origin, signal groups, anomalies,
  component kind, and connector wiring rules with exact thresholds
  and decision order.
- `doc/user/analysis.md` (new): user-facing version of the same
  rules in plain language. Linked from `doc/user/index.md`.

Tests: +6 new cases (62 total). Adjusted `test_persist.cpp` to set
the signal type explicitly in the fixture (no more auto-inference).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 20:23:33 +02:00
parent 5e89b33088
commit 90502c0762
22 changed files with 1608 additions and 58 deletions

120
DESIGN.md
View File

@@ -55,6 +55,9 @@ src/
screen_settype.cpp BuildSettypeScreen
screen_explore.cpp BuildExploreScreen (browse modules → parts/signals/connections → details; not scriptable)
screen_net.cpp BuildNetScreen (BFS over connections from a starting (module, signal))
screen_dashboard.cpp BuildDashboardScreen (read-only system overview)
screen_analyze.cpp BuildAnalyzeScreen (anomalies / groups / power decisions)
screen_palette.cpp BuildPaletteModal (global Ctrl-P fuzzy launcher)
screen_sigtype_modal.cpp BuildSignalTypeModal (popup attached to net + explore via Modal())
doc/classes.puml -- PlantUML class diagram
```
@@ -100,7 +103,16 @@ Pending prompts (from incomplete inline commands) are NOT considered interactive
`save` / `restore` (`src/system/persist.{hpp,cpp}`): tab-delimited line format, no extra deps. Tags: `M` (module), `P` (part with `connector_type`), `N` (pin → signal name; empty = NC; optional 4th field carries `nc_origin_tag()`: `U` = ImportedUnconnected, `D` = DroppedSingleton — omitted when the pin has a signal or when origin is `None`), `S` (signal → type override; only emitted for non-default), `C` (connection header with endpoints + `transform_name`), `W` (wire pair within the current connection). The 4th N field is backward-compatible: pre-existing snapshots without it restore with `nc_origin = None`.
**Signals** carry a `type` (`SignalType::Power | GndShield | Other`) auto-inferred from the name in `Signal::Signal` via `infer_signal_type` (heuristic: GND/GROUND/SHIELD/CHASSIS → GndShield; PWR/VCC/VDD/VEE/VSS/VBAT/VS_/VS3_*/+/- prefixes → Power; else Other). Override with `set-signal-type <module> <signal> <power|gnd|other>`. The explore screen shows the type in the signal detail header.
**Signals** carry a `type` (`SignalType::Power | GndShield | Other`). The `Signal` constructor **defaults to `Other`** — auto-inference no longer happens at construction. Types are set in three ways, in priority order:
1. **`infer_signal_types(System*)`** (`src/system/analysis.{hpp,cpp}`) runs at the end of every `load` (after `drop_singleton_signals`). It assigns:
- `GndShield` when the **name alone** is unambiguous (`GND`, `SHIELD`, `CHASSIS`, `EARTH`, …) — false-positive rate is essentially zero on these.
- `Power` requires (a) the name heuristic (`infer_signal_type` says Power), (b) a **hard fan-out floor**: signals with fewer than `POWER_FANOUT_HARD_FLOOR = 3` pins are *always* refused, regardless of name or voltage pattern (a real rail physically cannot land on just 1-2 pads), and (c) at least one positive structural signal — fan-out ≥ `POWER_FANOUT_CONFIRM_MIN = 4` **or** a voltage pattern in the name (`3V3`, `5V`, `12V`, …; detector: a `V` adjacent to a digit). This catches `VSEL_*`, `PWR_OK`, `_VDD_SENSE` etc. which look like Power by name but aren't real rails. Both thresholds are exposed in `analysis.hpp` so the analyze screen can render the same reasoning without duplicating constants.
- `Other` otherwise. The "name-said-Power-but-refuted-by-structure" count is reported by `load`.
2. **`set-signal-type <module> <signal> <power|gnd|other>`** is the user override and wins over any inference.
3. **`restore`** reads `S` records, which only exist for non-default (`Other`) types — so save/restore round-trips both inferred and overridden types.
The explore screen shows the type in the signal detail header.
**Pin role expectations**: every Pin carries an `expected_signal_type` populated by `set-type` from a per-(connector_type, pin_name) lookup (`src/system/pin_role.{hpp,cpp}`). The framework is wired end-to-end; the actual VPX 3U lookup table is currently a stub returning Other for all positions — fill in `vpx_3u_role(col, row, idx)` with the real VITA 46 layout when needed.
@@ -110,9 +122,10 @@ Pending prompts (from incomplete inline commands) are NOT considered interactive
**`analyze` (post-processing pass)**: `analyze_system(System*) → AnalysisReport` (`src/system/analysis.{hpp,cpp}`) is a stateless read-only pass that detects structural signal groups and anomalies. Per-module (signals are module-scoped):
- **Diff pairs**: signal names ending `_P` / `_N` (case-insensitive) grouped by stem. Both halves present → `SignalGroup{kind=DiffPair}`.
- **Diff pairs**: signal names ending `_P` / `_N` (case-insensitive) grouped by stem. Both halves present → candidate matched pair.
- **Diff buses**: ≥ 2 matched diff pairs whose pair-stems share a common outer-stem after stripping a trailing integer (`MDI0` / `MDI1` / `MDI2` → outer `MDI` + indices). The strict `_` rule from plain buses does NOT apply to this trailing-index split: `_P`/`_N` was already stripped, so we know remaining digits are an index. Two index variants accepted: contiguous (`MDI0`) and underscore-separated (`PCIE_TX_0`). Emitted as `SignalGroup{kind=DiffBus, lo, hi}` with label `OUTER[lo..hi]_P/N`. Members include all 2·N constituent signals. A "bus" of size 1 falls back to `DiffPair` (single index does not a bus make).
- **Buses**: two accepted forms — bracketed `NAME[N]` or strict-underscore `NAME_N`. The strict `_` rule before the digits is what avoids matching names like `GETH_01_VDD12` (no `_` before `12`). A stem with ≥ 2 entries becomes `SignalGroup{kind=Bus, lo, hi}`.
- **Anomalies** detected: `DiffPairOrphan` and `BusGap` (missing index inside `[lo..hi]`). The diff-pair orphan reporter is **asymmetric on purpose**: only `_P` without `_N` is reported, because `_N` is overloaded with active-low semantics (`RESET_N`, `BOOTMODE_N`) and reporting both directions floods the output with false positives.
- **Anomalies** detected: `DiffPairOrphan`, `DiffBusGap` (missing lane in `MDI[0..3]_P/N`), and `BusGap` (missing index inside a plain bus `[lo..hi]`). The diff-pair orphan reporter is **asymmetric on purpose**: only `_P` without `_N` is reported, because `_N` is overloaded with active-low semantics (`RESET_N`, `BOOTMODE_N`) and reporting both directions floods the output with false positives.
- **Filters** to keep noise low: signals starting with `$` are skipped (Mentor's internal `$Nxxxx` net names).
Exposed as the `analyze` shell command which prints groups (sorted by module + label) followed by anomalies. Designed to be consumed by the upcoming dashboard so the summary is visible at a glance. Tests: `tests/test_analysis.cpp`.
@@ -131,12 +144,32 @@ Exposed as the `analyze` shell command which prints groups (sorted by module + l
**Subset wiring + NC backfill**: `CheckIdentityCompatible(a, b, info=&s)` accepts the case where one side's canonical pin set is a subset of the other's — typical when one importer drops NC pins (Altium) and the other doesn't (Mentor). It populates `info` with a non-fatal "N pin(s) only on '<part>'" message. Bidirectional mismatch (both sides have orphans) is still refused. After acceptance, `connect` calls `FillIdentityNCs(p1, p2)` which materialises the orphan canonical positions on the missing side as NC pins (`new Pin(other_side_name)`) — so `Connection::pin_map.size()` matches the larger side's count. Idempotent.
`screen_idx` mapping: 0 = main TUI, 1 = search, 2 = connect, 3 = set-type, 4 = explore, 5 = net. Adding a new screen mode is the same recipe each time: state members, `Container::Vertical` of focusable components, a `Renderer` lambda that recomputes derived state per frame (e.g. filtered part lists), an entry in `Container::Tab`, and Tab/Esc handling in the outer `CatchEvent`.
`screen_idx` mapping: **6 = dashboard (home, set in the constructor)**, 0 = console (textual shell + log view), 1 = search, 2 = connect, 3 = set-type, 4 = explore, 5 = net, 7 = analyze. The dashboard is the boot screen; the console is the secondary screen reachable via the `[c]` shortcut and used to display textual output from `verify`/`analyze`/etc. plus collect arguments for multi-step commands. The label was renamed from "log" to "console" because the screen is also where commands are typed — "log" only described half of what it does.
**Dashboard letter conflicts**: with the screen renames, `[c]` now opens the **console** rather than `connect`. The connect command is surfaced as **`[p]lug`** on the dashboard (a UI rename only — the canonical command stays `connect` for script + save/restore stability, with `plug` registered as an alias so the palette finds it under either name).
**Esc navigation**: every non-home screen returns to the dashboard. The dashboard itself swallows Esc (no parent) — use `[q]uit` or the `quit` command via the palette / shell to leave. From the shell, Esc cancels a pending multi-step prompt if one is in flight, otherwise returns to the dashboard.
**Quit propagation**: the `quit` / `exit` commands set `Tui::quit = true` *and* call `screen_ptr->Exit()` directly, so quitting works from any screen (including via the palette while sitting on the dashboard). The legacy `if (quit) screen.Exit();` in the shell Renderer stays as a belt-and-braces backup. Adding a new screen mode is the same recipe each time: state members, `Container::Vertical` of focusable components, a `Renderer` lambda that recomputes derived state per frame (e.g. filtered part lists), an entry in `Container::Tab`, and Tab/Esc handling in the outer `CatchEvent`.
**Analyze screen** (`screen_analyze.cpp`, dashboard shortcut `a`, `screen_idx = 7`): unified **verify + analyze** view with a tabbed layout — horizontal tab bar at the top (`Issues (…) │ Groups (…) │ Types: …`), and a single scrollable detail panel below showing the active tab's list. Tab swap is handled at the outer `CatchEvent` (`Tab` / `→` cycle forward, `Shift-Tab` / `←` cycle back). The detail uses `Container::Tab({issues_menu, groups_menu, types_menu}, &analyze_focus_idx)` so `↑/↓` always navigate the visible list; each tab preserves its own selection idx.
- **Issues** pane merges: pin-role mismatches (typed pins whose actual signal type disagrees with the role from `connector_type`), bridged-net Power↔Gnd inconsistencies (the BFS check formerly in `verify` pass 2), and the structural anomalies from `analyze_system` (`DiffPairOrphan`, `BusGap`, `DiffBusGap`). Header counts each category.
- **Groups** pane lists every detected `SignalGroup` sorted by `module / label` with kind tag and member count.
- **Types** pane lists per-signal Power decisions (`[Power confirmed]` / `[Power REFUTED]` / `[Gnd]`) plus a trailing `[NC]` orphan rollup line. The pane header summarises counts (`N pwr-ok, M refuted, K gnd`).
Everything is recomputed every frame so manual overrides via the signal-type popup are reflected immediately. Esc returns to the dashboard. The dashboard's previous `[v]erify` letter shortcut was removed — its content is fully covered by this screen. The textual `verify` / `analyze` commands still exist for scripts.
**Command palette** (`screen_palette.cpp`): a global modal launcher attached to the whole tab tree via `tab | Modal(BuildPaletteModal(), &palette_open)` in `Run()`. Trigger: `Event::CtrlP` (FTXUI Input does not consume Ctrl-P, so the outer `CatchEvent` reliably picks it up first). Behaviour: a single Input bound to `palette_query` plus a result list rebuilt on every frame. Indexes three kinds of entries: commands (from the `commands` map), modules and per-module signals (qualified as `module/signal`). Fuzzy match is subsequence-based, case-insensitive: lower score wins, computed as `first_match_position * 100 + sum_of_gaps`. Kinds are biased by a constant offset (commands +0, modules +1000, signals +2000) so command matches come first when scores tie. Output capped at 20 rows to keep render cheap on big systems. Activation (`Enter`): commands → `Dispatch(name)` (which dispatches like the shell, including opening interactive screens), module → prefill `explore_*` state and jump to `screen_idx = 4`, signal → prefill `net_modules` + seed `net_sig_filter` to the exact signal name and jump to `screen_idx = 5`. `Esc` closes the palette. While the palette is open, the outer `CatchEvent` cedes events to it so Tab/Esc/etc. don't leak into the underlying screen.
**Dashboard** (`screen_dashboard.cpp`, `dashboard` command, `screen_idx = 6`): read-only system overview. Single Renderer, no Input child. Recomputes everything per frame (cheap on realistic sizes): counters (modules/parts/signals/connections), three health rows (verify pin-role mismatches, bridged-net inconsistencies, NC orphans — green check / yellow warning prefix), an analysis summary line (diff pairs / buses / anomaly count, coloured if non-zero), and a per-module table (parts / signals / `connector_type`-tagged parts). Letter shortcuts handled in the outer `CatchEvent`: `s/c/t/e/n` `Dispatch` the bare interactive command (which populates state + flips `screen_idx`), `v` and `a` `Dispatch` the textual command then jump back to the shell (`screen_idx = 0`) so the output is readable. `Esc` returns to the shell. The dashboard is `interactive = true`, `scriptable = false`; running `dashboard` inside `source` aborts the script.
**Screen titles** (shared idiom): every interactive screen renders a top bar in the form `" essim "` (bold) + `"→ "` (dim) + `"<screen-name>"` (bold) + `" — <short description>"` (dim), followed by a `separator()`. The main screen has its own variant that adds a live `N module(s), M connection(s)` counter on the right. Aim is to make the breadcrumb between essim and the current mode visible at all times.
**Focus highlighting**: each interactive screen reuses `FocusLabel(elem, focused)` from `tui_helpers.hpp` (inline helper, `focused ? e | inverted : e`) on the label of the currently-focused field so the user sees at a glance where the next keystroke lands. Indices match the `Container::Vertical` order — e.g. `connect` has 7 (m1, p1-filter, p1-menu, m2, p2-filter, p2-menu, Button), `net` has 3 (filter, module, signal). Buttons (`Connect`, `Apply`) get the highlight on the button itself, not a separate label.
**Context help panel**: every screen renders a right-column help panel via `RenderHelpPanel(title, entries)` (`tui_helpers.{hpp,cpp}`). Fixed width 30 cols, key column 9 chars, then a flex description. The entries list is per-screen so it acts as a context cheat-sheet: dashboard advertises its letter shortcuts + `Ctrl-P` + `q`; the shell advertises history / scroll / `Ctrl-P` / common commands; the interactive screens advertise `Tab` / `↑↓` / `Enter` / `Esc` semantics. Helps replace the previous one-line dim footers, which were truncated at small widths.
**Main-screen title bar**: top of `BuildMainScreen` renders `" essim "` (bold) + `"— system digital twin"` (dim) on the left and a live `"N module(s), M connection(s)"` (or `"no system loaded"`) on the right, then a `separator()`. Built from `sys->modules()->size()` / `sys->connections()->size()` each frame — cheap. `screen_main.cpp` therefore needs to include `system/system.hpp`, `system/modules.hpp`, `system/connect.hpp` directly (forward-declared in `tui.hpp`).
**Main-screen scrollback**: the visualisation area lets you scroll through past output. State is `Tui::scroll_offset` (0 = follow tail). Keys (default branch of the outer `CatchEvent` in `Run()`): `PageUp` / `PageDown` step 10 lines, `Home` jumps to top, `End` returns to tail. `Print()` resets `scroll_offset = 0` so any new output snaps the view back to the tail (otherwise late errors would be hidden). Render: instead of `focusPositionRelative(0, 1)` always anchoring to the bottom, the screen places `| focus` on `output[size - 1 - scroll_offset]` and shows a `[scroll: -N / PgUp PgDn Home End]` indicator next to the prompt when offset > 0. `vscroll_indicator | yframe` for the FTXUI-side scroll bar.
@@ -171,6 +204,85 @@ Command history is persisted on disk and loaded on startup. Path resolution is p
Each successful submission appends a single line to the file (so a crash doesn't lose history). Multi-step prompt answers are NOT persisted — only top-level commands.
## Categorization rules (normative)
Everything in this section is a precise description of how signals, pins, parts, and analysis groups are classified. Each rule lists its input, its check, its output, and the source file. Code and doc are kept in sync — when a threshold changes, update both.
### Signal type (Power / GndShield / Other)
Default: `Signal::type = SignalType::Other` (constructor does no inference).
Type is set by `infer_signal_types(System*)` (`src/system/analysis.cpp`), called at the end of every `load` and after `duplicate`. The decision per signal:
1. Compute `named = infer_signal_type(name)` (`src/system/signals.cpp`):
- `GndShield` if name matches **case-insensitively**: `GND`, `GROUND`, `EARTH`, `SHIELD`, `CHASSIS`, or starts with `GND_` / `GROUND_` / `EARTH_` / `SHIELD_` / `CHASSIS_`.
- `Power` if the name contains any of `PWR`, `POWER`, `VCC`, `VDD`, `VEE`, `VSS`, `VBAT`, or starts with `VS_`, `VS3_`, `+`, `-` followed by a digit.
- Else `Other`.
2. If `named == GndShield` → final = `GndShield`. (Single criterion: name. False positives near zero.)
3. If `named == Power`:
- **Hard floor**: fan-out `< POWER_FANOUT_HARD_FLOOR` (= **3**) → final = `Other`. Always overrides. A net touching only 12 pads physically cannot be a power rail.
- **Else**: confirmation requires at least one of: fan-out `≥ POWER_FANOUT_CONFIRM_MIN` (= **4**), **or** a voltage pattern in the name (any `V` adjacent to a digit, case-insensitive — catches `3V3`, `5V`, `12V`, `0V9`). If either holds → final = `Power`. Otherwise → `Other`.
4. If `named == Other` → final = `Other`.
`set-signal-type <module> <signal> <power|gnd|other>` always wins. `save`/`restore` round-trips the final `type` via `S` records (only non-`Other` are persisted).
### NC pin origin
`Pin::nc_origin` (`src/system/pins.hpp`). Default `NcOrigin::None`. Set by:
- **`NcOrigin::ImportedUnconnected`** — Mentor importer (`src/imports/import_mentor.cpp`): the signal field of an `Explicit Pin:` row starts case-insensitively with `unconnected` (e.g. `'unconnected'`, `'unconnected (by TERM)'`). The pin is kept on the part with no signal.
- **`NcOrigin::DroppedSingleton`** — `drop_singleton_signals(Signals*)` (`src/system/signals.cpp`) called at the end of `load`: any signal whose pin set has size exactly 1 is unconnected by definition. The pin is detached (`sig = nullptr`) and tagged; the `Signal` object is deleted.
- **`NcOrigin::None` (no tag)** — pins materialised by `FillIdentityNCs` at `connect` time. These are unconnected locally but bridged to a real signal on the peer module via `Connection::pin_map`; they are explicitly excluded from the "orphan" count in `verify` and the analyze screen.
### Signal groups
`analyze_system(System*)` (`src/system/analysis.cpp`) emits `SignalGroup`s **per module** (signals are module-scoped). Internal names (starting with `$` — Mentor's `$Nxxxx` convention) are skipped wholesale at every step.
**DiffPair** (`GroupKind::DiffPair`):
- Signal name ends `_P` or `_N` (case-insensitive). The character before the suffix must be `_`.
- Group by stem (name minus `_P`/`_N`). Both halves present → emit `DiffPair` with two members.
**DiffBus** (`GroupKind::DiffBus`):
- Take every matched `DiffPair` candidate and split its stem with `split_trailing_index`: trailing digit run becomes the index, the rest the outer-stem. Two forms accepted (`MDI0` → outer `MDI` + 0; `PCIE_TX_0` → outer `PCIE_TX_` + 0). The strict `_` rule of plain buses does **not** apply here (the `_P`/`_N` was already stripped, so any trailing digit is an index).
- Group by outer-stem. **≥ 2** pairs → `DiffBus` with label `OUTER[lo..hi]_P/N` and 2·N members. Exactly 1 → falls back to `DiffPair` (a single index does not make a bus).
**Bus** (`GroupKind::Bus`):
- Two accepted forms: bracketed `NAME[N]` (where `N` is digits between `[` and `]`), and underscore `NAME_N` (where `_` is **strictly required** between the stem and the trailing digit run — this is what rejects `GETH_01_VDD12` from being parsed as a bus).
- Group by stem. **≥ 2** signals → `Bus` with label `STEM[lo..hi]`.
### Analysis anomalies
`analyze_system` reports the following:
- **`DiffPairOrphan`** — a stem has `_P` but no matching `_N`. The reverse (`_N` orphan) is **deliberately not reported** because `_N` is overloaded with active-low semantics (`RESET_N`, `BOOTMODE_N`, `PRESENT_N`); flagging it would flood the output with false positives.
- **`BusGap`** — within a `Bus[lo..hi]`, one or more indices in the interior are missing.
- **`DiffBusGap`** — within a `DiffBus[lo..hi]`, one or more lane indices in the interior are missing.
The analyze screen additionally surfaces two "verify-class" issues, computed the same way as the textual `verify` command:
- **pin-role mismatch** — a pin whose `expected_signal_type` (set by `set-type` via `pin_role(connector_type, pin_name)`) disagrees with the actual signal type.
- **net-mix** — a bridged net (BFS over `Connection::pin_map`, ≥ 2 members) where `net_type_consistent(net, &dominant)` returns false. Specifically, the net contains both `Power` and `GndShield` signals.
### Component kind
`Part::kind` is inferred at construction (`src/system/component_kind.cpp`) from the leading reference-designator letter(s) of the part name. **Longest-match wins**:
- Two-letter prefixes (checked first, case-insensitive): `LED → Semiconductor`, `TP → TestPoint`, `SW → Switch`, `FB → Passive`, `MK / MP / MH → Mechanical`, `HS → Mechanical`, `RA / RN / RP / RV → Passive`.
- Single-letter fallback: `R / C / L / F → Passive`, `D / Q → Semiconductor`, `U → IntegratedCircuit`, `J / P → Connector`, `Y / X → Crystal`, `S → Switch`.
- No match → `Other`.
Recomputed on `restore` (no persistence tag). Currently not used by any decision flow — branch points are search filter / `set-type` guard / explore header.
### Connector wiring (transforms)
`connect` looks up a registered transform for `(p1->connector_type, p2->connector_type)` via `TransformRegistry::lookup`, tried in both directions. Fall-through is `IdentityTransform`:
- Compares pin sets by **canonical name** (`canonical_pin_name(s)`, `src/system/pin_name.cpp`): split into prefix + digit suffix; if the suffix is pure digits, zero-pad to width 3 (`A1``A001`, `A001``A001`, `A1B``A1B`, `VCC``VCC`).
- `CheckIdentityCompatible(a, b)` accepts the **subset case** (one side's canonical set is a subset of the other's — typical because Altium drops NC, Mentor doesn't). Bidirectional mismatch (both sides have orphans) is refused.
- After acceptance, `FillIdentityNCs(p1, p2)` **materialises** the missing canonical positions on the smaller side as new NC pins (`new Pin(other_side_name)`, no signal attached, `nc_origin = None`). Idempotent.
Pins materialised this way are bridged via `Connection::pin_map` to a real signal on the peer module — the orphan counter in `verify` and the analyze screen excludes them.
## Gotchas
- All three importers (`IMPORT_MENTOR`, `IMPORT_ALTIUM`, `IMPORT_ODS`) are wired in `System::Load`. Wrap calls in `try/catch` (the TUI does).

136
doc/user/analysis.md Normal file
View File

@@ -0,0 +1,136 @@
# essim — how the analysis classifies things
essim looks at signal names and the way pins are wired to decide
whether a net is a **power rail**, a **ground**, a **diff pair**, a
**bus**, etc. This page summarises those rules in plain language so you
know what to expect when you run `analyze` (the `[a]` shortcut on the
dashboard) or when you read the numbers on the home screen.
Nothing here mutates anything you cannot fix manually: every
inference can be overridden with `set-signal-type`, and the rules are
re-run on every `load` so the picture stays consistent with the
netlists currently in memory.
## Signal type — Power / Gnd / Other
Every signal is classified into one of three buckets.
**Gnd** if the name matches one of:
`GND`, `GROUND`, `EARTH`, `SHIELD`, `CHASSIS` (or starts with any of
those followed by `_`). The name alone is enough — false positives
here are essentially nil.
**Power** is a two-stage decision:
1. The name has to suggest power — it contains `PWR`, `POWER`,
`VCC`, `VDD`, `VEE`, `VSS`, `VBAT`, or starts with `VS_`, `VS3_`,
`+5V`-style or `-12V`-style prefixes.
2. The wiring has to corroborate it. essim requires at least one of:
- the signal lands on **4 or more pins** (a real rail goes to
decouplers + ICs + connectors, so it almost always has many
pads), or
- the name contains a **voltage value**`3V3`, `5V`, `12V`,
`0V9`, `5V0`, etc. (any `V` next to a digit).
*Hard floor*: a signal touching **fewer than 3 pins** is
**never** Power, even if both 1 and the voltage motif are
present. Physically you cannot have a rail on 1 or 2 pads.
**Other** in every other case.
This rule deliberately rejects things that look like power but
aren't: `PWR_OK` (status), `VSEL_0` (voltage select), `VDD_SENSE`
(sense feedback) — they all match step 1 but fail steps 2/3. The
analyze screen lists them under **Suspect Power** with the reason
attached (`fan-out 1, no voltage` etc.). Inspect, then either accept
the suspect status or force it back with `set-signal-type`.
## NC (no-connect) pins
A pin is shown as `(NC)` in the explore detail when it has no signal
attached. essim distinguishes three reasons:
- **Imported NC** — the netlist explicitly says the pin is
unconnected (Mentor format: signal name `unconnected` or
`unconnected (by TERM)`; Altium format: the pin is simply omitted
from every signal block).
- **Dropped singleton** — after import, essim removes every signal
that touches exactly one pin. A net with a single endpoint cannot
carry signal anywhere, so the pin is detached and tagged. This
catches both intentional sentinels and the per-IC `NC_*` labels
that customers often put on dead pads.
- **Filled at connect** — when you `connect` two parts that don't
agree on which pins exist (a Mentor part may have all pads, an
Altium part only the wired ones), essim materialises the missing
pads on the smaller side. They are unconnected *locally* on that
module but are bridged to a real signal on the other module via
the connection — so they do not count as orphans.
The dashboard's "NC" row summarises orphan counts (imported and
dropped only; filled-at-connect pins are excluded). The analyze
screen's "Types" tab adds a trailing line with the totals.
## Signal groups
essim groups signals that share an obvious structural pattern. They
are detected per module — a multi-card bus on the system is the BFS
union of the per-module groups it touches.
**Diff pair** — two signals named `STEM_P` and `STEM_N`
(case-insensitive, `_` required before the polarity letter). Both
halves must be present. Lone `_P` halves are flagged as orphans;
lone `_N` halves are *not* flagged (the `_N` suffix is overloaded
with active-low semantics — `RESET_N`, `BOOTMODE_N` — and flagging
them would flood the report).
**Diff bus** — at least two diff pairs whose stems share a common
prefix and only differ by a trailing index: `MDI0_P`/`MDI0_N`,
`MDI1_P`/`MDI1_N`, … → `MDI[0..3]_P/N`. Both `STEMN` and `STEM_N`
forms work (`MDI0`, `PCIE_TX_0`).
**Bus** — at least two signals with a common stem and a trailing
integer index. Two notations: `DATA[0]`, `DATA[1]`, … (bracketed)
or `ADDR_0`, `ADDR_1`, … (underscore — *strict*: an underscore is
required between the stem and the digits, so a name like
`GETH_01_VDD12` is *not* a bus).
**Anomalies** are emitted alongside groups:
- *Diff pair orphan*: a `_P` with no matching `_N`.
- *Diff bus gap*: e.g. `MDI[0..3]` has `MDI0`, `MDI1`, `MDI3` (`MDI2`
missing).
- *Bus gap*: same idea on plain buses.
Internal Mentor net names that start with `$` (like `$N12345`) are
skipped from every group/bus detection.
## Issues reported by `analyze`
The Issues tab of the analyze screen aggregates everything that
deserves attention:
| Tag | What it means |
|---|---|
| `[pin-role]` | A connector pin is typed (via `set-type`) as Power or Gnd but the actual signal landing on it disagrees. |
| `[net-mix]` | A net bridged across modules carries both Power and Gnd signals — almost always a topology mistake. |
| `[diff-pair-orphan]` | `STEM_P` with no `STEM_N` in the same module. |
| `[bus-gap]` | A bus is missing one or more index values inside its range. |
| `[diff-bus-gap]` | A diff bus is missing one or more lane indices. |
Zero issues = the module passes every structural check essim knows
how to run today.
## Overrides
Every classification is advisory. To force a different type:
- **Signal type**: from the `net` or `explore` screen, press Enter
on a signal entry → a popup lets you pick `power` / `gnd` /
`other`. Or type `set-signal-type <module> <signal> <type>` in the
console (or from the palette).
- **Connector type**: `set-type <module> <part> <connector-kind>`
(also via the dashboard `[t]` shortcut). This drives the pin role
expectations, which feed the `pin-role` check.
Overrides survive `save`/`restore` but are recomputed at every
`load` (i.e. the inference re-runs).

View File

@@ -93,6 +93,9 @@ fresh.
- [`commands.md`](commands.md) — exhaustive command reference,
regenerated from the binary on every `cmake --build build --target doc`.
- [`analysis.md`](analysis.md) — how essim classifies signals
(Power / Gnd / Other), how it detects buses and diff pairs, what
the `analyze` screen actually reports and why.
- [`scripting.md`](scripting.md) — `set` / `$var` / `${var}`, `source`
semantics, the script-save denylist.
- [`DESIGN.md`](../../DESIGN.md) — implementation notes, useful if

View File

@@ -12,6 +12,7 @@
const char *group_kind_name(GroupKind k) {
switch (k) {
case GroupKind::DiffPair: return "diff-pair";
case GroupKind::DiffBus: return "diff-bus";
case GroupKind::Bus: return "bus";
}
return "?";
@@ -21,6 +22,7 @@ const char *anomaly_kind_name(AnomalyKind k) {
switch (k) {
case AnomalyKind::DiffPairOrphan: return "diff-pair-orphan";
case AnomalyKind::BusGap: return "bus-gap";
case AnomalyKind::DiffBusGap: return "diff-bus-gap";
}
return "?";
}
@@ -77,6 +79,21 @@ bool is_internal_name(const std::string &n) {
return !n.empty() && n[0] == '$';
}
// Trailing-integer split: "MDI0" → ("MDI", 0); "PCIE_TX_3" → ("PCIE_TX_", 3);
// "USB" → false (no trailing digits). Used for diff-bus aggregation only —
// the strict `_` rule from `numeric_suffix` does NOT apply here because the
// caller has already stripped a `_P` / `_N` polarity suffix, so we know the
// remaining digits are an index rather than part of a longer name.
bool split_trailing_index(const std::string &s, std::string &outer, int &idx) {
if (s.empty()) return false;
size_t i = s.size();
while (i > 0 && std::isdigit((unsigned char)s[i - 1])) --i;
if (i == s.size() || i == 0) return false;
idx = std::atoi(s.c_str() + i);
outer = s.substr(0, i);
return true;
}
void analyse_module(Module *mod, AnalysisReport &out) {
// ---- Pass 1: diff pairs ----
std::unordered_map<std::string, std::pair<Signal *, Signal *>> dp; // stem -> {P, N}
@@ -88,15 +105,15 @@ void analyse_module(Module *mod, AnalysisReport &out) {
if (pol == 'P') slot.first = kv.second;
else slot.second = kv.second;
}
// Two stages: (1) emit orphan anomalies for unmatched pairs, (2) try to
// bus-ify the matched ones by trailing index. A diff bus = ≥2 matched
// pairs sharing the same outer-stem.
struct Pair { Signal *p; Signal *n; std::string stem; };
std::vector<Pair> matched;
for (const auto &kv : dp) {
const auto &slot = kv.second;
if (slot.first && slot.second) {
SignalGroup g;
g.kind = GroupKind::DiffPair;
g.label = kv.first + "_P/N";
g.module = mod;
g.members = {slot.first, slot.second};
out.groups.push_back(std::move(g));
matched.push_back({slot.first, slot.second, kv.first});
continue;
}
// Orphan reporting is asymmetric on purpose: a `_P` without a `_N`
@@ -114,6 +131,74 @@ void analyse_module(Module *mod, AnalysisReport &out) {
out.anomalies.push_back(std::move(a));
}
// Group matched pairs by outer-stem of their pair-stem.
std::map<std::string, std::map<int, Pair>> diff_buses;
std::vector<Pair> loners;
for (auto &m : matched) {
std::string outer; int idx;
if (split_trailing_index(m.stem, outer, idx))
diff_buses[outer][idx] = m;
else
loners.push_back(m);
}
// Emit unfamily-able pairs as plain DiffPair.
for (const auto &p : loners) {
SignalGroup g;
g.kind = GroupKind::DiffPair;
g.label = p.stem + "_P/N";
g.module = mod;
g.members = {p.p, p.n};
out.groups.push_back(std::move(g));
}
// Bus-ify groups of size ≥ 2; singletons fall back to DiffPair.
for (auto &bkv : diff_buses) {
auto &members = bkv.second;
if (members.size() == 1) {
const auto &p = members.begin()->second;
SignalGroup g;
g.kind = GroupKind::DiffPair;
g.label = p.stem + "_P/N";
g.module = mod;
g.members = {p.p, p.n};
out.groups.push_back(std::move(g));
continue;
}
int lo = members.begin()->first;
int hi = members.rbegin()->first;
SignalGroup g;
g.kind = GroupKind::DiffBus;
g.module = mod;
g.lo = lo; g.hi = hi;
g.label = bkv.first + "[" + std::to_string(lo) + ".."
+ std::to_string(hi) + "]_P/N";
for (auto &mkv : members) {
g.members.push_back(mkv.second.p);
g.members.push_back(mkv.second.n);
}
out.groups.push_back(std::move(g));
std::vector<int> missing;
for (int i = lo + 1; i < hi; ++i)
if (!members.count(i)) missing.push_back(i);
if (!missing.empty()) {
Anomaly a;
a.kind = AnomalyKind::DiffBusGap;
a.module = mod;
std::string m = mod->name + ": " + bkv.first + "["
+ std::to_string(lo) + ".." + std::to_string(hi)
+ "]_P/N missing lane(s)";
for (int idx : missing) m += " " + std::to_string(idx);
a.message = std::move(m);
for (auto &mkv : members) {
a.involved.push_back(mkv.second.p);
a.involved.push_back(mkv.second.n);
}
out.anomalies.push_back(std::move(a));
}
}
// ---- Pass 2: buses ----
// Group by stem; only consider stems with at least 2 entries.
// Mixed bracketed / non-bracketed in the same stem are treated as one
@@ -160,6 +245,62 @@ void analyse_module(Module *mod, AnalysisReport &out) {
} // namespace
// Voltage pattern detector: returns true if `name` (case-insensitive) holds
// a token like `3V3`, `12V`, `0V9`, `5V0`. Strong signal that the net
// actually carries a power rail. Walks the string in one pass.
bool has_voltage_pattern(const std::string &name) {
auto is_d = [](char c) { return c >= '0' && c <= '9'; };
for (size_t i = 0; i < name.size(); ++i) {
char c = name[i];
char up = (char)std::toupper((unsigned char)c);
if (up != 'V') continue;
bool digits_before = i > 0 && is_d(name[i - 1]);
bool digits_after = i + 1 < name.size() && is_d(name[i + 1]);
if (digits_before || digits_after) return true;
}
return false;
}
SignalTypeInferenceStats infer_signal_types(System *sys) {
SignalTypeInferenceStats st;
if (!sys) return st;
for (auto &mkv : *sys->modules()) {
Module *mod = mkv.second;
for (auto &skv : *mod->signals) {
Signal *s = skv.second;
SignalType named = infer_signal_type(s->name);
if (named == SignalType::GndShield) {
s->type = SignalType::GndShield;
++st.gnd;
continue;
}
if (named == SignalType::Power) {
int fanout = (int)s->size();
// Hard rule: a "power" net that touches fewer than three
// pins cannot physically be a rail (a real rail goes to
// decouplers + ICs at minimum). Override the name.
if (fanout < POWER_FANOUT_HARD_FLOOR) {
s->type = SignalType::Other;
++st.kept_other;
continue;
}
bool big_fanout = fanout >= POWER_FANOUT_CONFIRM_MIN;
bool voltage = has_voltage_pattern(s->name);
if (big_fanout || voltage) {
s->type = SignalType::Power;
++st.power;
} else {
s->type = SignalType::Other;
++st.kept_other;
}
continue;
}
s->type = SignalType::Other;
}
}
return st;
}
AnalysisReport analyze_system(const System *sys) {
AnalysisReport out;
if (!sys) return out;

View File

@@ -14,6 +14,8 @@ class System;
enum class GroupKind {
DiffPair, ///< Two signals of the form X_P / X_N (or X_p / X_n).
DiffBus, ///< ≥2 diff pairs sharing an outer stem with consecutive
///< integer indices: MDI0_P/N, MDI1_P/N, MDI2_P/N → MDI[0..2]_P/N.
Bus, ///< NAME[0..N] or NAME_0..NAME_N (consecutive integer suffix).
};
@@ -28,6 +30,7 @@ struct SignalGroup {
enum class AnomalyKind {
DiffPairOrphan, ///< X_P present without X_N (or vice versa).
BusGap, ///< NAME[0..N] has a missing index inside the range.
DiffBusGap, ///< Diff bus MDI[0..3]_P/N is missing one of its lanes.
};
struct Anomaly {
@@ -47,4 +50,27 @@ AnalysisReport analyze_system(const System *sys);
const char *group_kind_name(GroupKind k);
const char *anomaly_kind_name(AnomalyKind k);
struct SignalTypeInferenceStats {
int power = 0; ///< Signals promoted to Power (name + structural).
int gnd = 0; ///< Signals promoted to GndShield (name only).
int kept_other = 0; ///< Name said Power but structural evidence too weak.
};
// Thresholds used by `infer_signal_types` (re-exposed so the analyze screen
// can compute the same decision rationale without duplicating constants).
inline constexpr int POWER_FANOUT_HARD_FLOOR = 3; ///< Below this, never Power.
inline constexpr int POWER_FANOUT_CONFIRM_MIN = 4; ///< ≥ this confirms Power.
bool has_voltage_pattern(const std::string &name);
// Best-effort signal-type inference. Sets `Signal::type`:
// - GndShield when the name unambiguously matches GND/SHIELD/CHASSIS/EARTH.
// - Power when the name suggests Power AND there is structural evidence
// (fan-out ≥ POWER_FANOUT_MIN, or a voltage pattern like `3V3` / `5V`
// embedded in the name).
// - Other otherwise.
// Stateless beyond the signal->type write; safe to re-run after import.
// Returns counts for reporting.
SignalTypeInferenceStats infer_signal_types(System *sys);
#endif // _ANALYSIS_HPP_

View File

@@ -74,7 +74,7 @@ SignalType infer_signal_type(const std::string &name) {
Signal::Signal(std::string name)
: SystemElementContainer<Pin>(name), prnt(nullptr),
type(infer_signal_type(name)) {};
type(SignalType::Other) {};
void Signal::add(Pin *pin)
{

View File

@@ -74,10 +74,16 @@ void Tui::RegisterCommands() {
};
commands["clear"] = { {}, [this](auto &) { output.clear(); }, true,
"clear the visualization area" };
commands["quit"] = { {}, [this](auto &) { quit = true; }, true,
"leave essim" };
commands["exit"] = { {}, [this](auto &) { quit = true; }, true,
"leave essim (alias of quit)" };
// quit / exit work from any screen: set the flag *and* call Exit() on the
// captured ScreenInteractive so the FTXUI loop returns immediately. The
// legacy main-screen Renderer also reads `quit` as a belt-and-braces
// backup when the screen_ptr hasn't been set yet (early-init / tests).
auto do_quit = [this](auto &) {
quit = true;
if (screen_ptr) screen_ptr->Exit();
};
commands["quit"] = { {}, do_quit, true, "leave essim" };
commands["exit"] = { {}, do_quit, true, "leave essim (alias of quit)" };
commands["new"] = { {}, [this](auto &) {
sys = std::make_unique<System>();
@@ -135,11 +141,16 @@ void Tui::RegisterCommands() {
sys->Load(args[0], args[1], t);
Module *mod = sys->modules()->get(args[0]);
int dropped = drop_singleton_signals(mod->signals);
auto inf = infer_signal_types(sys.get());
Print("loaded '" + args[0] + "' from " + args[1]);
Print(" parts: " + std::to_string(mod->size()));
Print(" signals: " + std::to_string(mod->signals->size())
+ (dropped ? " (dropped " + std::to_string(dropped)
+ " singleton/NC signal(s))" : ""));
Print(" types: " + std::to_string(inf.power) + " power, "
+ std::to_string(inf.gnd) + " gnd, "
+ std::to_string(inf.kept_other)
+ " name-power refuted by analysis");
} catch (const std::exception &e) {
Print(std::string("load failed: ") + e.what());
}
@@ -281,6 +292,13 @@ void Tui::RegisterCommands() {
}, true,
"check pin roles locally and signal-type consistency across bridged nets" };
commands["dashboard"] = { {}, [this](auto &) {
screen_idx = 6;
}, true,
"open the dashboard (system overview)",
/*scriptable=*/ false,
/*interactive=*/ true };
commands["analyze"] = { {}, [this](auto &) {
if (!sys) { Print("no system: run 'new' first."); return; }
AnalysisReport rep = analyze_system(sys.get());
@@ -616,6 +634,10 @@ void Tui::RegisterCommands() {
/*scriptable=*/ true,
/*interactive=*/ true,
};
// UI alias: the dashboard surfaces this command as `plug`. Keep the
// canonical `connect` for script + save/restore stability.
commands["plug"] = commands["connect"];
commands["plug"].description = "alias of `connect` (UI label used in the dashboard)";
commands["explore"] = { {}, [this](auto &) {
if (!sys) { Print("no system: run 'new' first."); return; }

289
src/tui/screen_analyze.cpp Normal file
View File

@@ -0,0 +1,289 @@
#include "tui/tui.hpp"
#include "tui/tui_helpers.hpp"
#include "system/analysis.hpp"
#include "system/connect.hpp"
#include "system/modules.hpp"
#include "system/nets.hpp"
#include "system/parts.hpp"
#include "system/pins.hpp"
#include "system/signals.hpp"
#include "system/system.hpp"
#include <ftxui/component/component.hpp>
#include <ftxui/component/component_options.hpp>
#include <ftxui/dom/elements.hpp>
#include <algorithm>
#include <array>
#include <unordered_set>
using namespace ftxui;
Component Tui::BuildAnalyzeScreen() {
auto issues_menu = Menu(&analyze_issues, &analyze_issue_idx);
auto groups_menu = Menu(&analyze_groups, &analyze_group_idx);
auto types_menu = Menu(&analyze_types, &analyze_type_idx);
// Single detail panel — Tab swap routes a fresh Menu to the renderer.
// `analyze_focus_idx` selects which is shown and focused.
auto detail = Container::Tab(
{issues_menu, groups_menu, types_menu}, &analyze_focus_idx);
return Renderer(detail, [this, detail] {
auto title = hbox({
text(" essim ") | bold,
text("") | dim,
text("analyze") | bold,
text(" — verify + structural analysis in one place") | dim,
});
if (!sys) {
return vbox({
title,
separator(),
text(" no system loaded") | dim,
filler(),
}) | border;
}
AnalysisReport rep = analyze_system(sys.get());
// ============================================================ Issues
// Three sub-categories, in priority order: pin-role mismatches first
// (typed pins where the actual signal type disagrees with the role),
// then bridged-net inconsistencies (Power↔Gnd mixing across a
// connection), then structural anomalies from the analysis pass.
analyze_issues.clear();
int n_role_mismatches = 0, n_typed_pins = 0;
for (auto &mkv : *sys->modules())
for (auto &pkv : *mkv.second) {
Part *prt = pkv.second;
if (prt->connector_type.empty()) continue;
for (auto &nkv : *prt) {
Pin *pin = nkv.second;
++n_typed_pins;
SignalType expected = pin->expected_signal_type;
if (expected == SignalType::Other) continue;
Signal *s = pin->signal();
SignalType actual = s ? s->type : SignalType::Other;
if (actual == expected) continue;
++n_role_mismatches;
std::string sig_label = s ? s->name : std::string("(NC)");
analyze_issues.push_back(
"[pin-role] " + mkv.first + "/" + prt->name + "/"
+ pin->name + ": expected " + signal_type_name(expected)
+ ", got " + signal_type_name(actual)
+ " (signal: " + sig_label + ")");
}
}
auto nets = compute_all_nets(sys.get());
int n_bridged = 0, n_inconsistent = 0;
for (const auto &n : nets) {
if (n.members.size() < 2) continue;
++n_bridged;
SignalType dom;
if (net_type_consistent(n, dom)) continue;
++n_inconsistent;
std::string line = "[net-mix] mixes Power and Gnd:";
for (const auto &mp : n.members)
line += " " + mp.first->name + "/" + mp.second->name
+ "(" + signal_type_name(mp.second->type) + ")";
analyze_issues.push_back(std::move(line));
}
for (const auto &a : rep.anomalies)
analyze_issues.push_back(std::string("[")
+ anomaly_kind_name(a.kind) + "] "
+ a.message);
if (analyze_issues.empty()) analyze_issues.push_back("(no issue found)");
if (analyze_issue_idx >= (int)analyze_issues.size())
analyze_issue_idx = (int)analyze_issues.size() - 1;
std::string issues_header = "Issues ("
+ std::to_string(n_role_mismatches + n_inconsistent
+ (int)rep.anomalies.size())
+ ": " + std::to_string(n_role_mismatches) + " pin-role, "
+ std::to_string(n_inconsistent) + " net-mix, "
+ std::to_string(rep.anomalies.size()) + " struct.)";
// ============================================================ Groups
analyze_groups.clear();
auto groups = rep.groups;
std::sort(groups.begin(), groups.end(),
[](const SignalGroup &a, const SignalGroup &b) {
std::string ma = a.module ? a.module->name : std::string{};
std::string mb = b.module ? b.module->name : std::string{};
if (ma != mb) return ma < mb;
return a.label < b.label;
});
for (const auto &g : groups) {
std::string mname = g.module ? g.module->name : std::string("?");
int n = (int)g.members.size();
analyze_groups.push_back(
"[" + std::string(group_kind_name(g.kind)) + "] "
+ mname + "/" + g.label
+ "" + std::to_string(n) + " signal" + (n == 1 ? "" : "s"));
}
if (analyze_groups.empty()) analyze_groups.push_back("(no group)");
if (analyze_group_idx >= (int)analyze_groups.size())
analyze_group_idx = (int)analyze_groups.size() - 1;
std::string groups_header = "Groups (" + std::to_string(rep.groups.size()) + ")";
// ============================================================= Types
// Power decisions (confirmed / refuted) and NC orphan breakdown.
analyze_types.clear();
int conf_pwr = 0, ref_pwr = 0, gnd = 0;
struct Row { char kind; std::string mod, sig; int fanout; bool voltage; };
std::vector<Row> rows;
for (auto &mkv : *sys->modules()) {
Module *mod = mkv.second;
for (auto &skv : *mod->signals) {
Signal *s = skv.second;
SignalType named = infer_signal_type(s->name);
char kind = 0;
if (named == SignalType::GndShield && s->type == SignalType::GndShield) {
kind = 'G'; ++gnd;
} else if (named == SignalType::Power && s->type == SignalType::Power) {
kind = 'P'; ++conf_pwr;
} else if (named == SignalType::Power && s->type == SignalType::Other) {
kind = 'R'; ++ref_pwr;
} else continue;
rows.push_back({kind, mod->name, s->name,
(int)s->size(), has_voltage_pattern(s->name)});
}
}
std::sort(rows.begin(), rows.end(),
[](const Row &a, const Row &b) {
if (a.kind != b.kind) return a.kind < b.kind;
if (a.mod != b.mod) return a.mod < b.mod;
return a.sig < b.sig;
});
for (const auto &r : rows) {
// Tag + the *reason* the decision went that way. The reason is
// what the analysis pass actually checked: name match alone is
// never enough for Power; fan-out ≥ 4 or a voltage pattern in
// the name is the structural confirmation.
const char *tag;
std::string reason;
bool big = r.fanout >= POWER_FANOUT_CONFIRM_MIN;
bool below_floor = r.fanout < POWER_FANOUT_HARD_FLOOR;
if (r.kind == 'P') {
tag = "[Power] ";
if (big && r.voltage) reason = "name + fan-out " + std::to_string(r.fanout)
+ " + voltage in name";
else if (big) reason = "name + fan-out " + std::to_string(r.fanout);
else reason = "name + voltage in name (fan-out "
+ std::to_string(r.fanout) + ")";
} else if (r.kind == 'R') {
tag = "[Suspect Power] ";
if (below_floor) reason = "fan-out " + std::to_string(r.fanout)
+ " < " + std::to_string(POWER_FANOUT_HARD_FLOOR)
+ " (hard floor — never Power)";
else reason = "name only — fan-out "
+ std::to_string(r.fanout)
+ ", no voltage";
} else {
tag = "[Gnd] ";
reason = "name match (fan-out " + std::to_string(r.fanout) + ")";
}
analyze_types.push_back(
std::string(tag) + r.mod + "/" + r.sig + "" + reason);
}
// NC orphan rollup — same filter as the verify pass.
std::unordered_set<Pin *> bridged_pins;
for (auto &ckv : *sys->connections())
for (auto &wp : ckv.second->pin_map) {
if (wp.first) bridged_pins.insert(wp.first);
if (wp.second) bridged_pins.insert(wp.second);
}
int orph_imported = 0, orph_dropped = 0;
for (auto &mkv : *sys->modules())
for (auto &pkv : *mkv.second)
for (auto &nkv : *pkv.second) {
Pin *pin = nkv.second;
if (pin->signal() || bridged_pins.count(pin)) continue;
if (pin->nc_origin == NcOrigin::ImportedUnconnected) ++orph_imported;
else if (pin->nc_origin == NcOrigin::DroppedSingleton) ++orph_dropped;
}
analyze_types.push_back(
"[NC] orphan pin(s): " + std::to_string(orph_imported + orph_dropped)
+ " (" + std::to_string(orph_imported) + " imported, "
+ std::to_string(orph_dropped) + " dropped)");
if (analyze_type_idx >= (int)analyze_types.size())
analyze_type_idx = (int)analyze_types.size() - 1;
std::string types_header = "Types: " + std::to_string(conf_pwr)
+ " Power, " + std::to_string(ref_pwr)
+ " Suspect, " + std::to_string(gnd)
+ " Gnd";
// Tab bar — horizontal headers, active one inverted.
const std::array<std::string, 3> tab_labels = {
issues_header, groups_header, types_header };
Elements tabs;
for (int i = 0; i < 3; ++i) {
auto el = text(" " + tab_labels[i] + " ");
if (i == analyze_focus_idx) el = el | bold | inverted;
else el = el | dim;
tabs.push_back(el);
if (i < 2) tabs.push_back(text("") | dim);
}
auto tab_bar = hbox(std::move(tabs));
Element help = RenderHelpPanel("analyze", {
{"Tab / ←→", "switch tab"},
{"↑/↓", "navigate"},
{"Ctrl-P", "palette"},
{"Esc", "dashboard"},
});
// Glossary: only relevant on the Types tab. Same width as the help
// panel for visual coherence.
Element types_glossary = vbox({
text(" types ") | bold,
separator(),
hbox({text(" Power ") | bold | size(WIDTH, EQUAL, 12),
text("name + structure") | flex}),
hbox({text(" ") | dim | size(WIDTH, EQUAL, 12),
text("(fan-out ≥ 4 or") | dim | flex}),
hbox({text(" ") | dim | size(WIDTH, EQUAL, 12),
text(" voltage in name)") | dim | flex}),
text(""),
hbox({text(" Suspect ") | bold | size(WIDTH, EQUAL, 12),
text("name only,") | flex}),
hbox({text(" ") | dim | size(WIDTH, EQUAL, 12),
text("weak evidence") | dim | flex}),
text(""),
hbox({text(" hard ") | bold | size(WIDTH, EQUAL, 12),
text("fan-out < 3 →") | flex}),
hbox({text(" floor ") | bold | size(WIDTH, EQUAL, 12),
text("never Power") | flex}),
text(""),
hbox({text(" Gnd ") | bold | size(WIDTH, EQUAL, 12),
text("name only") | flex}),
}) | size(WIDTH, EQUAL, 30);
Element side = (analyze_focus_idx == 2)
? vbox({help, text(""), types_glossary}) | size(WIDTH, EQUAL, 30)
: help;
return vbox({
title,
separator(),
tab_bar,
separator(),
hbox({
detail->Render() | vscroll_indicator | yframe | flex,
separator(),
side,
}) | flex,
}) | border;
});
}

View File

@@ -141,9 +141,19 @@ Component Tui::BuildConnectScreen() {
text(" — wire two parts across modules (TransformRegistry-driven)") | dim,
});
Element help = RenderHelpPanel("connect", {
{"Tab", "cycle focus"},
{"↑/↓", "navigate menu"},
{"Enter", "on [Connect] → wire"},
{"Ctrl-P", "palette"},
{"Esc", "dashboard"},
});
return vbox({
title,
separator(),
hbox({
vbox({
hbox({
col("endpoint 1", m1_menu, p1_filter, p1_menu, 0, 1, 2),
separator(),
@@ -153,7 +163,10 @@ Component Tui::BuildConnectScreen() {
hbox({filler(),
FocusLabel(connect_button->Render(), connect_focus_idx == 6),
filler()}),
text(" Tab: cycle focus | Enter on [Connect]: confirm | Esc: leave ") | dim,
}) | flex,
separator(),
help,
}) | flex,
}) | border;
});
}

View File

@@ -0,0 +1,317 @@
#include "tui/tui.hpp"
#include "tui/tui_helpers.hpp"
#include "system/analysis.hpp"
#include "system/connect.hpp"
#include "system/modules.hpp"
#include "system/nets.hpp"
#include "system/parts.hpp"
#include "system/pins.hpp"
#include "system/signals.hpp"
#include "system/system.hpp"
#include <ftxui/component/component.hpp>
#include <ftxui/dom/elements.hpp>
#include <algorithm>
#include <map>
#include <unordered_set>
#include <vector>
using namespace ftxui;
namespace {
// Two-column key/value row, fixed-width key so columns line up.
Element kv(const std::string &k, const std::string &v) {
return hbox({
text(" " + k) | dim | size(WIDTH, EQUAL, 16),
text(v),
});
}
} // namespace
Component Tui::BuildDashboardScreen() {
return Renderer([this] {
auto title = hbox({
text(" essim ") | bold,
text("") | dim,
text("dashboard") | bold,
text(" — system overview at a glance") | dim,
});
Element early_help = RenderHelpPanel("dashboard", {
{"c", "console"},
{"a", "analyze"},
{"q", "quit"},
{"Ctrl-P", "palette"},
});
if (!sys) {
return vbox({
title,
separator(),
hbox({
vbox({
text(" no system loaded — run 'new' or 'restore <file>'") | dim,
text(" (press 'c' for the console, or Ctrl-P for the palette)") | dim,
filler(),
}) | flex,
separator(),
early_help,
}) | flex,
}) | border;
}
// ---- counters ----
int n_modules = (int)sys->modules()->size();
int n_parts = 0, n_signals = 0;
for (auto &mkv : *sys->modules()) {
n_parts += (int)mkv.second->size();
n_signals += (int)mkv.second->signals->size();
}
int n_conn = (int)sys->connections()->size();
// ---- verify-style health (recomputed; cheap on realistic sizes) ----
int n_role_mismatches = 0, n_typed_pins = 0;
for (auto &mkv : *sys->modules())
for (auto &pkv : *mkv.second) {
Part *prt = pkv.second;
if (prt->connector_type.empty()) continue;
for (auto &nkv : *prt) {
Pin *pin = nkv.second;
++n_typed_pins;
SignalType expected = pin->expected_signal_type;
if (expected == SignalType::Other) continue;
Signal *s = pin->signal();
SignalType actual = s ? s->type : SignalType::Other;
if (actual != expected) ++n_role_mismatches;
}
}
auto nets = compute_all_nets(sys.get());
int n_bridged = 0, n_inconsistent = 0;
for (const auto &n : nets) {
if (n.members.size() < 2) continue;
++n_bridged;
SignalType dom;
if (!net_type_consistent(n, dom)) ++n_inconsistent;
}
// ---- NC orphan summary (matches verify pass 3) ----
std::unordered_set<Pin *> bridged_pins;
for (auto &ckv : *sys->connections())
for (auto &wp : ckv.second->pin_map) {
if (wp.first) bridged_pins.insert(wp.first);
if (wp.second) bridged_pins.insert(wp.second);
}
int orph_imported = 0, orph_dropped = 0;
// Per-module list of dropped-singleton pins, for the detail rows below
// the NC health line. The signal name is gone (the Signal object was
// deleted by `drop_singleton_signals`), but the pin's full path is
// enough to locate it in `explore`.
std::map<std::string, std::vector<std::string>> dropped_by_module;
for (auto &mkv : *sys->modules())
for (auto &pkv : *mkv.second)
for (auto &nkv : *pkv.second) {
Pin *pin = nkv.second;
if (pin->signal() || bridged_pins.count(pin)) continue;
if (pin->nc_origin == NcOrigin::ImportedUnconnected) {
++orph_imported;
} else if (pin->nc_origin == NcOrigin::DroppedSingleton) {
++orph_dropped;
dropped_by_module[mkv.first].push_back(
pkv.first + "/" + nkv.first);
}
}
auto health_line = [](bool ok, const std::string &s) {
return hbox({
text(ok ? "" : "") | (ok ? color(Color::Green) : color(Color::Yellow)),
text(s),
});
};
Elements health_rows;
health_rows.push_back(health_line(n_role_mismatches == 0,
"verify: " + std::to_string(n_role_mismatches)
+ " pin-role mismatch(es) over " + std::to_string(n_typed_pins)
+ " typed pin(s)"));
health_rows.push_back(health_line(n_inconsistent == 0,
"nets: " + std::to_string(n_inconsistent) + " inconsistent over "
+ std::to_string(n_bridged) + " bridged (" + std::to_string(nets.size())
+ " total)"));
int orph_total = orph_imported + orph_dropped;
health_rows.push_back(health_line(orph_total == 0,
"NC: " + std::to_string(orph_total) + " orphan pin(s) ("
+ std::to_string(orph_imported) + " imported, "
+ std::to_string(orph_dropped) + " dropped)"));
// ---- analysis summary ----
AnalysisReport rep = analyze_system(sys.get());
int n_diff = 0, n_diff_bus = 0, n_bus = 0;
for (const auto &g : rep.groups) {
if (g.kind == GroupKind::DiffPair) ++n_diff;
else if (g.kind == GroupKind::DiffBus) ++n_diff_bus;
else if (g.kind == GroupKind::Bus) ++n_bus;
}
// ---- per-module table ----
std::vector<std::string> mod_names;
for (auto &mkv : *sys->modules()) mod_names.push_back(mkv.first);
std::sort(mod_names.begin(), mod_names.end(), NaturalLess);
size_t maxw = 1;
for (const auto &n : mod_names) maxw = std::max(maxw, n.size());
Elements mod_rows;
for (const auto &name : mod_names) {
Module *m = sys->modules()->get(name);
int total = (int)m->size();
// Group parts by connector_type so the table answers "which type
// is on which part?" rather than just "how many are typed?".
std::map<std::string, std::vector<std::string>> by_type;
for (auto &pkv : *m)
if (!pkv.second->connector_type.empty())
by_type[pkv.second->connector_type].push_back(pkv.first);
mod_rows.push_back(hbox({
text(" " + name + std::string(maxw - name.size(), ' '))
| size(WIDTH, EQUAL, (int)maxw + 4),
text(std::to_string(total) + " part(s)") | size(WIDTH, EQUAL, 14),
text(std::to_string(m->signals->size()) + " signal(s)"),
}));
// Power-signal breakdown for this module — same classification as
// the analyze screen so the dashboard summary stays consistent.
int n_pwr_ok = 0, n_pwr_refuted = 0, n_gnd = 0;
for (auto &skv : *m->signals) {
Signal *s = skv.second;
SignalType named = infer_signal_type(s->name);
if (named == SignalType::GndShield && s->type == SignalType::GndShield) ++n_gnd;
else if (named == SignalType::Power && s->type == SignalType::Power) ++n_pwr_ok;
else if (named == SignalType::Power && s->type == SignalType::Other) ++n_pwr_refuted;
}
if (n_pwr_ok + n_pwr_refuted + n_gnd > 0) {
std::string label = "power: " + std::to_string(n_pwr_ok)
+ " confirmed, " + std::to_string(n_pwr_refuted)
+ " refuted gnd: " + std::to_string(n_gnd);
auto el = text(" " + label);
if (n_pwr_refuted > 0) el = el | color(Color::Yellow);
mod_rows.push_back(el);
}
if (by_type.empty()) {
mod_rows.push_back(hbox({
text(" "),
text("(no connector types assigned)") | dim,
}));
} else {
for (auto &tkv : by_type) {
std::sort(tkv.second.begin(), tkv.second.end(), NaturalLess);
std::string parts_csv;
for (size_t i = 0; i < tkv.second.size(); ++i) {
if (i) parts_csv += ", ";
parts_csv += tkv.second[i];
}
mod_rows.push_back(hbox({
text(" "),
text(tkv.first) | bold,
text(": "),
text(parts_csv),
}));
}
}
}
// Flatten the dashboard into a list of lines so we can scroll it as a
// whole when the content overflows. The pattern mirrors the shell's
// scrollback: pick one focused line and wrap in `yframe`.
Elements lines;
lines.push_back(text(" Overview") | bold);
lines.push_back(hbox({
vbox({
kv("Modules", std::to_string(n_modules)),
kv("Parts", std::to_string(n_parts)),
}) | flex,
vbox({
kv("Signals", std::to_string(n_signals)),
kv("Connections", std::to_string(n_conn)),
}) | flex,
}));
lines.push_back(separator());
lines.push_back(text(" Health") | bold);
for (auto &h : health_rows) lines.push_back(std::move(h));
// Detail rows for the dropped-singleton NCs. Imported NCs are not
// expanded — they were already explicit in the netlist. Dropped NCs
// come from a heuristic, so listing them gives the user a chance to
// spot a false positive.
if (orph_dropped > 0) {
lines.push_back(hbox({
text(" dropped detail:") | dim,
}));
for (auto &dkv : dropped_by_module) {
std::sort(dkv.second.begin(), dkv.second.end(), NaturalLess);
std::string csv;
for (size_t i = 0; i < dkv.second.size(); ++i) {
if (i) csv += ", ";
csv += dkv.second[i];
}
lines.push_back(hbox({
text(" " + dkv.first + ": ") | bold,
text(csv),
}));
}
}
lines.push_back(separator());
lines.push_back(text(" Analysis") | bold);
lines.push_back(hbox({text("") | dim,
text(std::to_string(n_diff) + " diff pair(s)")}));
lines.push_back(hbox({text("") | dim,
text(std::to_string(n_diff_bus) + " diff bus(es)")}));
lines.push_back(hbox({text("") | dim,
text(std::to_string(n_bus) + " bus(es)")}));
lines.push_back(hbox({text("") | dim,
rep.anomalies.empty()
? text(std::to_string(rep.anomalies.size()) + " anomaly(ies)")
: text(std::to_string(rep.anomalies.size()) + " anomaly(ies)")
| color(Color::Yellow)}));
lines.push_back(separator());
lines.push_back(text(" Modules") | bold);
for (auto &r : mod_rows) lines.push_back(std::move(r));
// Clamp scroll, mark a focused line so `yframe` positions the view.
int line_count = (int)lines.size();
if (dashboard_scroll_offset < 0) dashboard_scroll_offset = 0;
if (dashboard_scroll_offset > line_count - 1)
dashboard_scroll_offset = std::max(0, line_count - 1);
lines[dashboard_scroll_offset] = lines[dashboard_scroll_offset] | focus;
Element main_col = vbox(std::move(lines))
| vscroll_indicator
| yframe
| flex;
Element help = RenderHelpPanel("dashboard", {
{"c", "console"},
{"s", "search"},
{"p", "plug"},
{"t", "set-type"},
{"e", "explore"},
{"n", "net"},
{"a", "analyze (verify + groups)"},
{"PgUp", "scroll up"},
{"PgDn", "scroll down"},
{"Home", "scroll top"},
{"End", "scroll bottom"},
{"Ctrl-P", "palette"},
{"q", "quit"},
});
return vbox({
title,
separator(),
hbox({main_col, separator(), help}) | flex,
}) | border;
});
}

View File

@@ -232,11 +232,19 @@ Component Tui::BuildExploreScreen() {
text(" — browse modules → parts/signals/connections → details") | dim,
});
Element help = RenderHelpPanel("explore", {
{"Tab", "cycle focus"},
{"↑/↓", "navigate"},
{"Enter", "set signal type"},
{"Ctrl-P", "palette"},
{"Esc", "dashboard"},
});
return vbox({
title,
separator(),
hbox({col1, separator(), col2, separator(), col3, separator(), col4}) | flex,
text(" Tab: cycle focus | Enter (on a signal): set signal type | Esc: leave ") | dim,
hbox({col1, separator(), col2, separator(), col3, separator(),
col4 | flex, separator(), help}) | flex,
}) | border;
} catch (const std::exception &e) {
return vbox({
@@ -245,7 +253,7 @@ Component Tui::BuildExploreScreen() {
separator(),
text("explore: render error") | bold,
text(std::string(" ") + e.what()) | dim,
text(" Esc: leave explore ") | dim,
text(" Esc: dashboard ") | dim,
}) | border;
}
});

View File

@@ -1,4 +1,5 @@
#include "tui/tui.hpp"
#include "tui/tui_helpers.hpp"
#include "system/connect.hpp"
#include "system/modules.hpp"
@@ -65,12 +66,33 @@ Component Tui::BuildMainScreen(ScreenInteractive &screen) {
+ " / PgUp PgDn Home End to navigate]"
: "";
Element help = RenderHelpPanel("console", {
{"Enter", "submit command"},
{"Tab", "complete"},
{"↑/↓", "history"},
{"PgUp", "scroll up"},
{"PgDn", "scroll down"},
{"Home", "scroll top"},
{"End", "scroll tail"},
{"Ctrl-P", "palette"},
{"Esc", "dashboard"},
{"help", "list commands"},
{"quit", "leave essim"},
});
auto base = vbox({
title,
separator(),
hbox({
vbox({
view,
separator(),
hbox({text(label), input_component->Render(), filler(), text(status) | dim}),
hbox({text(label), input_component->Render(),
filler(), text(status) | dim}),
}) | flex,
separator(),
help,
}) | flex,
}) | border;
if (loading) {

View File

@@ -117,11 +117,19 @@ Component Tui::BuildNetScreen() {
text(" — BFS of (module, signal) bridged through connections") | dim,
});
Element help = RenderHelpPanel("net", {
{"Tab", "cycle focus"},
{"↑/↓", "navigate menu"},
{"Enter", "set signal type"},
{"Ctrl-P", "palette"},
{"Esc", "dashboard"},
});
return vbox({
title,
separator(),
hbox({left, separator(), middle, separator(), right}) | flex,
text(" Tab: cycle focus | Enter (on signal): set signal type | Esc: leave ") | dim,
hbox({left, separator(), middle, separator(), right | flex,
separator(), help}) | flex,
}) | border;
});
}

205
src/tui/screen_palette.cpp Normal file
View File

@@ -0,0 +1,205 @@
#include "tui/tui.hpp"
#include "tui/tui_helpers.hpp"
#include "system/modules.hpp"
#include "system/signals.hpp"
#include "system/system.hpp"
#include <ftxui/component/component.hpp>
#include <ftxui/component/component_options.hpp>
#include <ftxui/component/event.hpp>
#include <ftxui/dom/elements.hpp>
#include <algorithm>
#include <utility>
using namespace ftxui;
namespace {
// Subsequence-based fuzzy score. Returns {true, score} when every char of
// `query` (case-insensitive) appears in `candidate` in order. Lower score
// is better: earlier first match + fewer gaps.
std::pair<bool, int> fuzzy_score(const std::string &query,
const std::string &candidate) {
if (query.empty()) return {true, 0};
std::string q = ToLower(query);
std::string c = ToLower(candidate);
size_t qi = 0;
int first_match = -1, gap = 0, prev = -1;
for (size_t ci = 0; ci < c.size() && qi < q.size(); ++ci) {
if (c[ci] != q[qi]) continue;
if (first_match < 0) first_match = (int)ci;
if (prev >= 0) gap += (int)ci - prev - 1;
prev = (int)ci;
++qi;
}
if (qi < q.size()) return {false, 0};
return {true, first_match * 100 + gap};
}
} // namespace
void Tui::OpenPalette() {
palette_open = true;
palette_query.clear();
palette_idx = 0;
}
void Tui::ActivatePaletteEntry() {
if (palette_idx < 0 || palette_idx >= (int)palette_items.size()) {
palette_open = false;
return;
}
auto [kind, payload] = palette_items[palette_idx];
palette_open = false;
if (kind == 'c') {
Dispatch(payload);
// A command that needs arguments pushes onto `pending`; the only
// place that knows how to answer it today is the shell's Input.
// Flip there so the user can complete the wizard.
if (!pending.empty()) screen_idx = 0;
return;
}
if (kind == 'm') {
// Jump to explore, prefilled on this module.
if (!sys) return;
explore_modules.clear();
for (auto &mk : *sys->modules()) explore_modules.push_back(mk.first);
std::sort(explore_modules.begin(), explore_modules.end(), NaturalLess);
auto it = std::find(explore_modules.begin(), explore_modules.end(), payload);
if (it == explore_modules.end()) return;
explore_module_idx = (int)(it - explore_modules.begin());
explore_type_idx = 0; // parts
explore_child_filter.clear();
explore_child_idx = 0;
explore_detail_filter.clear();
explore_detail_idx = 0;
explore_focus_idx = 0;
screen_idx = 4;
return;
}
if (kind == 's') {
// payload = "module\tsignal" → jump to net screen prefilled.
size_t tab = payload.find('\t');
if (tab == std::string::npos || !sys) return;
std::string mname = payload.substr(0, tab);
std::string sname = payload.substr(tab + 1);
net_modules.clear();
for (auto &mk : *sys->modules()) net_modules.push_back(mk.first);
std::sort(net_modules.begin(), net_modules.end(), NaturalLess);
auto it = std::find(net_modules.begin(), net_modules.end(), mname);
if (it == net_modules.end()) return;
net_module_idx = (int)(it - net_modules.begin());
// The net screen recomputes net_sigs every frame from the filter;
// pre-seeding the filter to the exact name highlights the target.
net_sig_filter = sname;
net_sig_idx = 0;
net_focus_idx = 2; // start focused on the signal menu
screen_idx = 5;
return;
}
}
Component Tui::BuildPaletteModal() {
InputOption opt;
opt.multiline = false;
opt.on_change = [this]() { palette_idx = 0; };
opt.transform = [](InputState s) {
auto el = s.element;
if (s.is_placeholder) el |= dim;
return el;
};
auto query_input = Input(&palette_query, "command / module / signal…", opt);
auto handler = CatchEvent(query_input, [this](Event e) {
if (e == Event::Escape) { palette_open = false; return true; }
if (e == Event::Return) { ActivatePaletteEntry(); return true; }
if (e == Event::ArrowDown) {
if (!palette_items.empty())
palette_idx = std::min((int)palette_items.size() - 1,
palette_idx + 1);
return true;
}
if (e == Event::ArrowUp) {
palette_idx = std::max(0, palette_idx - 1);
return true;
}
return false;
});
return Renderer(handler, [this, query_input] {
// ---- Rebuild ranked result list from query + current system ----
palette_labels.clear();
palette_items.clear();
struct Hit { int score; std::string label; char kind; std::string payload; };
std::vector<Hit> hits;
// Commands.
for (const auto &kv : commands) {
auto [ok, score] = fuzzy_score(palette_query, kv.first);
if (!ok) continue;
hits.push_back({score,
" [cmd] " + kv.first + "" + kv.second.description,
'c', kv.first});
}
// Modules + signals (skip when no system).
if (sys) {
for (auto &mk : *sys->modules()) {
Module *mod = mk.second;
{
auto [ok, score] = fuzzy_score(palette_query, mod->name);
if (ok)
hits.push_back({score + 1000,
" [mod] " + mod->name,
'm', mod->name});
}
for (auto &sk : *mod->signals) {
std::string label = mod->name + "/" + sk.first;
auto [ok, score] = fuzzy_score(palette_query, label);
if (!ok) continue;
hits.push_back({score + 2000,
" [sig] " + label,
's', mod->name + "\t" + sk.first});
}
}
}
std::sort(hits.begin(), hits.end(),
[](const Hit &a, const Hit &b) {
if (a.score != b.score) return a.score < b.score;
return a.label < b.label;
});
// Cap the list to keep render cheap on big systems.
const size_t MAX_SHOWN = 20;
if (hits.size() > MAX_SHOWN) hits.resize(MAX_SHOWN);
for (const auto &h : hits) {
palette_labels.push_back(h.label);
palette_items.emplace_back(h.kind, h.payload);
}
if (palette_idx >= (int)palette_items.size())
palette_idx = std::max(0, (int)palette_items.size() - 1);
Elements rows;
for (int i = 0; i < (int)palette_labels.size(); ++i) {
auto el = text(palette_labels[i]);
if (i == palette_idx) el = el | inverted;
rows.push_back(el);
}
if (rows.empty()) rows.push_back(text(" (no match)") | dim);
return vbox({
hbox({text(" palette ") | bold,
text("— Ctrl-P / : to toggle ") | dim}),
separator(),
hbox({text(" "), query_input->Render() | flex}) | border,
vbox(std::move(rows)),
separator(),
text(" ↑/↓ select • Enter run/jump • Esc cancel ") | dim,
}) | border | size(WIDTH, GREATER_THAN, 60);
});
}

View File

@@ -83,11 +83,17 @@ Component Tui::BuildSearchScreen() {
text(" — filter parts and signals by pattern") | dim,
});
Element help = RenderHelpPanel("search", {
{"Tab", "cycle focus"},
{"↑/↓", "navigate menu"},
{"Ctrl-P", "palette"},
{"Esc", "dashboard"},
});
return vbox({
title,
separator(),
hbox({left, separator(), right}) | flex,
text(" Tab: cycle focus | Esc: leave search ") | dim,
hbox({left, separator(), right | flex, separator(), help}) | flex,
}) | border;
});
}

View File

@@ -133,13 +133,21 @@ Component Tui::BuildSettypeScreen() {
text(" — tag a part with its connector kind (drives transforms + pin roles)") | dim,
});
Element help = RenderHelpPanel("set-type", {
{"Tab", "cycle focus"},
{"↑/↓", "navigate menu"},
{"Enter", "on [Apply] → tag"},
{"Ctrl-P", "palette"},
{"Esc", "dashboard"},
});
return vbox({
title,
separator(),
hbox({left, separator(), middle, separator(), right}) | flex,
hbox({left, separator(), middle, separator(), right | flex,
separator(), help}) | flex,
separator(),
status,
text(" Tab: cycle focus | Enter on [Apply]: apply (stay) | Esc: leave ") | dim,
}) | border;
});
}

View File

@@ -13,7 +13,8 @@ Tui::Tui()
loading(false), tick_in_flight(false),
loading_idx(0), loading_executed(0), loading_lineno(0),
loading_prev_in_source(false), screen_ptr(nullptr),
screen_idx(0),
screen_idx(6), // boot to the dashboard; shell (screen 0) is now a sub-screen
search_types{"parts", "signals"},
search_module_idx(0), search_type_idx(0), search_focus_idx(0),
connect_m1_idx(0), connect_m2_idx(0),
@@ -45,51 +46,105 @@ void Tui::Run() {
&sigtype_dialog_open);
auto net_screen = BuildNetScreen() | Modal(BuildSignalTypeModal(),
&sigtype_dialog_open);
auto dashboard_screen = BuildDashboardScreen();
auto analyze_screen = BuildAnalyzeScreen();
auto tab = Container::Tab(
{main_screen, search_screen, connect_screen, settype_screen, explore_screen,
net_screen},
net_screen, dashboard_screen, analyze_screen},
&screen_idx);
auto root = CatchEvent(tab, [this](Event e) {
// The signal-type popup must own Escape / Tab while it's open so the
// outer switch doesn't yank us back to the main screen.
if (sigtype_dialog_open) return false;
// Palette is a global Modal — overlays the tab on every screen.
auto with_palette = tab | Modal(BuildPaletteModal(), &palette_open);
auto root = CatchEvent(with_palette, [this](Event e) {
// Modals (palette + sigtype popup) own their events while open.
if (palette_open || sigtype_dialog_open) return false;
// Ctrl-P opens the palette from any screen.
if (e == Event::CtrlP) { OpenPalette(); return true; }
switch (screen_idx) {
case 7: // analyze
if (e == Event::Escape) { screen_idx = 6; return true; }
// Tab and ←/→ both switch the active tab. ↑/↓ stay with the
// detail Menu so it can scroll.
if (e == Event::Tab || e == Event::ArrowRight) {
analyze_focus_idx = (analyze_focus_idx + 1) % 3;
return true;
}
if (e == Event::TabReverse || e == Event::ArrowLeft) {
analyze_focus_idx = (analyze_focus_idx + 2) % 3;
return true;
}
return false;
case 6: // dashboard (home)
// Home has no parent — Esc is swallowed. Use 'q' to quit.
if (e == Event::Escape) { return true; }
// Scroll the dashboard when content overflows the viewport. The
// upper bound is clamped inside the Renderer (we don't know the
// line count from here).
if (e == Event::PageDown) { dashboard_scroll_offset += 10; return true; }
if (e == Event::PageUp) {
dashboard_scroll_offset = std::max(0, dashboard_scroll_offset - 10);
return true;
}
if (e == Event::Home) { dashboard_scroll_offset = 0; return true; }
if (e == Event::End) { dashboard_scroll_offset = 100000; return true; }
if (e == Event::Character("q")) { Dispatch("quit"); return true; }
// [c]onsole = the textual shell screen (former [l]og). [p]lug
// = the `connect` command (UI rename only; the underlying
// command stays `connect` for script + save/restore stability,
// with `plug` registered as an alias so the palette finds it).
if (e == Event::Character("c")) { screen_idx = 0; return true; }
if (e == Event::Character("p")) { Dispatch("connect"); return true; }
if (e == Event::Character("s")) { Dispatch("search"); return true; }
if (e == Event::Character("t")) { Dispatch("set-type"); return true; }
if (e == Event::Character("e")) { Dispatch("explore"); return true; }
if (e == Event::Character("n")) { Dispatch("net"); return true; }
// [a]nalyze is the unified verify + analyze screen (issues +
// groups + types). The textual `verify` and `analyze` commands
// still exist for scripts.
if (e == Event::Character("a")) { screen_idx = 7; return true; }
return false;
case 5: // net
if (e == Event::Escape) { screen_idx = 0; return true; }
if (e == Event::Escape) { screen_idx = 6; return true; }
if (e == Event::Tab) { net_focus_idx = (net_focus_idx + 1) % 3; return true; }
if (e == Event::TabReverse) { net_focus_idx = (net_focus_idx + 2) % 3; return true; }
return false;
case 4: // explore
if (e == Event::Escape) { screen_idx = 0; return true; }
if (e == Event::Escape) { screen_idx = 6; return true; }
if (e == Event::Tab) { explore_focus_idx = (explore_focus_idx + 1) % 6; return true; }
if (e == Event::TabReverse) { explore_focus_idx = (explore_focus_idx + 5) % 6; return true; }
return false;
case 3: // set-type
if (e == Event::Escape) { screen_idx = 0; return true; }
if (e == Event::Escape) { screen_idx = 6; return true; }
if (e == Event::Tab) { settype_focus_idx = (settype_focus_idx + 1) % 5; return true; }
if (e == Event::TabReverse) { settype_focus_idx = (settype_focus_idx + 4) % 5; return true; }
return false;
case 2: // connect
if (e == Event::Escape) { screen_idx = 0; return true; }
if (e == Event::Escape) { screen_idx = 6; return true; }
if (e == Event::Tab) { connect_focus_idx = (connect_focus_idx + 1) % 7; return true; }
if (e == Event::TabReverse) { connect_focus_idx = (connect_focus_idx + 6) % 7; return true; }
return false;
case 1: // search
if (e == Event::Escape) { screen_idx = 0; return true; }
if (e == Event::Escape) { screen_idx = 6; return true; }
if (e == Event::Tab) { search_focus_idx = (search_focus_idx + 1) % 3; return true; }
if (e == Event::TabReverse) { search_focus_idx = (search_focus_idx + 2) % 3; return true; }
return false;
default: // main
default: // main (shell / log view)
if (e == Event::Special("\x02tick")) { ProcessNextSourceLine(); return true; }
if (e == Event::Escape && !pending.empty()) { CancelPending(); return true; }
if (e == Event::Escape) {
if (!pending.empty()) { CancelPending(); return true; }
screen_idx = 6; return true;
}
if (e == Event::PageUp) { scroll_offset += 10; return true; }
if (e == Event::PageDown) { scroll_offset = std::max(0, scroll_offset - 10); return true; }
if (e == Event::Home) { scroll_offset = (int)output.size(); return true; }

View File

@@ -110,6 +110,28 @@ class Tui {
int net_sig_idx;
int net_focus_idx;
// ---- Dashboard scroll state (0 = top; grows as the user scrolls down) ----
int dashboard_scroll_offset = 0;
// ---- Analyze screen state (unified verify + analyze) ----
int analyze_focus_idx = 0; ///< 0=issues 1=groups 2=types
std::vector<std::string> analyze_issues;
std::vector<std::string> analyze_groups;
std::vector<std::string> analyze_types;
int analyze_issue_idx = 0;
int analyze_group_idx = 0;
int analyze_type_idx = 0;
// ---- Command palette (global, fuzzy-find over commands + objects) ----
bool palette_open = false;
std::string palette_query;
int palette_idx = 0;
// Rebuilt every frame from <query, sys>: label shown to the user.
std::vector<std::string> palette_labels;
// Parallel kind/payload for each label. kind: 'c'=command, 'm'=module,
// 's'=signal. payload: command name / module name / "module\tsignal".
std::vector<std::pair<char, std::string>> palette_items;
// ---- Signal-type popup (shared between net + explore screens) ----
bool sigtype_dialog_open = false;
std::string sigtype_dialog_mod;
@@ -180,7 +202,14 @@ private:
ftxui::Component BuildSettypeScreen();
ftxui::Component BuildExploreScreen();
ftxui::Component BuildNetScreen();
ftxui::Component BuildDashboardScreen();
ftxui::Component BuildAnalyzeScreen();
ftxui::Component BuildSignalTypeModal();
ftxui::Component BuildPaletteModal();
// Open palette (resets query/index, builds initial list).
void OpenPalette();
// Execute the currently-highlighted palette entry.
void ActivatePaletteEntry();
};
#endif // _TUI_HPP_

View File

@@ -1,8 +1,30 @@
#include "tui/tui_helpers.hpp"
#include <ftxui/dom/elements.hpp>
#include <algorithm>
#include <cctype>
using namespace ftxui;
Element RenderHelpPanel(const std::string &title,
const std::vector<HelpEntry> &entries) {
// Key column wide enough for "Ctrl-P", with a 2-char gutter.
const int KEY_W = 9;
Elements rows;
for (const auto &e : entries) {
rows.push_back(hbox({
text(" " + e.key) | bold | size(WIDTH, EQUAL, KEY_W),
text(e.desc) | flex,
}));
}
return vbox({
text(" " + title + " ") | bold,
separator(),
vbox(std::move(rows)),
}) | size(WIDTH, EQUAL, 30);
}
std::string ToLower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),

View File

@@ -16,6 +16,17 @@ inline ftxui::Element FocusLabel(ftxui::Element e, bool focused) {
std::string ToLower(std::string s);
// ---- Context help panel (right-column on every screen) ----
struct HelpEntry {
std::string key; ///< Key or chord label ("Tab", "Enter", "Ctrl-P", "s").
std::string desc; ///< Short description of what it does.
};
// Renders a vertical help column: bold title, separator, then a two-column
// list of (key, desc). Fixed width so the layout is consistent.
ftxui::Element RenderHelpPanel(const std::string &title,
const std::vector<HelpEntry> &entries);
// Case-insensitive natural-order comparison: digit runs compared as integers,
// letters compared after std::tolower.
bool NaturalLess(const std::string &a, const std::string &b);

View File

@@ -50,6 +50,67 @@ TEST_CASE("analyze detects diff pairs and reports `_P` orphans only") {
CHECK(orphans == 1); // only PCIE_RX_P
}
TEST_CASE("analyze aggregates diff pairs into a diff bus by trailing index") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
add_signal(m, p, "MDI0_P"); add_signal(m, p, "MDI0_N");
add_signal(m, p, "MDI1_P"); add_signal(m, p, "MDI1_N");
add_signal(m, p, "MDI2_P"); add_signal(m, p, "MDI2_N");
add_signal(m, p, "MDI3_P"); add_signal(m, p, "MDI3_N");
// Underscore-separated index variant.
add_signal(m, p, "PCIE_TX_0_P"); add_signal(m, p, "PCIE_TX_0_N");
add_signal(m, p, "PCIE_TX_1_P"); add_signal(m, p, "PCIE_TX_1_N");
// A lonely pair under a bus-able stem (only one index) must stay DiffPair.
add_signal(m, p, "USB3_TX_P"); add_signal(m, p, "USB3_TX_N");
AnalysisReport r = analyze_system(sys.get());
int dp = 0, db = 0;
bool mdi_found = false, pcie_found = false;
for (const auto &g : r.groups) {
if (g.kind == GroupKind::DiffPair) ++dp;
if (g.kind == GroupKind::DiffBus) ++db;
if (g.kind == GroupKind::DiffBus && g.label.find("MDI[") == 0) {
mdi_found = true;
CHECK(g.lo == 0);
CHECK(g.hi == 3);
CHECK(g.members.size() == 8); // 4 pairs × 2 signals
}
if (g.kind == GroupKind::DiffBus && g.label.find("PCIE_TX_[") == 0) {
pcie_found = true;
CHECK(g.lo == 0);
CHECK(g.hi == 1);
CHECK(g.members.size() == 4);
}
}
CHECK(db == 2);
CHECK(dp == 1); // USB3_TX kept as solo DiffPair (single index — degenerate bus)
CHECK(mdi_found);
CHECK(pcie_found);
}
TEST_CASE("analyze flags a diff bus with a missing lane") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
add_signal(m, p, "LANE0_P"); add_signal(m, p, "LANE0_N");
add_signal(m, p, "LANE1_P"); add_signal(m, p, "LANE1_N");
// LANE2 missing
add_signal(m, p, "LANE3_P"); add_signal(m, p, "LANE3_N");
AnalysisReport r = analyze_system(sys.get());
int gaps = 0;
for (const auto &a : r.anomalies)
if (a.kind == AnomalyKind::DiffBusGap) ++gaps;
CHECK(gaps == 1);
}
TEST_CASE("analyze detects buses with bracketed and underscore forms") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
@@ -160,6 +221,60 @@ TEST_CASE("analyze on empty / null system") {
CHECK(r.anomalies.empty());
}
TEST_CASE("infer_signal_types: Power requires name+structural agreement") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
auto fan_out = [&](Signal *s, int n) {
for (int i = 0; i < n; ++i) {
Pin *pin = new Pin(s->name + "_" + std::to_string(i));
p->add(pin); s->add(pin); pin->connect(s);
}
};
Signal *p_3v3 = m->signals->merge("PWR_3V3"); fan_out(p_3v3, 3); // voltage + ≥ floor → Power
Signal *vcc = m->signals->merge("VCC"); fan_out(vcc, 5); // fan-out ≥ 4 → Power
Signal *pwr_ok = m->signals->merge("PWR_OK"); fan_out(pwr_ok, 1); // < 3 → hard floor → Other
Signal *pwr_2 = m->signals->merge("PWR_2"); fan_out(pwr_2, 2); // < 3 → hard floor → Other
Signal *gnd = m->signals->merge("GND"); fan_out(gnd, 1); // gnd: name alone
Signal *clk = m->signals->merge("CLK_50MHZ"); fan_out(clk, 3); // not power-ish → Other
auto st = infer_signal_types(sys.get());
CHECK(st.power == 2); // PWR_3V3, VCC
CHECK(st.gnd == 1); // GND (name alone)
CHECK(st.kept_other == 2); // PWR_OK, PWR_2 below the hard floor
CHECK(p_3v3->type == SignalType::Power);
CHECK(vcc->type == SignalType::Power);
CHECK(gnd->type == SignalType::GndShield);
CHECK(pwr_ok->type == SignalType::Other);
CHECK(pwr_2->type == SignalType::Other);
CHECK(clk->type == SignalType::Other);
}
TEST_CASE("infer_signal_types: fan-out hard floor overrides voltage in name") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
// VS_3V3 has a voltage pattern that would normally confirm Power, but
// with only 2 pins it must still drop to Other because of the hard floor.
Signal *s = m->signals->merge("VS_3V3");
Pin *p1 = new Pin("p1"); p->add(p1); s->add(p1); p1->connect(s);
Pin *p2 = new Pin("p2"); p->add(p2); s->add(p2); p2->connect(s);
auto st = infer_signal_types(sys.get());
CHECK(st.power == 0);
CHECK(st.kept_other == 1);
CHECK(s->type == SignalType::Other);
}
TEST_CASE("Signal ctor defaults type to Other (no auto-inference)") {
Signal s("VCC");
CHECK(s.type == SignalType::Other);
}
TEST_CASE("analyze scopes detection per module (no cross-module merge)") {
auto sys = std::make_unique<System>();
Module *m1 = sys->modules()->merge("M1");

View File

@@ -98,15 +98,17 @@ TEST_CASE("save+restore round-trip preserves modules, parts, types, signals, NC"
}
TEST_CASE("save+restore preserves signal type overrides") {
// The Signal ctor defaults to Other; promotion to Power/Gnd is the job
// of `infer_signal_types(sys)` at load time. Here we just set the types
// explicitly to assert that save/restore round-trips them.
auto sys = make_fixture();
// Force a non-default override on a signal that auto-infers as Other.
Signal *vcc = sys->modules()->get("BKP")->signals->get("VCC");
REQUIRE(vcc);
CHECK(vcc->type == SignalType::Power); // auto-detected from "VCC"
vcc->type = SignalType::Power;
Signal *gnd = sys->modules()->get("BKP")->signals->get("GND");
REQUIRE(gnd);
CHECK(gnd->type == SignalType::GndShield);
gnd->type = SignalType::GndShield;
std::string path = tmp_path("essim_signal_type.txt");
std::string err;