Interactive net screen, main + per-screen title bars, focus highlight, help split.

- New `net` full-screen layout (`screen_net.cpp`, `screen_idx = 5`): three
  columns (module menu / signal filter + menu / live BFS result). Bare
  `net` opens the screen; `net <m> <s>` keeps the inline path.
- Main screen grows a title bar: " essim — system digital twin "
  (bold + dim) on the left, live "N module(s), M connection(s)" on
  the right.
- Every interactive screen now renders the same breadcrumb at the top:
  " essim → <name>  — <short description> ", followed by a separator.
- `tui_helpers.hpp` exports `FocusLabel(elem, focused)`. Every
  interactive screen wraps its field labels with it so the active
  field's label flips to inverted video. Buttons (Connect, Apply)
  invert as a whole.
- `CommandSpec` gains a `bool interactive`. `help` (no args) splits
  the listing into "Interactive (open a full-screen mode)" and
  "Other". `help <name>` tags interactive entries with [interactive]
  and explains the bare-vs-inline duality.
- `DESIGN.md` (renamed from `CLAUDE.md`): refreshed Layout, TUI, and
  screen-recipe sections to cover the new field, the title idiom,
  FocusLabel, the `net` screen, and the event-paced `Computing…`
  modal during `source`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 07:52:22 +02:00
parent c3bb00cb4d
commit fe2dc13c89
11 changed files with 309 additions and 38 deletions

View File

