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

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,19 +141,32 @@ 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({
col("endpoint 1", m1_menu, p1_filter, p1_menu, 0, 1, 2),
vbox({
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,
separator(),
hbox({filler(),
FocusLabel(connect_button->Render(), connect_focus_idx == 6),
filler()}),
}) | flex,
separator(),
col("endpoint 2", m2_menu, p2_filter, p2_menu, 3, 4, 5),
help,
}) | flex,
separator(),
hbox({filler(),
FocusLabel(connect_button->Render(), connect_focus_idx == 6),
filler()}),
text(" Tab: cycle focus | Enter on [Connect]: confirm | Esc: leave ") | dim,
}) | 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(),
view,
separator(),
hbox({text(label), input_component->Render(), filler(), text(status) | dim}),
hbox({
vbox({
view,
separator(),
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),
@@ -37,59 +38,113 @@ void Tui::Run() {
auto screen = ScreenInteractive::Fullscreen();
screen_ptr = &screen;
auto main_screen = BuildMainScreen(screen);
auto search_screen = BuildSearchScreen();
auto connect_screen = BuildConnectScreen();
auto settype_screen = BuildSettypeScreen();
auto explore_screen = BuildExploreScreen() | Modal(BuildSignalTypeModal(),
&sigtype_dialog_open);
auto net_screen = BuildNetScreen() | Modal(BuildSignalTypeModal(),
&sigtype_dialog_open);
auto main_screen = BuildMainScreen(screen);
auto search_screen = BuildSearchScreen();
auto connect_screen = BuildConnectScreen();
auto settype_screen = BuildSettypeScreen();
auto explore_screen = BuildExploreScreen() | Modal(BuildSignalTypeModal(),
&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);