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:
@@ -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;
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
289
src/tui/screen_analyze.cpp
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
317
src/tui/screen_dashboard.cpp
Normal file
317
src/tui/screen_dashboard.cpp
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
205
src/tui/screen_palette.cpp
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user