@@ -53,6 +53,7 @@ src/
screen_connect.cpp BuildConnectScreen + shared RefreshFilteredPartList helper screen_connect.cpp BuildConnectScreen + shared RefreshFilteredPartList helper
screen_settype.cpp BuildSettypeScreen screen_settype.cpp BuildSettypeScreen
screen_explore.cpp BuildExploreScreen (browse modules → parts/signals/connections → details; not scriptable) screen_explore.cpp BuildExploreScreen (browse modules → parts/signals/connections → details; not scriptable)
screen_net.cpp BuildNetScreen (BFS over connections from a starting (module, signal))
doc/classes.puml -- PlantUML class diagram doc/classes.puml -- PlantUML class diagram
``` ```
@@ -74,7 +75,7 @@ doc/classes.puml -- PlantUML class diagram
- `CatchEvent` is wrapped **outside** the `Renderer` (not between `Renderer` and `Input`). Pattern: `Renderer(input_component, lambda)` then `CatchEvent(renderer, handler)`. Wrapping `CatchEvent(input, …)` inside a `Container::Vertical({…})` and then `Renderer(container, …)` was found to break the `Input`'s content rendering on at least one terminal — typed characters didn't show even though `on_enter` fired correctly. - `CatchEvent` is wrapped **outside** the `Renderer` (not between `Renderer` and `Input`). Pattern: `Renderer(input_component, lambda)` then `CatchEvent(renderer, handler)`. Wrapping `CatchEvent(input, …)` inside a `Container::Vertical({…})` and then `Renderer(container, …)` was found to break the `Input`'s content rendering on at least one terminal — typed characters didn't show even though `on_enter` fired correctly.
- `InputOption::transform` is overridden to avoid hard-coded colors (default sets `White on Black` when focused, which is unreadable on light terminal themes). The custom transform only applies `dim` to the placeholder; everything else inherits terminal default fg/bg, so the UI adapts to any theme. - `InputOption::transform` is overridden to avoid hard-coded colors (default sets `White on Black` when focused, which is unreadable on light terminal themes). The custom transform only applies `dim` to the placeholder; everything else inherits terminal default fg/bg, so the UI adapts to any theme.
- `InputOption::multiline` **must** be set to `false` for the command prompt. Default is `true`, and FTXUI's `HandleReturn()` then both inserts `\n` into the content **and** fires `on_enter` — so `Submit()` would receive `"help\n"` instead of `"help"` and command lookups would all fall through to "unknown command". - `InputOption::multiline` **must** be set to `false` for the command prompt. Default is `true`, and FTXUI's `HandleReturn()` then both inserts `\n` into the content **and** fires `on_enter` — so `Submit()` would receive `"help\n"` instead of `"help"` and command lookups would all fall through to "unknown command".
- Commands live in a `std::map<std::string, CommandSpec>` registry built in `RegisterCommands()`. Each `CommandSpec` carries `params` (list of `Param{name, completion}` where `completion` is the `Tui::Completion` enum: `None | Path | Command`), an `action(args)` lambda, a `prompt_for_missing` bool (default `true`), a one-line `description` string, and a `scriptable` bool (default `true`). Setting `scriptable = false` (e.g. `explore`) excludes the command from the script-save buffer; if a non-scriptable command is invoked from a sourced file the `Source` loop still aborts via the `screen_idx != 0` check, since these commands are typically screen-openers. `Dispatch(raw)` tokenises the input (whitespace split with `"…"` quoting), takes inline args first, and (only when `prompt_for_missing`) pushes a `Prompt` onto the queue for each missing param. The last prompt's callback calls `Finalize()`. Set `prompt_for_missing = false` for commands that accept either zero args or a full arg list (e.g. `search`: bare → interactive, full → inline) — the action gets called immediately with whatever it received and decides what to do. - Commands live in a `std::map<std::string, CommandSpec>` registry built in `RegisterCommands()`. Each `CommandSpec` carries `params` (list of `Param{name, completion}` where `completion` is the `Tui::Completion` enum: `None | Path | Command`), an `action(args)` lambda, a `prompt_for_missing` bool (default `true`), a one-line `description` string, a `scriptable` bool (default `true`), and an `interactive` bool (default `false`) that flags screen-opening commands. Setting `scriptable = false` (e.g. `explore`) excludes the command from the script-save buffer; if a non-scriptable command is invoked from a sourced file the `Source` loop still aborts via the `screen_idx != 0` check, since these commands are typically screen-openers. `interactive = true` is consumed only by `help`: the listing splits commands into two sections (`Interactive (open a full-screen mode)` and `Other`), and `help <name>` adds an `[interactive]` tag plus a note about the bare-vs-inline duality. `Dispatch(raw)` tokenises the input (whitespace split with `"…"` quoting), takes inline args first, and (only when `prompt_for_missing`) pushes a `Prompt` onto the queue for each missing param. The last prompt's callback calls `Finalize()`. Set `prompt_for_missing = false` for commands that accept either zero args or a full arg list (e.g. `search`: bare → interactive, full → inline) — the action gets called immediately with whatever it received and decides what to do.
- `Tab` completes by `Param::completion`: `Path` triggers `CompletePath()` (filesystem listing with `~/` expansion), `Command` triggers `CompleteCommand()` (matches against the registry), `None` does nothing. Both work inline (`CompleteInline()` figures out the arg position) and inside a multi-step prompt. - `Tab` completes by `Param::completion`: `Path` triggers `CompletePath()` (filesystem listing with `~/` expansion), `Command` triggers `CompleteCommand()` (matches against the registry), `None` does nothing. Both work inline (`CompleteInline()` figures out the arg position) and inside a multi-step prompt.
- `help` (no args) iterates the registry and prints `name — description` for every command. `help <command>` describes a single command, including each `Param`'s name. The argument has `Completion::Command`, so `help <Tab>` lists registered commands. - `help` (no args) iterates the registry and prints `name — description` for every command. `help <command>` describes a single command, including each `Param`'s name. The argument has `Completion::Command`, so `help <Tab>` lists registered commands.
- `Finalize()` rebuilds the **canonical inline form** (`name arg1 "arg with spaces" arg3`) and writes that to history — so a `load` command answered via interactive prompts still shows up in `history` (and on disk) as a single inline line, ready to be replayed via ↑. - `Finalize()` rebuilds the **canonical inline form** (`name arg1 "arg with spaces" arg3`) and writes that to history — so a `load` command answered via interactive prompts still shows up in `history` (and on disk) as a single inline line, ready to be replayed via ↑.
@@ -117,7 +118,13 @@ Pending prompts (from incomplete inline commands) are NOT considered interactive
**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. **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. 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: 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 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.
**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. **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.
@@ -129,12 +136,18 @@ The connect screen rebuilds the filtered part lists inside the `Renderer` lambda
The Connection record stores `Module*`/`Part*` for both endpoints (added to `Connection` for this — minimal struct fields, no behaviour change). The Connection record stores `Module*`/`Part*` for both endpoints (added to `Connection` for this — minimal struct fields, no behaviour change).
`search` switches to a second full-screen layout (handled by `Container::Tab({main, search}, &screen_idx)`). Layout: `search` switches to a second full-screen layout (handled by `Container::Tab({main, search, …}, &screen_idx)`). Layout:
- Left column: `Menu` for the module list and `Menu` for the type (`parts` / `signals`). - Left column: `Menu` for the module list and `Menu` for the type (`parts` / `signals`).
- Right column: `Input` for the live filter query, plus a results panel rebuilt every frame. - Right column: `Input` for the live filter query, plus a results panel rebuilt every frame.
- `Tab` cycles focus between the query input and the menus. **Implemented manually** in the outer `CatchEvent`: `Menu::OnEvent` consumes `Event::Tab` to cycle its own entries and returns `true`, which prevents `Container::Vertical` from ever seeing the event (Container only cycles between children when the active child returns `false`). So we short-circuit Tab/TabReverse upstream and mutate `search_focus_idx` directly. - `Tab` cycles focus between the query input and the menus. **Implemented manually** in the outer `CatchEvent`: `Menu::OnEvent` consumes `Event::Tab` to cycle its own entries and returns `true`, which prevents `Container::Vertical` from ever seeing the event (Container only cycles between children when the active child returns `false`). So we short-circuit Tab/TabReverse upstream and mutate `search_focus_idx` directly.
- `Esc` exits the search mode (flips `screen_idx` back to 0). The search state (selected module/type, query) is preserved across re-entries until `search` is run again. - `Esc` exits the search mode (flips `screen_idx` back to 0). The search state (selected module/type, query) is preserved across re-entries until `search` is run again.
`net` is dual-mode (`prompt_for_missing = false`, `interactive = true`):
- Inline: `net <module> <signal>` — prints the BFS-reached `(module, signal)` set in the visualisation area (with types and an `[INCONSISTENT]` flag).
- Bare: opens `screen_idx = 5`. Three columns: module `Menu` (left), filter `Input` + filtered signal `Menu` of the selected module (middle), and a read-only panel (right) that recomputes the net on every frame and lists `(module, signal, type)` for each member plus a header summarising count + dominant type + inconsistency flag. The signal list is sorted with `NaturalLess`; `net_sig_idx` is clamped if the filter shrinks it. `Tab` cycles 3 fields (filter → module → signal); `Esc` leaves.
**Long-running scripts (`source`)**: `Source(file)` is event-paced. It reads all lines, sets `loading = true`, then spawns a detached pacing thread that posts `Event::Special("\x02tick")` every ~30 ms — **but only one tick at a time**: the main thread acks each one (`tick_in_flight = false` at the end of `ProcessNextSourceLine`) before the ticker sleeps and posts the next. Without this, FTXUI batches all queued events into one render pass; a long line (e.g. a heavy Mentor parse) would let the ticker queue many ticks and the modal would freeze. Each tick triggers `ProcessNextSourceLine`, which processes one effective line (skipping comments/blanks) via `Submit()`. A centred `borderDouble` modal (`" Computing… "` + filename + `N / M lines`) is overlaid via `dbox` while `loading` is true.
Adding a new screen mode = drop a `screen_<name>.cpp` defining `Component Tui::BuildXxxScreen()`, add corresponding state members in `tui.hpp`, register a `BuildXxxScreen` declaration in the screen-builders block, then in `tui.cpp::Run()` add a child to `Container::Tab` and a case in the screen-mode `switch` of the outer `CatchEvent` (Tab cycling + Esc-leave). Commands lived in `commands.cpp` set `screen_idx` to enter the new mode. Adding a new screen mode = drop a `screen_<name>.cpp` defining `Component Tui::BuildXxxScreen()`, add corresponding state members in `tui.hpp`, register a `BuildXxxScreen` declaration in the screen-builders block, then in `tui.cpp::Run()` add a child to `Container::Tab` and a case in the screen-mode `switch` of the outer `CatchEvent` (Tab cycling + Esc-leave). Commands lived in `commands.cpp` set `screen_idx` to enter the new mode.
Command history is persisted on disk and loaded on startup. Path resolution is platform-aware: Command history is persisted on disk and loaded on startup. Path resolution is platform-aware:
@@ -154,3 +167,5 @@ Each successful submission appends a single line to the file (so a crash doesn't
## Memory layout for sessions ## Memory layout for sessions
Persistent notes live in `~/.claude/projects/-home-francois-Projets-essim/memory/`. Index: `MEMORY.md`. Add project/feedback/user/reference entries there when relevant — see top-level Claude Code memory rules. Persistent notes live in `~/.claude/projects/-home-francois-Projets-essim/memory/`. Index: `MEMORY.md`. Add project/feedback/user/reference entries there when relevant — see top-level Claude Code memory rules.
This file is `DESIGN.md` (renamed from the original `CLAUDE.md`).

View File

@@ -25,15 +25,23 @@ void Tui::RegisterCommands() {
{{"command name (optional)", Completion::Command}}, {{"command name (optional)", Completion::Command}},
[this](const std::vector<std::string> &args) { [this](const std::vector<std::string> &args) {
if (args.empty()) { if (args.empty()) {
Print("Commands — type `help <name>` for details.");
size_t maxw = 0; size_t maxw = 0;
for (const auto &kv : commands) maxw = std::max(maxw, kv.first.size()); for (const auto &kv : commands) maxw = std::max(maxw, kv.first.size());
auto print_group = [&](const std::string &title, bool want_interactive) {
bool printed_any = false;
for (const auto &kv : commands) { for (const auto &kv : commands) {
if (kv.second.interactive != want_interactive) continue;
if (!printed_any) { Print(title); printed_any = true; }
Print(" " + kv.first Print(" " + kv.first
+ std::string(maxw - kv.first.size() + 2, ' ') + std::string(maxw - kv.first.size() + 2, ' ')
+ kv.second.description); + kv.second.description);
} }
Print("Keys: Esc cancels a multi-step prompt; Tab completes commands or paths;"); };
Print("Commands — type `help <name>` for details.");
print_group("Interactive (open a full-screen mode):", true);
print_group("Other:", false);
Print("Keys: Esc cancels a multi-step prompt or leaves an interactive screen;");
Print(" Tab completes commands/paths or cycles focus in interactive screens;");
Print(" PageUp/PageDown scroll output (10 lines), Home/End jump to top/bottom."); Print(" PageUp/PageDown scroll output (10 lines), Home/End jump to top/bottom.");
return; return;
} }
@@ -41,7 +49,8 @@ void Tui::RegisterCommands() {
auto it = commands.find(name); auto it = commands.find(name);
if (it == commands.end()) { Print("unknown command: " + name); return; } if (it == commands.end()) { Print("unknown command: " + name); return; }
const auto &spec = it->second; const auto &spec = it->second;
Print(name + " " + spec.description); std::string tag = spec.interactive ? " [interactive]" : "";
Print(name + tag + "" + spec.description);
if (spec.params.empty()) { if (spec.params.empty()) {
Print(" no arguments."); Print(" no arguments.");
} else { } else {
@@ -49,8 +58,11 @@ void Tui::RegisterCommands() {
Print(" arg " + std::to_string(i + 1) + ": " + spec.params[i].name); Print(" arg " + std::to_string(i + 1) + ": " + spec.params[i].name);
} }
} }
if (!spec.prompt_for_missing) { if (spec.interactive) {
Print(" run with no args for the interactive form."); Print(" run with no args to open the interactive screen,");
Print(" or with all args for inline (scriptable) execution.");
} else if (!spec.prompt_for_missing) {
Print(" no per-arg prompt — provide all args inline (or use the bare form).");
} else if (!spec.params.empty()) { } else if (!spec.params.empty()) {
Print(" missing args trigger a prompt for each one."); Print(" missing args trigger a prompt for each one.");
} }
@@ -245,6 +257,25 @@ void Tui::RegisterCommands() {
{"signal name", Completion::None}}, {"signal name", Completion::None}},
[this](const std::vector<std::string> &args) { [this](const std::vector<std::string> &args) {
if (!sys) { Print("no system: run 'new' first."); return; } if (!sys) { Print("no system: run 'new' first."); return; }
if (args.empty()) {
net_modules.clear();
for (auto &m : *sys->modules()) net_modules.push_back(m.first);
std::sort(net_modules.begin(), net_modules.end(), NaturalLess);
if (net_modules.empty()) { Print("no modules loaded."); return; }
net_module_idx = 0;
net_sig_filter.clear();
net_sig_idx = 0;
net_focus_idx = 0;
screen_idx = 5;
return;
}
if (args.size() != 2) {
Print("usage: net <module> <signal> (or no args for interactive)");
return;
}
Module *mod; Module *mod;
try { mod = sys->modules()->get(args[0]); } try { mod = sys->modules()->get(args[0]); }
catch (const std::exception &) { catch (const std::exception &) {
@@ -267,8 +298,11 @@ void Tui::RegisterCommands() {
+ " (" + signal_type_name(mp.second->type) + ")"); + " (" + signal_type_name(mp.second->type) + ")");
} }
}, },
/*prompt_for_missing=*/ true, /*prompt_for_missing=*/ false,
"show all signals reachable from <module>/<signal> through connections", "show all signals reachable from <module>/<signal> through connections "
"(interactive screen if no args)",
/*scriptable=*/ true,
/*interactive=*/ true,
}; };
commands["set-signal-type"] = { commands["set-signal-type"] = {
@@ -364,6 +398,8 @@ void Tui::RegisterCommands() {
}, },
/*prompt_for_missing=*/ false, /*prompt_for_missing=*/ false,
"tag a part's connector type for transform lookup", "tag a part's connector type for transform lookup",
/*scriptable=*/ true,
/*interactive=*/ true,
}; };
commands["connect"] = { commands["connect"] = {
@@ -498,6 +534,8 @@ void Tui::RegisterCommands() {
}, },
/*prompt_for_missing=*/ false, /*prompt_for_missing=*/ false,
"connect a part across two modules (interactive screen if no args)", "connect a part across two modules (interactive screen if no args)",
/*scriptable=*/ true,
/*interactive=*/ true,
}; };
commands["explore"] = { {}, [this](auto &) { commands["explore"] = { {}, [this](auto &) {
@@ -515,7 +553,8 @@ void Tui::RegisterCommands() {
explore_focus_idx = 0; explore_focus_idx = 0;
screen_idx = 4; screen_idx = 4;
}, true, "browse modules → parts/signals/connections → details (interactive)", }, true, "browse modules → parts/signals/connections → details (interactive)",
/*scriptable=*/ false }; /*scriptable=*/ false,
/*interactive=*/ true };
commands["search"] = { commands["search"] = {
{{"module", Completion::None}, {{"module", Completion::None},
@@ -575,6 +614,8 @@ void Tui::RegisterCommands() {
}, },
/*prompt_for_missing=*/ false, /*prompt_for_missing=*/ false,
"list parts/signals matching a pattern (interactive screen if no args)", "list parts/signals matching a pattern (interactive screen if no args)",
/*scriptable=*/ true,
/*interactive=*/ true,
}; };
commands["duplicate"] = { commands["duplicate"] = {

View File

@@ -120,26 +120,39 @@ Component Tui::BuildConnectScreen() {
connect_p2_filter, connect_p2_list, connect_p2_idx); connect_p2_filter, connect_p2_list, connect_p2_idx);
auto col = [&](const std::string &title, auto col = [&](const std::string &title,
Component mm, Component pf, Component pm) { Component mm, Component pf, Component pm,
int m_focus, int f_focus, int p_focus) {
return vbox({ return vbox({
text(title) | bold, text(title) | bold,
text("module") | dim, FocusLabel(text(" module "), connect_focus_idx == m_focus) | dim,
mm->Render() | yframe | size(HEIGHT, LESS_THAN, 8), mm->Render() | yframe | size(HEIGHT, LESS_THAN, 8),
separator(), separator(),
hbox({text(" filter: "), pf->Render() | flex}) | border, hbox({FocusLabel(text(" filter: "), connect_focus_idx == f_focus),
text("part") | dim, pf->Render() | flex}) | border,
FocusLabel(text(" part "), connect_focus_idx == p_focus) | dim,
pm->Render() | yframe | flex, pm->Render() | yframe | flex,
}) | flex; }) | flex;
}; };
auto title = hbox({
text(" essim ") | bold,
text("") | dim,
text("connect") | bold,
text(" — wire two parts across modules (TransformRegistry-driven)") | dim,
});
return vbox({ return vbox({
hbox({ title,
col("endpoint 1", m1_menu, p1_filter, p1_menu),
separator(), separator(),
col("endpoint 2", m2_menu, p2_filter, p2_menu), hbox({
col("endpoint 1", m1_menu, p1_filter, p1_menu, 0, 1, 2),
separator(),
col("endpoint 2", m2_menu, p2_filter, p2_menu, 3, 4, 5),
}) | flex, }) | flex,
separator(), separator(),
hbox({filler(), connect_button->Render(), filler()}), hbox({filler(),
FocusLabel(connect_button->Render(), connect_focus_idx == 6),
filler()}),
text(" Tab: cycle focus | Enter on [Connect]: confirm | Esc: leave ") | dim, text(" Tab: cycle focus | Enter on [Connect]: confirm | Esc: leave ") | dim,
}) | border; }) | border;
}); });

View File

@@ -171,33 +171,49 @@ Component Tui::BuildExploreScreen() {
} }
auto col1 = vbox({ auto col1 = vbox({
text("module") | bold, FocusLabel(text(" module "), explore_focus_idx == 0) | bold,
module_menu->Render() | yframe | flex, module_menu->Render() | yframe | flex,
}) | size(WIDTH, EQUAL, 24); }) | size(WIDTH, EQUAL, 24);
auto col2 = vbox({ auto col2 = vbox({
text("type") | bold, FocusLabel(text(" type "), explore_focus_idx == 1) | bold,
type_menu->Render() | flex, type_menu->Render() | flex,
}) | size(WIDTH, EQUAL, 12); }) | size(WIDTH, EQUAL, 12);
auto col3 = vbox({ auto col3 = vbox({
text(explore_types[explore_type_idx]) | bold, FocusLabel(text(" " + explore_types[explore_type_idx] + " "),
hbox({text(" filter: "), child_filter->Render() | flex}) | border, explore_focus_idx == 3) | bold,
hbox({FocusLabel(text(" filter: "), explore_focus_idx == 2),
child_filter->Render() | flex}) | border,
children_menu->Render() | yframe | flex, children_menu->Render() | yframe | flex,
}) | size(WIDTH, EQUAL, 36); }) | size(WIDTH, EQUAL, 36);
auto col4 = vbox({ auto col4 = vbox({
text(explore_header) | bold, FocusLabel(text(" " + explore_header + " "),
hbox({text(" filter: "), detail_filter->Render() | flex}) | border, explore_focus_idx == 5) | bold,
hbox({FocusLabel(text(" filter: "), explore_focus_idx == 4),
detail_filter->Render() | flex}) | border,
detail_menu->Render() | vscroll_indicator | yframe | flex, detail_menu->Render() | vscroll_indicator | yframe | flex,
}) | flex; }) | flex;
auto title = hbox({
text(" essim ") | bold,
text("") | dim,
text("explore") | bold,
text(" — browse modules → parts/signals/connections → details") | dim,
});
return vbox({ return vbox({
title,
separator(),
hbox({col1, separator(), col2, separator(), col3, separator(), col4}) | flex, hbox({col1, separator(), col2, separator(), col3, separator(), col4}) | flex,
text(" Tab: cycle focus (incl. detail to scroll) | Esc: leave explore ") | dim, text(" Tab: cycle focus (incl. detail to scroll) | Esc: leave explore ") | dim,
}) | border; }) | border;
} catch (const std::exception &e) { } catch (const std::exception &e) {
return vbox({ return vbox({
hbox({text(" essim ") | bold, text("") | dim,
text("explore") | bold}),
separator(),
text("explore: render error") | bold, text("explore: render error") | bold,
text(std::string(" ") + e.what()) | dim, text(std::string(" ") + e.what()) | dim,
text(" Esc: leave explore ") | dim, text(" Esc: leave explore ") | dim,

View File

@@ -1,5 +1,9 @@
#include "tui/tui.hpp" #include "tui/tui.hpp"
#include "system/connect.hpp"
#include "system/modules.hpp"
#include "system/system.hpp"
#include <ftxui/component/component.hpp> #include <ftxui/component/component.hpp>
#include <ftxui/component/component_options.hpp> #include <ftxui/component/component_options.hpp>
#include <ftxui/component/screen_interactive.hpp> #include <ftxui/component/screen_interactive.hpp>
@@ -22,6 +26,18 @@ Component Tui::BuildMainScreen(ScreenInteractive &screen) {
return Renderer(input_component, [this, &screen, input_component] { return Renderer(input_component, [this, &screen, input_component] {
if (quit) screen.Exit(); if (quit) screen.Exit();
std::string subtitle = sys
? std::to_string(sys->modules()->size()) + " module(s), "
+ std::to_string(sys->connections()->size()) + " connection(s)"
: "no system loaded";
auto title = hbox({
text(" essim ") | bold,
text("— system digital twin") | dim,
filler(),
text(subtitle) | dim,
text(" "),
}) | bgcolor(Color::Default);
// Clamp scroll offset to a meaningful range and pick the line to focus. // Clamp scroll offset to a meaningful range and pick the line to focus.
int n = (int)output.size(); int n = (int)output.size();
if (scroll_offset < 0) scroll_offset = 0; if (scroll_offset < 0) scroll_offset = 0;
@@ -50,6 +66,8 @@ Component Tui::BuildMainScreen(ScreenInteractive &screen) {
: ""; : "";
auto base = vbox({ auto base = vbox({
title,
separator(),
view, view,
separator(), separator(),
hbox({text(label), input_component->Render(), filler(), text(status) | dim}), hbox({text(label), input_component->Render(), filler(), text(status) | dim}),

119
src/tui/screen_net.cpp Normal file
View File

@@ -0,0 +1,119 @@
#include "tui/tui.hpp"
#include "tui/tui_helpers.hpp"
#include "system/modules.hpp"
#include "system/nets.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 <exception>
using namespace ftxui;
Component Tui::BuildNetScreen() {
InputOption filter_opt;
filter_opt.multiline = false;
filter_opt.transform = [](InputState s) {
auto el = s.element;
if (s.is_placeholder) el |= dim;
return el;
};
auto filter_input = Input(&net_sig_filter, "filter signals…", filter_opt);
auto module_menu = Menu(&net_modules, &net_module_idx);
auto signal_menu = Menu(&net_sigs, &net_sig_idx);
auto components = Container::Vertical(
{filter_input, module_menu, signal_menu}, &net_focus_idx);
return Renderer(components,
[this, filter_input, module_menu, signal_menu] {
// Rebuild filtered signal list for current module + filter.
net_sigs.clear();
Module *cur_mod = nullptr;
if (sys && !net_modules.empty()) {
try { cur_mod = sys->modules()->get(net_modules[net_module_idx]); }
catch (const std::exception &) {}
}
if (cur_mod) {
std::string needle = ToLower(net_sig_filter);
for (auto &skv : *cur_mod->signals) {
if (needle.empty()
|| ToLower(skv.first).find(needle) != std::string::npos)
net_sigs.push_back(skv.first);
}
std::sort(net_sigs.begin(), net_sigs.end(), NaturalLess);
}
if (net_sig_idx >= (int)net_sigs.size())
net_sig_idx = std::max(0, (int)net_sigs.size() - 1);
// Compute the net for the current selection.
Net net;
SignalType dom = SignalType::Other;
bool consistent = true;
if (cur_mod && !net_sigs.empty()) {
try {
Signal *sig = cur_mod->signals->get(net_sigs[net_sig_idx]);
net = find_net(sys.get(), cur_mod, sig);
consistent = net_type_consistent(net, dom);
} catch (const std::exception &) {}
}
Elements member_lines;
for (const auto &mp : net.members)
member_lines.push_back(text(
" " + mp.first->name + "/" + mp.second->name
+ " (" + signal_type_name(mp.second->type) + ")"));
std::string header;
if (cur_mod && !net_sigs.empty()) {
header = cur_mod->name + "/" + net_sigs[net_sig_idx]
+ "" + std::to_string(net.members.size()) + " signal(s)"
+ (consistent ? "" : " [INCONSISTENT]")
+ " / dominant: " + signal_type_name(dom);
} else {
header = "(select a module + signal)";
}
auto left = vbox({
FocusLabel(text(" module "), net_focus_idx == 1) | bold,
module_menu->Render() | yframe | flex,
}) | size(WIDTH, EQUAL, 24);
auto filter_row = hbox({
FocusLabel(text(" filter: "), net_focus_idx == 0),
filter_input->Render() | flex,
}) | border;
auto middle = vbox({
filter_row,
FocusLabel(text(" signal "), net_focus_idx == 2) | bold,
text(std::to_string(net_sigs.size()) + " signal(s)") | dim,
signal_menu->Render() | yframe | flex,
}) | size(WIDTH, EQUAL, 32);
auto right = vbox({
text(header) | bold,
separator(),
vbox(std::move(member_lines)) | yframe | flex,
}) | flex;
auto title = hbox({
text(" essim ") | bold,
text("") | dim,
text("net") | bold,
text(" — BFS of (module, signal) bridged through connections") | dim,
});
return vbox({
title,
separator(),
hbox({left, separator(), middle, separator(), right}) | flex,
text(" Tab: cycle focus (filter ↔ module ↔ signal) | Esc: leave net ") | dim,
}) | border;
});
}

View File

@@ -62,20 +62,30 @@ Component Tui::BuildSearchScreen() {
text(" " + h.first + " (" + std::to_string(h.second) + " pins)")); text(" " + h.first + " (" + std::to_string(h.second) + " pins)"));
auto left = vbox({ auto left = vbox({
text("module") | bold, FocusLabel(text(" module "), search_focus_idx == 1) | bold,
module_menu->Render() | yframe | flex, module_menu->Render() | yframe | flex,
separator(), separator(),
text("type") | bold, FocusLabel(text(" type "), search_focus_idx == 2) | bold,
type_menu->Render(), type_menu->Render(),
}) | size(WIDTH, EQUAL, 28); }) | size(WIDTH, EQUAL, 28);
auto right = vbox({ auto right = vbox({
hbox({text(" search: "), query_input->Render() | flex}) | border, hbox({FocusLabel(text(" search: "), search_focus_idx == 0),
query_input->Render() | flex}) | border,
text(std::to_string(hits.size()) + " match(es)") | dim, text(std::to_string(hits.size()) + " match(es)") | dim,
vbox(std::move(result_lines)) | yframe | flex, vbox(std::move(result_lines)) | yframe | flex,
}) | flex; }) | flex;
auto title = hbox({
text(" essim ") | bold,
text("") | dim,
text("search") | bold,
text(" — filter parts and signals by pattern") | dim,
});
return vbox({ return vbox({
title,
separator(),
hbox({left, separator(), right}) | flex, hbox({left, separator(), right}) | flex,
text(" Tab: cycle focus | Esc: leave search ") | dim, text(" Tab: cycle focus | Esc: leave search ") | dim,
}) | border; }) | border;

View File

@@ -98,13 +98,14 @@ Component Tui::BuildSettypeScreen() {
else for (const auto &k : known_types) known_line += " " + k; else for (const auto &k : known_types) known_line += " " + k;
auto left = vbox({ auto left = vbox({
text("module") | bold, FocusLabel(text(" module "), settype_focus_idx == 0) | bold,
module_menu->Render() | yframe | flex, module_menu->Render() | yframe | flex,
}) | size(WIDTH, EQUAL, 28); }) | size(WIDTH, EQUAL, 28);
auto middle = vbox({ auto middle = vbox({
hbox({text(" filter: "), part_filter->Render() | flex}) | border, hbox({FocusLabel(text(" filter: "), settype_focus_idx == 1),
text("part") | dim, part_filter->Render() | flex}) | border,
FocusLabel(text(" part "), settype_focus_idx == 2) | dim,
part_menu->Render() | yframe | flex, part_menu->Render() | yframe | flex,
}) | flex; }) | flex;
@@ -112,18 +113,29 @@ Component Tui::BuildSettypeScreen() {
text("current type: ") | bold, text("current type: ") | bold,
text(" " + current), text(" " + current),
separator(), separator(),
text("new type:") | bold, FocusLabel(text(" new type: "), settype_focus_idx == 3) | bold,
hbox({text(" "), type_input->Render() | flex}) | border, hbox({text(" "), type_input->Render() | flex}) | border,
text(known_line) | dim, text(known_line) | dim,
filler(), filler(),
hbox({filler(), button->Render(), filler()}), hbox({filler(),
FocusLabel(button->Render(), settype_focus_idx == 4),
filler()}),
}) | size(WIDTH, EQUAL, 40); }) | size(WIDTH, EQUAL, 40);
Element status = settype_status.empty() Element status = settype_status.empty()
? text("") | dim ? text("") | dim
: text(" " + settype_status) | bold; : text(" " + settype_status) | bold;
auto title = hbox({
text(" essim ") | bold,
text("") | dim,
text("set-type") | bold,
text(" — tag a part with its connector kind (drives transforms + pin roles)") | dim,
});
return vbox({ return vbox({
title,
separator(),
hbox({left, separator(), middle, separator(), right}) | flex, hbox({left, separator(), middle, separator(), right}) | flex,
separator(), separator(),
status, status,

View File

@@ -23,6 +23,7 @@ Tui::Tui()
explore_types{"parts", "signals", "connections"}, explore_types{"parts", "signals", "connections"},
explore_type_idx(0), explore_child_idx(0), explore_type_idx(0), explore_child_idx(0),
explore_detail_idx(0), explore_focus_idx(0), explore_detail_idx(0), explore_focus_idx(0),
net_module_idx(0), net_sig_idx(0), net_focus_idx(0),
settype_m_idx(0), settype_p_idx(0), settype_focus_idx(0) settype_m_idx(0), settype_p_idx(0), settype_focus_idx(0)
{ {
LoadHistory(); LoadHistory();
@@ -41,13 +42,21 @@ void Tui::Run() {
auto connect_screen = BuildConnectScreen(); auto connect_screen = BuildConnectScreen();
auto settype_screen = BuildSettypeScreen(); auto settype_screen = BuildSettypeScreen();
auto explore_screen = BuildExploreScreen(); auto explore_screen = BuildExploreScreen();
auto net_screen = BuildNetScreen();
auto tab = Container::Tab( auto tab = Container::Tab(
{main_screen, search_screen, connect_screen, settype_screen, explore_screen}, {main_screen, search_screen, connect_screen, settype_screen, explore_screen,
net_screen},
&screen_idx); &screen_idx);
auto root = CatchEvent(tab, [this](Event e) { auto root = CatchEvent(tab, [this](Event e) {
switch (screen_idx) { switch (screen_idx) {
case 5: // net
if (e == Event::Escape) { screen_idx = 0; 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 case 4: // explore
if (e == Event::Escape) { screen_idx = 0; return true; } if (e == Event::Escape) { screen_idx = 0; return true; }
if (e == Event::Tab) { explore_focus_idx = (explore_focus_idx + 1) % 6; return true; } if (e == Event::Tab) { explore_focus_idx = (explore_focus_idx + 1) % 6; return true; }

View File

@@ -33,6 +33,7 @@ class Tui {
bool prompt_for_missing = true; bool prompt_for_missing = true;
std::string description; std::string description;
bool scriptable = true; bool scriptable = true;
bool interactive = false; ///< opens a full-screen mode when called bare
}; };
// ---- Shell state ---- // ---- Shell state ----
@@ -99,6 +100,14 @@ class Tui {
bool loading_prev_in_source; bool loading_prev_in_source;
ftxui::ScreenInteractive *screen_ptr; ///< set in Run() so Source() can post events. ftxui::ScreenInteractive *screen_ptr; ///< set in Run() so Source() can post events.
// ---- Net screen state ----
std::vector<std::string> net_modules;
int net_module_idx;
std::string net_sig_filter;
std::vector<std::string> net_sigs; ///< rebuilt every frame from filter
int net_sig_idx;
int net_focus_idx;
// ---- Set-type screen state ---- // ---- Set-type screen state ----
std::vector<std::string> settype_modules; std::vector<std::string> settype_modules;
int settype_m_idx; int settype_m_idx;
@@ -152,6 +161,7 @@ private:
ftxui::Component BuildConnectScreen(); ftxui::Component BuildConnectScreen();
ftxui::Component BuildSettypeScreen(); ftxui::Component BuildSettypeScreen();
ftxui::Component BuildExploreScreen(); ftxui::Component BuildExploreScreen();
ftxui::Component BuildNetScreen();
}; };
#endif // _TUI_HPP_ #endif // _TUI_HPP_

View File

@@ -1,11 +1,19 @@
#ifndef _TUI_HELPERS_HPP_ #ifndef _TUI_HELPERS_HPP_
#define _TUI_HELPERS_HPP_ #define _TUI_HELPERS_HPP_
#include <ftxui/dom/elements.hpp>
#include <string> #include <string>
#include <vector> #include <vector>
// Free helpers shared across the TUI translation units. // Free helpers shared across the TUI translation units.
// Highlight the label of a focused field in interactive screens. Used so the
// user can see at a glance which field will receive their next keystroke.
inline ftxui::Element FocusLabel(ftxui::Element e, bool focused) {
return focused ? (e | ftxui::inverted) : e;
}
std::string ToLower(std::string s); std::string ToLower(std::string s);
// Case-insensitive natural-order comparison: digit runs compared as integers, // Case-insensitive natural-order comparison: digit runs compared as integers,