Export command (CSV + ODS), file dialog, error modal, path persistence.
New user-facing features:
- `export connections <file>` writes a tabular dump of every wire pair:
connection, transform, left/right module/part/pin/signal/type/suspect,
mixed-types flag. Dispatch on extension: `.csv` (flat file) or `.ods`
(one sheet per connection). Any other extension shows an error and
writes nothing.
- Bare `export` (or dashboard `[x]`, or palette `export`) opens an
interactive file-picker dialog with a CSV/ODS toggle at the top.
Picking a filter rewrites the filename's extension. Last-used
directory and filename are remembered per-call-site.
- Two new CLI flags on the binary: `--source FILE` to run a script at
boot, `--restore FILE` to restore a snapshot at boot. Combinable.
Reusable infrastructure:
- `OdsWriter` (`src/imports/ods_writer.{hpp,cpp}`): minimal .ods writer
using libzip + pugixml (already in the build for the importer).
Multi-sheet workbook of string cells. ~180 lines, no new dep.
- Generic file-picker dialog (`screen_filedialog.cpp`): one Modal
reused for any "pick a path" interaction via
`OpenFileDialog(title, persist_key, default_filename, filters, cb)`.
Validates the picked extension against the filter whitelist;
unknown ones stay in the dialog with a status message. Persists
(dir, filename) per `persist_key`.
- Generic error modal (`screen_error.cpp`, `ShowError(msg)`): centred
red-titled popup, dismissable with Esc/Enter. Used by the export
failures (open-for-write, ODS save, unknown extension/kind);
ready for adoption elsewhere.
- Per-key path persistence (`SaveLastUsed`/`LoadLastUsed` in
`shell.cpp`): two-line file per key under the user-data dir.
- `UserDataDir()` extracted from the history path helper so the new
per-key persistence shares the same XDG/AppData logic.
- New help-screen topic "Export"; user-facing `doc/user/analysis.md`
gains an "Exporting" section; `DESIGN.md` gains a generics
section covering the dialog / error modal / persistence / ODS
writer; `DumpCommandsMd` now respects the `hidden` flag (the
`connect` alias no longer appears in the auto-gen reference).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
26
DESIGN.md
26
DESIGN.md
@@ -38,11 +38,12 @@ src/
|
|||||||
analysis.{hpp,cpp} analyze_system → AnalysisReport (diff pairs, buses, anomalies)
|
analysis.{hpp,cpp} analyze_system → AnalysisReport (diff pairs, buses, anomalies)
|
||||||
persist.{hpp,cpp} save / restore (tab-delimited)
|
persist.{hpp,cpp} save / restore (tab-delimited)
|
||||||
system.{hpp,cpp} System: owns Modules + Connections, exposes Load()
|
system.{hpp,cpp} System: owns Modules + Connections, exposes Load()
|
||||||
imports/ -- adapters that populate the domain
|
imports/ -- adapters that populate or emit the domain
|
||||||
import_base.hpp ImportBase interface
|
import_base.hpp ImportBase interface
|
||||||
import_mentor.{hpp,cpp} Mentor Graphics netlist parser
|
import_mentor.{hpp,cpp} Mentor Graphics netlist parser
|
||||||
import_altium.{hpp,cpp} Altium netlist parser (`[ ]` parts, `( )` signals)
|
import_altium.{hpp,cpp} Altium netlist parser (`[ ]` parts, `( )` signals)
|
||||||
import_ods.{hpp,cpp} ODS spreadsheet pinout (libzip + pugixml)
|
import_ods.{hpp,cpp} ODS spreadsheet pinout (libzip + pugixml)
|
||||||
|
ods_writer.{hpp,cpp} Minimal .ods writer (multi-sheet, string cells)
|
||||||
tui/ -- FTXUI shell, split by responsibility
|
tui/ -- FTXUI shell, split by responsibility
|
||||||
tui.{hpp,cpp} Class Tui (state + Run() orchestrator + screen-mode event dispatcher)
|
tui.{hpp,cpp} Class Tui (state + Run() orchestrator + screen-mode event dispatcher)
|
||||||
tui_helpers.{hpp,cpp} Free helpers: ToLower, NaturalLess, LongestCommonPrefix
|
tui_helpers.{hpp,cpp} Free helpers: ToLower, NaturalLess, LongestCommonPrefix
|
||||||
@@ -56,6 +57,9 @@ src/
|
|||||||
screen_dashboard.cpp BuildDashboardScreen (read-only system overview)
|
screen_dashboard.cpp BuildDashboardScreen (read-only system overview)
|
||||||
screen_analyze.cpp BuildAnalyzeScreen (anomalies / groups / power decisions)
|
screen_analyze.cpp BuildAnalyzeScreen (anomalies / groups / power decisions)
|
||||||
screen_palette.cpp BuildPaletteModal (global Ctrl-P fuzzy launcher)
|
screen_palette.cpp BuildPaletteModal (global Ctrl-P fuzzy launcher)
|
||||||
|
screen_filedialog.cpp BuildFileDialog + OpenFileDialog (reusable file picker)
|
||||||
|
screen_error.cpp BuildErrorModal + ShowError (centred error popup)
|
||||||
|
screen_help.cpp BuildHelpScreen (topic-driven feature reference)
|
||||||
screen_sigtype_modal.cpp BuildSignalTypeModal (popup attached to explore via Modal())
|
screen_sigtype_modal.cpp BuildSignalTypeModal (popup attached to explore via Modal())
|
||||||
doc/classes.puml -- PlantUML class diagram
|
doc/classes.puml -- PlantUML class diagram
|
||||||
```
|
```
|
||||||
@@ -158,6 +162,26 @@ Exposed as the `analyze` shell command which prints groups (sorted by module + l
|
|||||||
|
|
||||||
Everything is recomputed every frame so manual overrides via the signal-type popup are reflected immediately. Esc returns to the dashboard. The dashboard's previous `[v]erify` letter shortcut was removed — its content is fully covered by this screen. The textual `verify` / `analyze` commands still exist for scripts.
|
Everything is recomputed every frame so manual overrides via the signal-type popup are reflected immediately. Esc returns to the dashboard. The dashboard's previous `[v]erify` letter shortcut was removed — its content is fully covered by this screen. The textual `verify` / `analyze` commands still exist for scripts.
|
||||||
|
|
||||||
|
**Generic file-picker dialog** (`screen_filedialog.cpp`): one reusable modal for every "pick a path" interaction. State lives in `Tui::file_dialog` (a single `FileDialogState`); attached to the tab tree via `Modal(BuildFileDialog(), &file_dialog.open)` in `Run()`. API:
|
||||||
|
|
||||||
|
- `OpenFileDialog(title, persist_key, default_filename, filters, on_confirm)` — opens the modal, restoring the last-used `(dir, filename)` for `persist_key` if previously saved.
|
||||||
|
- `ConfirmFileDialog()` runs on Enter on the OK button: validates against the filter whitelist (rejects unknown extensions with an in-dialog status message), persists `(dir, filename)` under `persist_key`, closes the modal, then calls `on_confirm(full_path)`.
|
||||||
|
|
||||||
|
The optional `filters` vector (`{label, extension}` pairs) renders a horizontal Toggle at the top of the dialog. The first frame after Open seeds the filter index from the filename's current extension; subsequent index changes rewrite the filename extension so the caller's extension-based dispatch picks the right format. Empty filter list ⇒ no Toggle shown, no extension validation.
|
||||||
|
|
||||||
|
Today the only caller is `export` (`{"CSV", ".csv"}, {"ODS", ".ods"}` filters, key `export.connections`), but future callers (`save`, `restore`, `source`, …) plug in by changing four arguments.
|
||||||
|
|
||||||
|
**Generic error modal** (`screen_error.cpp`, `Tui::ShowError(msg)`): centred `borderRounded` popup with red title, the message (wrapped via `paragraph`), and an OK button. Esc / Enter dismiss. Stacked at the top of the Modal chain in `Run()` so it overlays every screen and every other modal. The error is also `Print()`-ed to the console log, so it remains inspectable after dismissal. Used by the export action (unknown extension, open-for-write failure, ODS save failure, unknown kind); other actions can adopt by replacing user-visible `Print("...failed...")` calls with `ShowError(...)`.
|
||||||
|
|
||||||
|
**Per-key path persistence** (`SaveLastUsed(key, dir, filename)` / `LoadLastUsed(key, &dir, &filename)` in `shell.cpp`): each key writes a tiny two-line file (`dir\nfilename\n`) under `UserDataDir() / <key>.last`. `UserDataDir()` is the cross-platform `XDG_DATA_HOME` / `LOCALAPPDATA` etc. helper also used by the command history file. Free functions, not Tui members, so any module (the file dialog today; could be the script-save buffer or the save command tomorrow) can use them with the same minimal API.
|
||||||
|
|
||||||
|
**ODS writer** (`src/imports/ods_writer.{hpp,cpp}`): minimal OpenDocument Spreadsheet writer. Backed by libzip + pugixml (both already pulled in by the ODS importer). Class hierarchy is two structs:
|
||||||
|
|
||||||
|
- `OdsSheet` — sparse row-major grid of string cells (`set(row, col, value)`).
|
||||||
|
- `OdsWriter` — owns the sheets, emits a valid `.ods` archive with `mimetype` (stored uncompressed, magic header), `META-INF/manifest.xml`, and `content.xml`.
|
||||||
|
|
||||||
|
`content.xml` is built with pugixml: one `<table:table>` per sheet, each row a `<table:table-row>` of string `<table:table-cell>` cells. String-only by design (no numbers, dates, formulas, styles, merges) — the format minimum for "rectangular text data" is concise enough to live in ~200 lines without depending on a heavyweight ODS library.
|
||||||
|
|
||||||
**Command palette** (`screen_palette.cpp`): a global modal launcher attached to the whole tab tree via `tab | Modal(BuildPaletteModal(), &palette_open)` in `Run()`. Trigger: `Event::CtrlP` (FTXUI Input does not consume Ctrl-P, so the outer `CatchEvent` reliably picks it up first). Behaviour: a single Input bound to `palette_query` plus a result list rebuilt on every frame. Indexes three kinds of entries: commands (from the `commands` map), modules and per-module signals (qualified as `module/signal`). Fuzzy match is subsequence-based, case-insensitive: lower score wins, computed as `first_match_position * 100 + sum_of_gaps`. Kinds are biased by a constant offset (commands +0, modules +1000, signals +2000) so command matches come first when scores tie. Output capped at 20 rows to keep render cheap on big systems. Activation (`Enter`): commands → `Dispatch(name)` (which dispatches like the shell, including opening interactive screens), module → prefill `explore_*` state and jump to `screen_idx = 4`, signal → prefill `net_modules` + seed `net_sig_filter` to the exact signal name and jump to `screen_idx = 5`. `Esc` closes the palette. While the palette is open, the outer `CatchEvent` cedes events to it so Tab/Esc/etc. don't leak into the underlying screen.
|
**Command palette** (`screen_palette.cpp`): a global modal launcher attached to the whole tab tree via `tab | Modal(BuildPaletteModal(), &palette_open)` in `Run()`. Trigger: `Event::CtrlP` (FTXUI Input does not consume Ctrl-P, so the outer `CatchEvent` reliably picks it up first). Behaviour: a single Input bound to `palette_query` plus a result list rebuilt on every frame. Indexes three kinds of entries: commands (from the `commands` map), modules and per-module signals (qualified as `module/signal`). Fuzzy match is subsequence-based, case-insensitive: lower score wins, computed as `first_match_position * 100 + sum_of_gaps`. Kinds are biased by a constant offset (commands +0, modules +1000, signals +2000) so command matches come first when scores tie. Output capped at 20 rows to keep render cheap on big systems. Activation (`Enter`): commands → `Dispatch(name)` (which dispatches like the shell, including opening interactive screens), module → prefill `explore_*` state and jump to `screen_idx = 4`, signal → prefill `net_modules` + seed `net_sig_filter` to the exact signal name and jump to `screen_idx = 5`. `Esc` closes the palette. While the palette is open, the outer `CatchEvent` cedes events to it so Tab/Esc/etc. don't leak into the underlying screen.
|
||||||
|
|
||||||
**Dashboard** (`screen_dashboard.cpp`, `dashboard` command, `screen_idx = 4`): read-only system overview. Single Renderer, no Input child. Recomputes everything per frame (cheap on realistic sizes): counters (modules/parts/signals/connections), three health rows (verify pin-role mismatches, bridged-net inconsistencies, NC orphans — green check / yellow warning prefix), an analysis summary line (diff pairs / buses / anomaly count, coloured if non-zero), and a per-module table (parts / signals / `connector_type`-tagged parts). Letter shortcuts handled in the outer `CatchEvent`: `c`=console, `p`=plug (connect), `t`=set-connector-type, `e`=explore, `a`=analyze, `q`=quit. `Esc` is swallowed on the dashboard (home). The dashboard is `interactive = true`, `scriptable = false`; running `dashboard` inside `source` aborts the script.
|
**Dashboard** (`screen_dashboard.cpp`, `dashboard` command, `screen_idx = 4`): read-only system overview. Single Renderer, no Input child. Recomputes everything per frame (cheap on realistic sizes): counters (modules/parts/signals/connections), three health rows (verify pin-role mismatches, bridged-net inconsistencies, NC orphans — green check / yellow warning prefix), an analysis summary line (diff pairs / buses / anomaly count, coloured if non-zero), and a per-module table (parts / signals / `connector_type`-tagged parts). Letter shortcuts handled in the outer `CatchEvent`: `c`=console, `p`=plug (connect), `t`=set-connector-type, `e`=explore, `a`=analyze, `q`=quit. `Esc` is swallowed on the dashboard (home). The dashboard is `interactive = true`, `scriptable = false`; running `dashboard` inside `source` aborts the script.
|
||||||
|
|||||||
@@ -142,3 +142,21 @@ Every classification is advisory. To force a different type:
|
|||||||
|
|
||||||
Overrides survive `save`/`restore` but are recomputed at every
|
Overrides survive `save`/`restore` but are recomputed at every
|
||||||
`load` (i.e. the inference re-runs).
|
`load` (i.e. the inference re-runs).
|
||||||
|
|
||||||
|
## Exporting
|
||||||
|
|
||||||
|
Dump structured data to an external file:
|
||||||
|
|
||||||
|
- `export connections <file>` — every wire pair, one row, with the
|
||||||
|
signals and types on both sides plus a `suspect` flag (name says
|
||||||
|
Power but the structural check disagreed) and a `mixed` flag
|
||||||
|
(both sides typed but disagreeing).
|
||||||
|
- The dashboard's `[x]` shortcut, the `export` command without
|
||||||
|
arguments, or the palette entry `export`: opens an interactive
|
||||||
|
file-picker dialog. A small toggle at the top of the dialog lets
|
||||||
|
you pick the format — **CSV** (flat, one row per wire) or
|
||||||
|
**ODS** (one sheet per connection, opens directly in
|
||||||
|
LibreOffice / Excel). The dialog remembers the directory and
|
||||||
|
filename you used last so the next export resumes from there.
|
||||||
|
- Inline scriptable form: `export connections /path/foo.csv` (or
|
||||||
|
`.ods`). Unknown extensions raise an error popup.
|
||||||
|
|||||||
@@ -13,7 +13,43 @@ history.
|
|||||||
|
|
||||||
## Interactive commands
|
## Interactive commands
|
||||||
|
|
||||||
### `connect` *(interactive)*
|
### `dashboard` *(interactive)*
|
||||||
|
|
||||||
|
open the dashboard (system overview)
|
||||||
|
|
||||||
|
**No arguments.**
|
||||||
|
**Notes**
|
||||||
|
|
||||||
|
- bare form opens an interactive screen; inline form (all args) is scriptable
|
||||||
|
- not recorded by `script-save` and rejected by `source`
|
||||||
|
|
||||||
|
---
|
||||||
|
### `explore` *(interactive)*
|
||||||
|
|
||||||
|
browse modules → parts/signals/connections → details (interactive)
|
||||||
|
|
||||||
|
**No arguments.**
|
||||||
|
**Notes**
|
||||||
|
|
||||||
|
- bare form opens an interactive screen; inline form (all args) is scriptable
|
||||||
|
- not recorded by `script-save` and rejected by `source`
|
||||||
|
|
||||||
|
---
|
||||||
|
### `export` *(interactive)*
|
||||||
|
|
||||||
|
export structured data to CSV (kinds: connections; bare form opens the file-picker dialog)
|
||||||
|
|
||||||
|
**Arguments**
|
||||||
|
|
||||||
|
1. `kind [connections]`
|
||||||
|
2. `filename (.csv)` *(Tab → path completion)*
|
||||||
|
|
||||||
|
**Notes**
|
||||||
|
|
||||||
|
- bare form opens an interactive screen; inline form (all args) is scriptable
|
||||||
|
|
||||||
|
---
|
||||||
|
### `plug` *(interactive)*
|
||||||
|
|
||||||
connect a part across two modules (interactive screen if no args)
|
connect a part across two modules (interactive screen if no args)
|
||||||
|
|
||||||
@@ -29,47 +65,7 @@ connect a part across two modules (interactive screen if no args)
|
|||||||
- bare form opens an interactive screen; inline form (all args) is scriptable
|
- bare form opens an interactive screen; inline form (all args) is scriptable
|
||||||
|
|
||||||
---
|
---
|
||||||
### `explore` *(interactive)*
|
### `set-connector-type` *(interactive)*
|
||||||
|
|
||||||
browse modules → parts/signals/connections → details (interactive)
|
|
||||||
|
|
||||||
**No arguments.**
|
|
||||||
**Notes**
|
|
||||||
|
|
||||||
- bare form opens an interactive screen; inline form (all args) is scriptable
|
|
||||||
- not recorded by `script-save` and rejected by `source`
|
|
||||||
|
|
||||||
---
|
|
||||||
### `net` *(interactive)*
|
|
||||||
|
|
||||||
show all signals reachable from <module>/<signal> through connections (interactive screen if no args)
|
|
||||||
|
|
||||||
**Arguments**
|
|
||||||
|
|
||||||
1. `module`
|
|
||||||
2. `signal name`
|
|
||||||
|
|
||||||
**Notes**
|
|
||||||
|
|
||||||
- bare form opens an interactive screen; inline form (all args) is scriptable
|
|
||||||
|
|
||||||
---
|
|
||||||
### `search` *(interactive)*
|
|
||||||
|
|
||||||
list parts/signals matching a pattern (interactive screen if no args)
|
|
||||||
|
|
||||||
**Arguments**
|
|
||||||
|
|
||||||
1. `module`
|
|
||||||
2. `kind [parts|signals]`
|
|
||||||
3. `pattern`
|
|
||||||
|
|
||||||
**Notes**
|
|
||||||
|
|
||||||
- bare form opens an interactive screen; inline form (all args) is scriptable
|
|
||||||
|
|
||||||
---
|
|
||||||
### `set-type` *(interactive)*
|
|
||||||
|
|
||||||
tag a part's connector type for transform lookup
|
tag a part's connector type for transform lookup
|
||||||
|
|
||||||
@@ -87,6 +83,12 @@ tag a part's connector type for transform lookup
|
|||||||
|
|
||||||
## Other commands
|
## Other commands
|
||||||
|
|
||||||
|
### `analyze`
|
||||||
|
|
||||||
|
detect signal groups (diff pairs, buses) and structural anomalies
|
||||||
|
|
||||||
|
**No arguments.**
|
||||||
|
---
|
||||||
### `clear`
|
### `clear`
|
||||||
|
|
||||||
clear the visualization area
|
clear the visualization area
|
||||||
@@ -115,7 +117,7 @@ leave essim (alias of quit)
|
|||||||
---
|
---
|
||||||
### `help`
|
### `help`
|
||||||
|
|
||||||
show command help (optionally for a specific command)
|
list commands (or `help <name>` for one command's details)
|
||||||
|
|
||||||
**Arguments**
|
**Arguments**
|
||||||
|
|
||||||
|
|||||||
148
src/imports/ods_writer.cpp
Normal file
148
src/imports/ods_writer.cpp
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
#include "ods_writer.hpp"
|
||||||
|
|
||||||
|
#include <pugixml.hpp>
|
||||||
|
#include <zip.h>
|
||||||
|
|
||||||
|
#include <sstream>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
void OdsSheet::set(int row, int col, std::string value) {
|
||||||
|
if (row < 0 || col < 0) return;
|
||||||
|
if ((int)cells_.size() <= row) cells_.resize(row + 1);
|
||||||
|
if ((int)cells_[row].size() <= col) cells_[row].resize(col + 1);
|
||||||
|
cells_[row][col] = std::move(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
int OdsSheet::cols() const {
|
||||||
|
int max = 0;
|
||||||
|
for (const auto &r : cells_)
|
||||||
|
if ((int)r.size() > max) max = (int)r.size();
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string &OdsSheet::cell(int row, int col) const {
|
||||||
|
static const std::string empty;
|
||||||
|
if (row < 0 || row >= (int)cells_.size()) return empty;
|
||||||
|
const auto &r = cells_[row];
|
||||||
|
if (col < 0 || col >= (int)r.size()) return empty;
|
||||||
|
return r[col];
|
||||||
|
}
|
||||||
|
|
||||||
|
OdsSheet *OdsWriter::add_sheet(const std::string &name) {
|
||||||
|
sheets_.emplace_back(name);
|
||||||
|
return &sheets_.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Build the `content.xml` payload for the spreadsheet. Returns the
|
||||||
|
// serialised UTF-8 string. The minimum schema the format requires for
|
||||||
|
// a string-only multi-sheet workbook.
|
||||||
|
std::string build_content_xml(const std::vector<OdsSheet> &sheets) {
|
||||||
|
pugi::xml_document doc;
|
||||||
|
auto decl = doc.append_child(pugi::node_declaration);
|
||||||
|
decl.append_attribute("version") = "1.0";
|
||||||
|
decl.append_attribute("encoding") = "UTF-8";
|
||||||
|
|
||||||
|
auto root = doc.append_child("office:document-content");
|
||||||
|
root.append_attribute("xmlns:office") =
|
||||||
|
"urn:oasis:names:tc:opendocument:xmlns:office:1.0";
|
||||||
|
root.append_attribute("xmlns:table") =
|
||||||
|
"urn:oasis:names:tc:opendocument:xmlns:table:1.0";
|
||||||
|
root.append_attribute("xmlns:text") =
|
||||||
|
"urn:oasis:names:tc:opendocument:xmlns:text:1.0";
|
||||||
|
root.append_attribute("office:version") = "1.2";
|
||||||
|
|
||||||
|
auto body = root.append_child("office:body");
|
||||||
|
auto sheet_root = body.append_child("office:spreadsheet");
|
||||||
|
|
||||||
|
for (const auto &s : sheets) {
|
||||||
|
auto table = sheet_root.append_child("table:table");
|
||||||
|
table.append_attribute("table:name") = s.name().c_str();
|
||||||
|
int rows = s.rows();
|
||||||
|
int cols = s.cols();
|
||||||
|
for (int r = 0; r < rows; ++r) {
|
||||||
|
auto row = table.append_child("table:table-row");
|
||||||
|
for (int c = 0; c < cols; ++c) {
|
||||||
|
auto cell = row.append_child("table:table-cell");
|
||||||
|
cell.append_attribute("office:value-type") = "string";
|
||||||
|
auto p = cell.append_child("text:p");
|
||||||
|
p.append_child(pugi::node_pcdata).set_value(s.cell(r, c).c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::ostringstream oss;
|
||||||
|
doc.save(oss, " ", pugi::format_no_declaration | pugi::format_raw);
|
||||||
|
// pugi's `format_no_declaration` drops the `<?xml…?>` line; we want
|
||||||
|
// it. Re-emit explicitly to control encoding.
|
||||||
|
return std::string(R"(<?xml version="1.0" encoding="UTF-8"?>)" "\n")
|
||||||
|
+ oss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimum META-INF/manifest.xml that satisfies the spec.
|
||||||
|
std::string build_manifest_xml() {
|
||||||
|
return R"(<?xml version="1.0" encoding="UTF-8"?>)" "\n"
|
||||||
|
R"(<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">)" "\n"
|
||||||
|
R"( <manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/>)" "\n"
|
||||||
|
R"( <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>)" "\n"
|
||||||
|
R"(</manifest:manifest>)" "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// libzip wrapper for "add a string as a file with the given name". The
|
||||||
|
// buffer must outlive zip_close, which is why callers keep a vector of
|
||||||
|
// owned strings alive until after the close.
|
||||||
|
bool add_string_entry(zip_t *zip, const std::string &path,
|
||||||
|
const std::string &data, bool store_only,
|
||||||
|
std::string &error) {
|
||||||
|
zip_source_t *src = zip_source_buffer(zip, data.data(),
|
||||||
|
data.size(), 0);
|
||||||
|
if (!src) { error = "zip_source_buffer failed"; return false; }
|
||||||
|
zip_int64_t idx = zip_file_add(zip, path.c_str(), src, ZIP_FL_OVERWRITE);
|
||||||
|
if (idx < 0) {
|
||||||
|
zip_source_free(src);
|
||||||
|
error = std::string("zip_file_add(") + path + "): "
|
||||||
|
+ zip_strerror(zip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (store_only) {
|
||||||
|
// Mandatory for the `mimetype` entry per ODF/OOXML magic-header
|
||||||
|
// convention: stored uncompressed so the package can be
|
||||||
|
// detected by reading the first 80 bytes.
|
||||||
|
zip_set_file_compression(zip, idx, ZIP_CM_STORE, 0);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool OdsWriter::save(const std::string &path, std::string &error) const {
|
||||||
|
int zerr = 0;
|
||||||
|
zip_t *zip = zip_open(path.c_str(), ZIP_CREATE | ZIP_TRUNCATE, &zerr);
|
||||||
|
if (!zip) {
|
||||||
|
zip_error_t err;
|
||||||
|
zip_error_init_with_code(&err, zerr);
|
||||||
|
error = std::string("zip_open(") + path + "): " + zip_error_strerror(&err);
|
||||||
|
zip_error_fini(&err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffers must outlive zip_close — keep them owned here.
|
||||||
|
std::string mimetype = "application/vnd.oasis.opendocument.spreadsheet";
|
||||||
|
std::string manifest = build_manifest_xml();
|
||||||
|
std::string content = build_content_xml(sheets_);
|
||||||
|
|
||||||
|
if (!add_string_entry(zip, "mimetype", mimetype, /*store=*/true, error) ||
|
||||||
|
!add_string_entry(zip, "META-INF/manifest.xml", manifest, /*store=*/false, error) ||
|
||||||
|
!add_string_entry(zip, "content.xml", content, /*store=*/false, error)) {
|
||||||
|
zip_discard(zip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zip_close(zip) != 0) {
|
||||||
|
error = std::string("zip_close: ") + zip_strerror(zip);
|
||||||
|
zip_discard(zip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
53
src/imports/ods_writer.hpp
Normal file
53
src/imports/ods_writer.hpp
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#ifndef _IMPORTS_ODS_WRITER_HPP_
|
||||||
|
#define _IMPORTS_ODS_WRITER_HPP_
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// Minimal OpenDocument Spreadsheet (.ods) writer. Backed by libzip +
|
||||||
|
// pugixml (already in the build for the ODS *importer*). Produces
|
||||||
|
// multi-sheet workbooks; each sheet is a 2-D grid of string cells.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// OdsWriter w;
|
||||||
|
// auto *s1 = w.add_sheet("connection A");
|
||||||
|
// s1->set(0, 0, "header1");
|
||||||
|
// s1->set(0, 1, "header2");
|
||||||
|
// s1->set(1, 0, "value");
|
||||||
|
// std::string err;
|
||||||
|
// bool ok = w.save("out.ods", err);
|
||||||
|
//
|
||||||
|
// Limitations (intentional, the format is huge):
|
||||||
|
// - string cells only (no numbers / dates / formulas)
|
||||||
|
// - no styles, no merged cells, no formatting
|
||||||
|
// - a single empty row is emitted when a row has at least one set cell;
|
||||||
|
// trailing empty rows are skipped.
|
||||||
|
|
||||||
|
class OdsSheet {
|
||||||
|
public:
|
||||||
|
explicit OdsSheet(std::string name) : name_(std::move(name)) {}
|
||||||
|
|
||||||
|
void set(int row, int col, std::string value);
|
||||||
|
|
||||||
|
const std::string &name() const { return name_; }
|
||||||
|
int rows() const { return (int)cells_.size(); }
|
||||||
|
int cols() const;
|
||||||
|
const std::string &cell(int row, int col) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string name_;
|
||||||
|
// Row-major sparse storage: cells_[r][c] = value. Rows/cols are grown
|
||||||
|
// lazily on set().
|
||||||
|
std::vector<std::vector<std::string>> cells_;
|
||||||
|
};
|
||||||
|
|
||||||
|
class OdsWriter {
|
||||||
|
public:
|
||||||
|
OdsSheet *add_sheet(const std::string &name);
|
||||||
|
bool save(const std::string &path, std::string &error) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<OdsSheet> sheets_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // _IMPORTS_ODS_WRITER_HPP_
|
||||||
43
src/main.cpp
43
src/main.cpp
@@ -8,17 +8,29 @@ namespace {
|
|||||||
|
|
||||||
void print_usage(const char *prog) {
|
void print_usage(const char *prog) {
|
||||||
std::cerr <<
|
std::cerr <<
|
||||||
"usage: " << prog << " [--commands-md [file] | --help]\n"
|
"usage: " << prog << " [--source FILE] [--restore FILE]\n"
|
||||||
" (no args) launch the interactive TUI\n"
|
" " << prog << " --commands-md [FILE]\n"
|
||||||
" --commands-md [file] dump the command registry as Markdown.\n"
|
" " << prog << " --help\n"
|
||||||
" With <file>: write there. Without: stdout.\n"
|
" (no args) launch the TUI on an empty system.\n"
|
||||||
|
" --source FILE after boot, run FILE as an essim script\n"
|
||||||
|
" (one command per line; same as the `source`\n"
|
||||||
|
" command). Output is in the console screen.\n"
|
||||||
|
" --restore FILE after boot, restore the system snapshot in\n"
|
||||||
|
" FILE (same as the `restore` command).\n"
|
||||||
|
" Combine with --source to layer a script on\n"
|
||||||
|
" top of a restored snapshot.\n"
|
||||||
|
" --commands-md [FILE] dump the command registry as Markdown.\n"
|
||||||
|
" With FILE: write there. Without: stdout.\n"
|
||||||
" (Used by `cmake --build build --target doc`.)\n"
|
" (Used by `cmake --build build --target doc`.)\n"
|
||||||
" --help, -h show this help\n";
|
" --help, -h show this help.\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char **argv) {
|
int main(int argc, char **argv) {
|
||||||
|
std::string boot_restore;
|
||||||
|
std::string boot_source;
|
||||||
|
|
||||||
for (int i = 1; i < argc; ++i) {
|
for (int i = 1; i < argc; ++i) {
|
||||||
std::string a = argv[i];
|
std::string a = argv[i];
|
||||||
if (a == "--commands-md") {
|
if (a == "--commands-md") {
|
||||||
@@ -35,6 +47,22 @@ int main(int argc, char **argv) {
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (a == "--source") {
|
||||||
|
if (i + 1 >= argc) {
|
||||||
|
std::cerr << "essim: --source needs a filename\n";
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
boot_source = argv[++i];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (a == "--restore") {
|
||||||
|
if (i + 1 >= argc) {
|
||||||
|
std::cerr << "essim: --restore needs a filename\n";
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
boot_restore = argv[++i];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (a == "--help" || a == "-h") {
|
if (a == "--help" || a == "-h") {
|
||||||
print_usage(argv[0]);
|
print_usage(argv[0]);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -45,6 +73,11 @@ int main(int argc, char **argv) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Tui tui;
|
Tui tui;
|
||||||
|
// Order matters: a `--restore` brings up a snapshot, then `--source`
|
||||||
|
// can layer additional commands on top of it (useful e.g. for "load
|
||||||
|
// snapshot, then re-run a small script that adds a new card").
|
||||||
|
if (!boot_restore.empty()) tui.BootDispatch("restore " + boot_restore);
|
||||||
|
if (!boot_source.empty()) tui.BootDispatch("source " + boot_source);
|
||||||
tui.Run();
|
tui.Run();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "tui/tui.hpp"
|
#include "tui/tui.hpp"
|
||||||
#include "tui/tui_helpers.hpp"
|
#include "tui/tui_helpers.hpp"
|
||||||
|
|
||||||
|
#include "imports/ods_writer.hpp"
|
||||||
#include "system/analysis.hpp"
|
#include "system/analysis.hpp"
|
||||||
#include "system/connect.hpp"
|
#include "system/connect.hpp"
|
||||||
#include "system/modules.hpp"
|
#include "system/modules.hpp"
|
||||||
@@ -205,6 +206,181 @@ void Tui::RegisterCommands() {
|
|||||||
"write the current system snapshot to a file",
|
"write the current system snapshot to a file",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
commands["export"] = {
|
||||||
|
{{"kind [connections]", Completion::None},
|
||||||
|
{"filename (.csv)", Completion::Path}},
|
||||||
|
[this](const std::vector<std::string> &args) {
|
||||||
|
if (!sys) { Print("no system: run 'new' first."); return; }
|
||||||
|
if (args.empty()) {
|
||||||
|
// Bare → reuse the generic file dialog. The kind defaults
|
||||||
|
// to "connections" (the only one today); when more kinds
|
||||||
|
// are added, prompt for it inside the dialog before the
|
||||||
|
// filename step. Filters give the user a one-keystroke
|
||||||
|
// toggle between CSV and ODS — picking either rewrites
|
||||||
|
// the filename's extension, and the action below
|
||||||
|
// dispatches on that extension.
|
||||||
|
OpenFileDialog(
|
||||||
|
"Export — connections",
|
||||||
|
"export.connections",
|
||||||
|
"connections.csv",
|
||||||
|
{{"CSV", ".csv"}, {"ODS", ".ods"}},
|
||||||
|
[this](const std::string &path) {
|
||||||
|
Dispatch("export connections " + path);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (args.size() != 2) {
|
||||||
|
Print("usage: export <kind> <file> (or no args for the dialog)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const std::string &kind = args[0];
|
||||||
|
const std::string &path = args[1];
|
||||||
|
|
||||||
|
// Minimal CSV quoter — wraps in `"…"` and doubles internal
|
||||||
|
// quotes when the field contains a comma, quote, or newline.
|
||||||
|
auto q = [](const std::string &s) -> std::string {
|
||||||
|
bool needs = s.find_first_of(",\"\n") != std::string::npos;
|
||||||
|
if (!needs) return s;
|
||||||
|
std::string out = "\"";
|
||||||
|
for (char c : s) { if (c == '"') out += '"'; out += c; }
|
||||||
|
out += '"';
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (kind == "connections") {
|
||||||
|
auto side = [](Pin *p, std::string &mod, std::string &part,
|
||||||
|
std::string &pin, std::string &sig,
|
||||||
|
std::string &type, std::string &suspect) {
|
||||||
|
if (!p) { mod = part = pin = sig = type = suspect = ""; return; }
|
||||||
|
mod = (p->prnt && p->prnt->prnt) ? p->prnt->prnt->name : "";
|
||||||
|
part = p->prnt ? p->prnt->name : "";
|
||||||
|
pin = p->name;
|
||||||
|
Signal *s = p->signal();
|
||||||
|
if (!s) {
|
||||||
|
sig = ""; type = "(NC)"; suspect = "";
|
||||||
|
} else {
|
||||||
|
sig = s->name;
|
||||||
|
type = signal_type_name(s->type);
|
||||||
|
suspect = (infer_signal_type(s->name) == SignalType::Power
|
||||||
|
&& s->type == SignalType::Other) ? "yes" : "no";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dispatch on extension. Accepted: `.csv` (flat file) and
|
||||||
|
// `.ods` (one sheet per connection). Anything else is an
|
||||||
|
// error — silently writing a CSV under a `.xyz` filename
|
||||||
|
// would be confusing.
|
||||||
|
std::string ext;
|
||||||
|
{
|
||||||
|
size_t dot = path.rfind('.');
|
||||||
|
if (dot != std::string::npos) ext = ToLower(path.substr(dot));
|
||||||
|
}
|
||||||
|
bool ods = (ext == ".ods");
|
||||||
|
bool csv = (ext == ".csv");
|
||||||
|
if (!ods && !csv) {
|
||||||
|
ShowError("export: unknown extension '"
|
||||||
|
+ (ext.empty() ? std::string("(none)") : ext)
|
||||||
|
+ "'\nAccepted: .csv, .ods");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ods) {
|
||||||
|
OdsWriter w;
|
||||||
|
int total = 0;
|
||||||
|
for (auto &ckv : *sys->connections()) {
|
||||||
|
Connection *c = ckv.second;
|
||||||
|
// Sheet names can't contain '/' in some viewers
|
||||||
|
// (LibreOffice tolerates it, Excel rejects it).
|
||||||
|
// Sanitise just in case.
|
||||||
|
std::string sname = c->name;
|
||||||
|
for (char &ch : sname)
|
||||||
|
if (ch == '/' || ch == '\\' || ch == '?' || ch == '*'
|
||||||
|
|| ch == ':' || ch == '[' || ch == ']') ch = '_';
|
||||||
|
OdsSheet *s = w.add_sheet(sname);
|
||||||
|
const char *hdr[] = {
|
||||||
|
"transform",
|
||||||
|
"left_module", "left_part", "left_pin",
|
||||||
|
"left_signal", "left_type", "left_suspect",
|
||||||
|
"right_module", "right_part", "right_pin",
|
||||||
|
"right_signal", "right_type", "right_suspect",
|
||||||
|
"mixed"};
|
||||||
|
for (int i = 0; i < 14; ++i) s->set(0, i, hdr[i]);
|
||||||
|
int row = 1;
|
||||||
|
for (auto &wp : c->pin_map) {
|
||||||
|
std::string lm, lp, ln, ls, lt, lsus;
|
||||||
|
std::string rm, rp, rn, rs, rt, rsus;
|
||||||
|
side(wp.first, lm, lp, ln, ls, lt, lsus);
|
||||||
|
side(wp.second, rm, rp, rn, rs, rt, rsus);
|
||||||
|
std::string mixed = (lt != "(NC)" && rt != "(NC)" && lt != rt)
|
||||||
|
? "yes" : "no";
|
||||||
|
s->set(row, 0, c->transform_name);
|
||||||
|
s->set(row, 1, lm); s->set(row, 2, lp);
|
||||||
|
s->set(row, 3, ln); s->set(row, 4, ls);
|
||||||
|
s->set(row, 5, lt); s->set(row, 6, lsus);
|
||||||
|
s->set(row, 7, rm); s->set(row, 8, rp);
|
||||||
|
s->set(row, 9, rn); s->set(row, 10, rs);
|
||||||
|
s->set(row, 11, rt); s->set(row, 12, rsus);
|
||||||
|
s->set(row, 13, mixed);
|
||||||
|
++row; ++total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
if (!w.save(path, err)) {
|
||||||
|
ShowError("export (.ods) failed:\n" + err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Print("export connections (.ods): "
|
||||||
|
+ std::to_string(sys->connections()->size())
|
||||||
|
+ " sheet(s), " + std::to_string(total)
|
||||||
|
+ " wire(s) → " + path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV fallback.
|
||||||
|
std::ofstream f(path);
|
||||||
|
if (!f) {
|
||||||
|
ShowError("export: cannot open '" + path + "' for writing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
f << "connection,transform,"
|
||||||
|
"left_module,left_part,left_pin,left_signal,left_type,left_suspect,"
|
||||||
|
"right_module,right_part,right_pin,right_signal,right_type,right_suspect,"
|
||||||
|
"mixed\n";
|
||||||
|
|
||||||
|
int rows = 0;
|
||||||
|
for (auto &ckv : *sys->connections()) {
|
||||||
|
Connection *c = ckv.second;
|
||||||
|
for (auto &wp : c->pin_map) {
|
||||||
|
std::string lm, lp, ln, ls, lt, lsus;
|
||||||
|
std::string rm, rp, rn, rs, rt, rsus;
|
||||||
|
side(wp.first, lm, lp, ln, ls, lt, lsus);
|
||||||
|
side(wp.second, rm, rp, rn, rs, rt, rsus);
|
||||||
|
std::string mixed = "no";
|
||||||
|
if (lt != "(NC)" && rt != "(NC)" && lt != rt) mixed = "yes";
|
||||||
|
f << q(c->name) << ',' << q(c->transform_name) << ','
|
||||||
|
<< q(lm) << ',' << q(lp) << ',' << q(ln) << ','
|
||||||
|
<< q(ls) << ',' << q(lt) << ',' << q(lsus) << ','
|
||||||
|
<< q(rm) << ',' << q(rp) << ',' << q(rn) << ','
|
||||||
|
<< q(rs) << ',' << q(rt) << ',' << q(rsus) << ','
|
||||||
|
<< mixed << '\n';
|
||||||
|
++rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Print("export connections (.csv): " + std::to_string(rows)
|
||||||
|
+ " wire(s) → " + path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShowError("export: unknown kind '" + kind + "'\n"
|
||||||
|
"Known kinds: connections");
|
||||||
|
},
|
||||||
|
/*prompt_for_missing=*/ false,
|
||||||
|
"export structured data to CSV (kinds: connections; "
|
||||||
|
"bare form opens the file-picker dialog)",
|
||||||
|
/*scriptable=*/ true,
|
||||||
|
/*interactive=*/ true,
|
||||||
|
};
|
||||||
|
|
||||||
commands["restore"] = {
|
commands["restore"] = {
|
||||||
{{"filename", Completion::Path}},
|
{{"filename", Completion::Path}},
|
||||||
[this](const std::vector<std::string> &args) {
|
[this](const std::vector<std::string> &args) {
|
||||||
|
|||||||
@@ -269,10 +269,14 @@ Component Tui::BuildAnalyzeScreen() {
|
|||||||
term("Gnd",
|
term("Gnd",
|
||||||
"Name matches GND, SHIELD, CHASSIS or EARTH. Name alone is "
|
"Name matches GND, SHIELD, CHASSIS or EARTH. Name alone is "
|
||||||
"enough — false positives are essentially nil."),
|
"enough — false positives are essentially nil."),
|
||||||
}) | borderRounded | size(WIDTH, EQUAL, 32);
|
}) | borderRounded
|
||||||
|
| size(WIDTH, LESS_THAN, 36)
|
||||||
|
| size(WIDTH, GREATER_THAN, 20);
|
||||||
|
|
||||||
Element side = (analyze_focus_idx == 2)
|
Element side = (analyze_focus_idx == 2)
|
||||||
? vbox({help, text(""), types_glossary}) | size(WIDTH, EQUAL, 32)
|
? vbox({help, text(""), types_glossary})
|
||||||
|
| size(WIDTH, LESS_THAN, 36)
|
||||||
|
| size(WIDTH, GREATER_THAN, 20)
|
||||||
: help;
|
: help;
|
||||||
|
|
||||||
return vbox({
|
return vbox({
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ Component Tui::BuildDashboardScreen() {
|
|||||||
{"e", "explore"},
|
{"e", "explore"},
|
||||||
{"a", "analyze (verify + groups)"},
|
{"a", "analyze (verify + groups)"},
|
||||||
{"h", "help screen"},
|
{"h", "help screen"},
|
||||||
|
{"x", "export (CSV)"},
|
||||||
{"PgUp", "scroll up"},
|
{"PgUp", "scroll up"},
|
||||||
{"PgDn", "scroll down"},
|
{"PgDn", "scroll down"},
|
||||||
{"Home", "scroll top"},
|
{"Home", "scroll top"},
|
||||||
|
|||||||
35
src/tui/screen_error.cpp
Normal file
35
src/tui/screen_error.cpp
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#include "tui/tui.hpp"
|
||||||
|
|
||||||
|
#include <ftxui/component/component.hpp>
|
||||||
|
#include <ftxui/component/event.hpp>
|
||||||
|
#include <ftxui/dom/elements.hpp>
|
||||||
|
|
||||||
|
using namespace ftxui;
|
||||||
|
|
||||||
|
void Tui::ShowError(const std::string &msg) {
|
||||||
|
error_message = msg;
|
||||||
|
error_open = true;
|
||||||
|
// Echo to the console log too — the modal is dismissable, the log
|
||||||
|
// is a record for later inspection.
|
||||||
|
Print("error: " + msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
Component Tui::BuildErrorModal() {
|
||||||
|
auto button = Button(" OK ", [this]() { error_open = false; });
|
||||||
|
auto handler = CatchEvent(button, [this](Event e) {
|
||||||
|
if (e == Event::Escape) { error_open = false; return true; }
|
||||||
|
if (e == Event::Return) { error_open = false; return true; }
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return Renderer(handler, [this, button] {
|
||||||
|
return vbox({
|
||||||
|
text(" Error ") | bold | center | color(Color::Red),
|
||||||
|
separator(),
|
||||||
|
paragraph(error_message),
|
||||||
|
separator(),
|
||||||
|
hbox({filler(), button->Render(), filler()}),
|
||||||
|
}) | borderRounded
|
||||||
|
| size(WIDTH, LESS_THAN, 70)
|
||||||
|
| size(WIDTH, GREATER_THAN, 40);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -84,29 +84,47 @@ Component Tui::BuildExploreScreen() {
|
|||||||
|
|
||||||
auto detail_filter = Input(&explore_detail_filter, "filter…", pf_opt);
|
auto detail_filter = Input(&explore_detail_filter, "filter…", pf_opt);
|
||||||
|
|
||||||
MenuOption detail_opt = MenuOption::Vertical();
|
|
||||||
detail_opt.entries = &explore_detail;
|
|
||||||
detail_opt.selected = &explore_detail_idx;
|
|
||||||
// Each `explore_detail_sig` slot is either empty (no action) or a
|
// Each `explore_detail_sig` slot is either empty (no action) or a
|
||||||
// `module\tsignal` pair. The cross-module form is what lets Enter on a
|
// `module\tsignal` pair. The cross-module form is what lets Enter on a
|
||||||
// net member row open the popup for that peer module's signal.
|
// net member row open the popup for that peer module's signal.
|
||||||
detail_opt.on_enter = [this]() {
|
auto fire = [this](const std::vector<std::string> &sigs, int idx) {
|
||||||
if (explore_detail_idx < 0
|
if (idx < 0 || idx >= (int)sigs.size()) return;
|
||||||
|| explore_detail_idx >= (int)explore_detail_sig.size()) return;
|
const std::string &payload = sigs[idx];
|
||||||
const std::string &payload = explore_detail_sig[explore_detail_idx];
|
|
||||||
if (payload.empty()) return;
|
if (payload.empty()) return;
|
||||||
size_t tab = payload.find('\t');
|
size_t tab = payload.find('\t');
|
||||||
if (tab == std::string::npos) return;
|
if (tab == std::string::npos) return;
|
||||||
OpenSignalTypeDialog(payload.substr(0, tab), payload.substr(tab + 1));
|
OpenSignalTypeDialog(payload.substr(0, tab), payload.substr(tab + 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
MenuOption detail_opt = MenuOption::Vertical();
|
||||||
|
detail_opt.entries = &explore_detail;
|
||||||
|
detail_opt.selected = &explore_detail_idx;
|
||||||
|
detail_opt.focused_entry = &explore_detail_idx;
|
||||||
|
detail_opt.on_enter = [this, fire]() {
|
||||||
|
fire(explore_detail_sig, explore_detail_idx);
|
||||||
|
};
|
||||||
auto detail_menu = Menu(detail_opt);
|
auto detail_menu = Menu(detail_opt);
|
||||||
|
|
||||||
|
// Second detail menu — only the signals tab populates it (with the
|
||||||
|
// BFS net members). Vertically split inside col4 so the user sees the
|
||||||
|
// local-pin section AND the cross-module section without scrolling.
|
||||||
|
MenuOption detail2_opt = MenuOption::Vertical();
|
||||||
|
detail2_opt.entries = &explore_detail2;
|
||||||
|
detail2_opt.selected = &explore_detail2_idx;
|
||||||
|
detail2_opt.focused_entry = &explore_detail2_idx;
|
||||||
|
detail2_opt.on_enter = [this, fire]() {
|
||||||
|
fire(explore_detail2_sig, explore_detail2_idx);
|
||||||
|
};
|
||||||
|
auto detail2_menu = Menu(detail2_opt);
|
||||||
|
|
||||||
auto components = Container::Vertical(
|
auto components = Container::Vertical(
|
||||||
{module_menu, type_menu, child_filter, children_menu, detail_filter, detail_menu},
|
{module_menu, type_menu, child_filter, children_menu, detail_filter,
|
||||||
|
detail_menu, detail2_menu},
|
||||||
&explore_focus_idx);
|
&explore_focus_idx);
|
||||||
|
|
||||||
return Renderer(components,
|
return Renderer(components,
|
||||||
[this, module_menu, type_menu, child_filter, children_menu, detail_filter, detail_menu] {
|
[this, module_menu, type_menu, child_filter, children_menu,
|
||||||
|
detail_filter, detail_menu, detail2_menu] {
|
||||||
try {
|
try {
|
||||||
// Clamp the module index defensively (covers fresh systems / restore).
|
// Clamp the module index defensively (covers fresh systems / restore).
|
||||||
if (explore_module_idx < 0
|
if (explore_module_idx < 0
|
||||||
@@ -162,6 +180,8 @@ Component Tui::BuildExploreScreen() {
|
|||||||
explore_header = "(no system)";
|
explore_header = "(no system)";
|
||||||
explore_detail.clear();
|
explore_detail.clear();
|
||||||
explore_detail_sig.clear();
|
explore_detail_sig.clear();
|
||||||
|
explore_detail2.clear();
|
||||||
|
explore_detail2_sig.clear();
|
||||||
if (cur_mod && !explore_children.empty()) {
|
if (cur_mod && !explore_children.empty()) {
|
||||||
const std::string &cname = explore_children[explore_child_idx];
|
const std::string &cname = explore_children[explore_child_idx];
|
||||||
try {
|
try {
|
||||||
@@ -219,12 +239,9 @@ Component Tui::BuildExploreScreen() {
|
|||||||
+ " pins • type: " + signal_type_name(s->type)
|
+ " pins • type: " + signal_type_name(s->type)
|
||||||
+ " • " + net_hdr;
|
+ " • " + net_hdr;
|
||||||
|
|
||||||
// Local pins first. Enter on any of these reopens the
|
// Local pins go to the TOP sub-panel (explore_detail).
|
||||||
// signal-type popup for the current signal (= same as
|
// Enter on any of these reopens the signal-type popup
|
||||||
// Enter on the signal in the children menu — redundant
|
// for the current signal.
|
||||||
// but natural).
|
|
||||||
explore_detail.push_back(" Local pins:");
|
|
||||||
explore_detail_sig.push_back({});
|
|
||||||
std::string self_payload = cur_mod->name + "\t" + s->name;
|
std::string self_payload = cur_mod->name + "\t" + s->name;
|
||||||
std::vector<std::string> rows;
|
std::vector<std::string> rows;
|
||||||
for (auto &pin_kv : *s) {
|
for (auto &pin_kv : *s) {
|
||||||
@@ -237,19 +254,15 @@ Component Tui::BuildExploreScreen() {
|
|||||||
std::sort(rows.begin(), rows.end(), NaturalLess);
|
std::sort(rows.begin(), rows.end(), NaturalLess);
|
||||||
for (const auto &r : rows)
|
for (const auto &r : rows)
|
||||||
if (keep_detail(r)) {
|
if (keep_detail(r)) {
|
||||||
explore_detail.push_back(" " + r);
|
explore_detail.push_back(" " + r);
|
||||||
explore_detail_sig.push_back(self_payload);
|
explore_detail_sig.push_back(self_payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-module net members (only when truly bridged).
|
// Cross-module net members → BOTTOM sub-panel
|
||||||
// Each row carries its own `module\tsignal` payload so
|
// (explore_detail2). Each row carries its own
|
||||||
// Enter opens the popup for that peer-module signal —
|
// `module\tsignal` payload so Enter opens the popup for
|
||||||
// not the locally-selected module.
|
// that peer-module signal, not the locally selected one.
|
||||||
if (n.members.size() >= 2) {
|
if (n.members.size() >= 2) {
|
||||||
explore_detail.push_back("");
|
|
||||||
explore_detail_sig.push_back({});
|
|
||||||
explore_detail.push_back(" Net members (across connections):");
|
|
||||||
explore_detail_sig.push_back({});
|
|
||||||
std::vector<std::pair<std::string, std::string>> net_rows;
|
std::vector<std::pair<std::string, std::string>> net_rows;
|
||||||
for (const auto &mp : n.members) {
|
for (const auto &mp : n.members) {
|
||||||
std::string label = mp.first->name + "/" + mp.second->name
|
std::string label = mp.first->name + "/" + mp.second->name
|
||||||
@@ -262,8 +275,8 @@ Component Tui::BuildExploreScreen() {
|
|||||||
[](const auto &a, const auto &b) { return NaturalLess(a.first, b.first); });
|
[](const auto &a, const auto &b) { return NaturalLess(a.first, b.first); });
|
||||||
for (const auto &r : net_rows)
|
for (const auto &r : net_rows)
|
||||||
if (keep_detail(r.first)) {
|
if (keep_detail(r.first)) {
|
||||||
explore_detail.push_back(" " + r.first);
|
explore_detail2.push_back(" " + r.first);
|
||||||
explore_detail_sig.push_back(r.second);
|
explore_detail2_sig.push_back(r.second);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -295,23 +308,35 @@ Component Tui::BuildExploreScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (explore_detail.empty()) explore_detail.push_back("(empty)");
|
if (explore_detail.empty()) explore_detail.push_back("(empty)");
|
||||||
// Pad the parallel sig vector so any index into explore_detail is safe.
|
// Pad parallel sig vectors so any index is safe.
|
||||||
while (explore_detail_sig.size() < explore_detail.size())
|
while (explore_detail_sig.size() < explore_detail.size())
|
||||||
explore_detail_sig.push_back({});
|
explore_detail_sig.push_back({});
|
||||||
if (explore_detail_idx < 0
|
if (explore_detail_idx < 0
|
||||||
|| explore_detail_idx >= (int)explore_detail.size()) {
|
|| explore_detail_idx >= (int)explore_detail.size()) {
|
||||||
explore_detail_idx = 0;
|
explore_detail_idx = 0;
|
||||||
}
|
}
|
||||||
|
// detail2 is only populated for the signals tab when the net has
|
||||||
|
// ≥ 2 members. Otherwise hide it by leaving it empty.
|
||||||
|
while (explore_detail2_sig.size() < explore_detail2.size())
|
||||||
|
explore_detail2_sig.push_back({});
|
||||||
|
if (explore_detail2_idx < 0
|
||||||
|
|| explore_detail2_idx >= (int)explore_detail2.size()) {
|
||||||
|
explore_detail2_idx = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns use LESS_THAN/GREATER_THAN bounds rather than strict
|
||||||
|
// EQUAL so the layout adapts when the terminal is narrow — without
|
||||||
|
// these, a fixed total >= viewport clips the rightmost element
|
||||||
|
// (the help panel) to nothing.
|
||||||
auto col1 = vbox({
|
auto col1 = vbox({
|
||||||
FocusLabel(text(" module "), explore_focus_idx == 0) | bold,
|
FocusLabel(text(" module "), explore_focus_idx == 0) | bold,
|
||||||
module_menu->Render() | yframe | flex,
|
module_menu->Render() | yframe | flex,
|
||||||
}) | size(WIDTH, EQUAL, 24);
|
}) | size(WIDTH, LESS_THAN, 26) | size(WIDTH, GREATER_THAN, 14);
|
||||||
|
|
||||||
auto col2 = vbox({
|
auto col2 = vbox({
|
||||||
FocusLabel(text(" type "), explore_focus_idx == 1) | bold,
|
FocusLabel(text(" type "), explore_focus_idx == 1) | bold,
|
||||||
type_menu->Render() | flex,
|
type_menu->Render() | flex,
|
||||||
}) | size(WIDTH, EQUAL, 12);
|
}) | size(WIDTH, LESS_THAN, 14) | size(WIDTH, GREATER_THAN, 10);
|
||||||
|
|
||||||
auto col3 = vbox({
|
auto col3 = vbox({
|
||||||
FocusLabel(text(" " + explore_types[explore_type_idx] + " "),
|
FocusLabel(text(" " + explore_types[explore_type_idx] + " "),
|
||||||
@@ -319,15 +344,37 @@ Component Tui::BuildExploreScreen() {
|
|||||||
hbox({FocusLabel(text(" filter: "), explore_focus_idx == 2),
|
hbox({FocusLabel(text(" filter: "), explore_focus_idx == 2),
|
||||||
child_filter->Render() | flex}) | border,
|
child_filter->Render() | flex}) | border,
|
||||||
children_menu->Render() | yframe | flex,
|
children_menu->Render() | yframe | flex,
|
||||||
}) | size(WIDTH, EQUAL, 36);
|
}) | size(WIDTH, LESS_THAN, 38) | size(WIDTH, GREATER_THAN, 22);
|
||||||
|
|
||||||
auto col4 = vbox({
|
// col4 is split horizontally inside the same column width: top
|
||||||
FocusLabel(text(" " + explore_header + " "),
|
// sub-panel = `explore_detail` (Local pins), bottom sub-panel =
|
||||||
explore_focus_idx == 5) | bold,
|
// `explore_detail2` (Net members across connections). The bottom
|
||||||
|
// panel is shown only when there is something to show — for the
|
||||||
|
// parts / connections tabs, it stays hidden so col4 looks like a
|
||||||
|
// single panel.
|
||||||
|
Elements col4_rows;
|
||||||
|
col4_rows.push_back(FocusLabel(text(" " + explore_header + " "),
|
||||||
|
explore_focus_idx == 5) | bold);
|
||||||
|
col4_rows.push_back(
|
||||||
hbox({FocusLabel(text(" filter: "), explore_focus_idx == 4),
|
hbox({FocusLabel(text(" filter: "), explore_focus_idx == 4),
|
||||||
detail_filter->Render() | flex}) | border,
|
detail_filter->Render() | flex}) | border);
|
||||||
detail_menu->Render() | vscroll_indicator | yframe | flex,
|
if (!explore_detail2.empty()) {
|
||||||
}) | flex;
|
col4_rows.push_back(
|
||||||
|
FocusLabel(text(" local pins "),
|
||||||
|
explore_focus_idx == 5) | dim);
|
||||||
|
col4_rows.push_back(
|
||||||
|
detail_menu->Render() | vscroll_indicator | yframe | flex);
|
||||||
|
col4_rows.push_back(separator());
|
||||||
|
col4_rows.push_back(
|
||||||
|
FocusLabel(text(" net members "),
|
||||||
|
explore_focus_idx == 6) | dim);
|
||||||
|
col4_rows.push_back(
|
||||||
|
detail2_menu->Render() | vscroll_indicator | yframe | flex);
|
||||||
|
} else {
|
||||||
|
col4_rows.push_back(
|
||||||
|
detail_menu->Render() | vscroll_indicator | yframe | flex);
|
||||||
|
}
|
||||||
|
auto col4 = vbox(std::move(col4_rows)) | flex;
|
||||||
|
|
||||||
auto title = hbox({
|
auto title = hbox({
|
||||||
text(" essim ") | bold,
|
text(" essim ") | bold,
|
||||||
|
|||||||
279
src/tui/screen_filedialog.cpp
Normal file
279
src/tui/screen_filedialog.cpp
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
#include "tui/tui.hpp"
|
||||||
|
#include "tui/tui_helpers.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 <exception>
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
|
using namespace ftxui;
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
// Free helpers defined in shell.cpp.
|
||||||
|
void SaveLastUsed(const std::string &key,
|
||||||
|
const std::string &dir,
|
||||||
|
const std::string &filename);
|
||||||
|
bool LoadLastUsed(const std::string &key,
|
||||||
|
std::string &dir, std::string &filename);
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Return the lowercased extension (with leading dot) of `name`, or empty.
|
||||||
|
std::string ext_of(const std::string &name) {
|
||||||
|
size_t dot = name.rfind('.');
|
||||||
|
if (dot == std::string::npos) return {};
|
||||||
|
std::string e = name.substr(dot);
|
||||||
|
for (char &c : e) c = (char)std::tolower((unsigned char)c);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the trailing extension of `name` with `new_ext` (which already
|
||||||
|
// includes the dot). If no dot is present, append it.
|
||||||
|
void replace_ext(std::string &name, const std::string &new_ext) {
|
||||||
|
size_t dot = name.rfind('.');
|
||||||
|
if (dot == std::string::npos) { name += new_ext; return; }
|
||||||
|
name = name.substr(0, dot) + new_ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index of the filter whose extension matches `e`, or -1.
|
||||||
|
int filter_for_ext(const std::vector<Tui::FilenameFilter> &filters,
|
||||||
|
const std::string &e) {
|
||||||
|
for (int i = 0; i < (int)filters.size(); ++i)
|
||||||
|
if (filters[i].extension == e) return i;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void Tui::OpenFileDialog(std::string title,
|
||||||
|
std::string persist_key,
|
||||||
|
std::string default_filename,
|
||||||
|
std::vector<FilenameFilter> filters,
|
||||||
|
std::function<void(const std::string &)> on_confirm) {
|
||||||
|
file_dialog.title = std::move(title);
|
||||||
|
file_dialog.persist_key = std::move(persist_key);
|
||||||
|
file_dialog.on_confirm = std::move(on_confirm);
|
||||||
|
file_dialog.filters = std::move(filters);
|
||||||
|
file_dialog.filter_labels.clear();
|
||||||
|
for (const auto &f : file_dialog.filters)
|
||||||
|
file_dialog.filter_labels.push_back(f.label);
|
||||||
|
file_dialog.status.clear();
|
||||||
|
|
||||||
|
std::string saved_dir, saved_fn;
|
||||||
|
if (LoadLastUsed(file_dialog.persist_key, saved_dir, saved_fn)) {
|
||||||
|
file_dialog.dir = std::move(saved_dir);
|
||||||
|
file_dialog.filename = std::move(saved_fn);
|
||||||
|
} else {
|
||||||
|
std::error_code ec;
|
||||||
|
fs::path cwd = fs::current_path(ec);
|
||||||
|
file_dialog.dir = ec ? std::string(".") : cwd.string();
|
||||||
|
file_dialog.filename = std::move(default_filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync filter_idx to the current filename extension if one matches.
|
||||||
|
file_dialog.filter_idx = 0;
|
||||||
|
if (!file_dialog.filters.empty()) {
|
||||||
|
int i = filter_for_ext(file_dialog.filters, ext_of(file_dialog.filename));
|
||||||
|
if (i >= 0) file_dialog.filter_idx = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
file_dialog.entry_idx = 0;
|
||||||
|
// Default focus: filename input. With filters present that's slot 2;
|
||||||
|
// without, slot 1.
|
||||||
|
file_dialog.focus_idx = file_dialog.filters.empty() ? 1 : 2;
|
||||||
|
file_dialog.open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Tui::ConfirmFileDialog() {
|
||||||
|
if (file_dialog.filename.empty()) {
|
||||||
|
file_dialog.status = "filename is empty";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// When the caller provided a filter list, treat it as a whitelist of
|
||||||
|
// acceptable extensions. An unrecognised extension keeps the dialog
|
||||||
|
// open with an explanatory status — never silently fall through to a
|
||||||
|
// wrong-format write.
|
||||||
|
if (!file_dialog.filters.empty()) {
|
||||||
|
std::string e = ext_of(file_dialog.filename);
|
||||||
|
if (filter_for_ext(file_dialog.filters, e) < 0) {
|
||||||
|
std::string allowed;
|
||||||
|
for (size_t i = 0; i < file_dialog.filters.size(); ++i) {
|
||||||
|
if (i) allowed += ", ";
|
||||||
|
allowed += file_dialog.filters[i].extension;
|
||||||
|
}
|
||||||
|
file_dialog.status = "unknown extension '"
|
||||||
|
+ (e.empty() ? std::string("(none)") : e)
|
||||||
|
+ "' — accepted: " + allowed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs::path full = fs::path(file_dialog.dir) / file_dialog.filename;
|
||||||
|
auto cb = file_dialog.on_confirm;
|
||||||
|
SaveLastUsed(file_dialog.persist_key,
|
||||||
|
file_dialog.dir, file_dialog.filename);
|
||||||
|
file_dialog.open = false;
|
||||||
|
if (cb) cb(full.string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Component Tui::BuildFileDialog() {
|
||||||
|
InputOption fn_opt;
|
||||||
|
fn_opt.multiline = false;
|
||||||
|
fn_opt.transform = [](InputState s) {
|
||||||
|
auto el = s.element;
|
||||||
|
if (s.is_placeholder) el |= dim;
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
auto filename_input = Input(&file_dialog.filename, "filename…", fn_opt);
|
||||||
|
|
||||||
|
MenuOption entries_opt = MenuOption::Vertical();
|
||||||
|
entries_opt.entries = &file_dialog.entries;
|
||||||
|
entries_opt.selected = &file_dialog.entry_idx;
|
||||||
|
entries_opt.focused_entry = &file_dialog.entry_idx;
|
||||||
|
entries_opt.on_enter = [this]() {
|
||||||
|
if (file_dialog.entry_idx < 0
|
||||||
|
|| file_dialog.entry_idx >= (int)file_dialog.entries.size()) return;
|
||||||
|
const std::string &entry = file_dialog.entries[file_dialog.entry_idx];
|
||||||
|
if (!file_dialog.entries_is_dir[file_dialog.entry_idx]) {
|
||||||
|
file_dialog.filename = entry;
|
||||||
|
// sync filter to the picked file's extension
|
||||||
|
if (!file_dialog.filters.empty()) {
|
||||||
|
int i = filter_for_ext(file_dialog.filters,
|
||||||
|
ext_of(file_dialog.filename));
|
||||||
|
if (i >= 0) file_dialog.filter_idx = i;
|
||||||
|
}
|
||||||
|
file_dialog.focus_idx = file_dialog.filters.empty() ? 2 : 3;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::error_code ec;
|
||||||
|
fs::path next;
|
||||||
|
if (entry == "../") next = fs::path(file_dialog.dir).parent_path();
|
||||||
|
else next = fs::path(file_dialog.dir) / entry;
|
||||||
|
fs::path canon = fs::canonical(next, ec);
|
||||||
|
if (ec) return;
|
||||||
|
file_dialog.dir = canon.string();
|
||||||
|
file_dialog.entry_idx = 0;
|
||||||
|
};
|
||||||
|
auto entries_menu = Menu(entries_opt);
|
||||||
|
|
||||||
|
// Horizontal filter selector (Toggle). The sync between the picked
|
||||||
|
// filter and the filename extension is done inside the Renderer
|
||||||
|
// lambda below (FTXUI's plain Toggle API has no on_change hook).
|
||||||
|
auto filter_toggle = Toggle(&file_dialog.filter_labels, &file_dialog.filter_idx);
|
||||||
|
|
||||||
|
auto button = Button(" OK ", [this]() { ConfirmFileDialog(); });
|
||||||
|
|
||||||
|
auto container = Container::Vertical(
|
||||||
|
{filter_toggle, entries_menu, filename_input, button},
|
||||||
|
&file_dialog.focus_idx);
|
||||||
|
|
||||||
|
auto handler = CatchEvent(container, [this](Event e) {
|
||||||
|
if (e == Event::Escape) { file_dialog.open = false; return true; }
|
||||||
|
// The number of focusable slots depends on whether filters are
|
||||||
|
// present (4 with, 3 without). Skip slot 0 (filters) when empty.
|
||||||
|
int n = file_dialog.filters.empty() ? 3 : 4;
|
||||||
|
int base = file_dialog.filters.empty() ? 1 : 0;
|
||||||
|
if (e == Event::Tab) {
|
||||||
|
do { file_dialog.focus_idx = (file_dialog.focus_idx + 1) % 4;
|
||||||
|
} while (file_dialog.focus_idx < base);
|
||||||
|
(void)n;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e == Event::TabReverse) {
|
||||||
|
do { file_dialog.focus_idx = (file_dialog.focus_idx + 3) % 4;
|
||||||
|
} while (file_dialog.focus_idx < base);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Renderer(handler,
|
||||||
|
[this, entries_menu, filename_input, button, filter_toggle,
|
||||||
|
last_filter_idx = int(-1)]() mutable {
|
||||||
|
// Sync filename extension to the currently picked filter when it
|
||||||
|
// changes (since FTXUI Toggle has no on_change hook).
|
||||||
|
if (!file_dialog.filters.empty()
|
||||||
|
&& file_dialog.filter_idx != last_filter_idx
|
||||||
|
&& file_dialog.filter_idx >= 0
|
||||||
|
&& file_dialog.filter_idx < (int)file_dialog.filters.size()) {
|
||||||
|
// On the first frame after Open, just record the index — don't
|
||||||
|
// rewrite the user's chosen filename. From then on, every
|
||||||
|
// change rewrites the extension.
|
||||||
|
if (last_filter_idx >= 0) {
|
||||||
|
replace_ext(file_dialog.filename,
|
||||||
|
file_dialog.filters[file_dialog.filter_idx].extension);
|
||||||
|
}
|
||||||
|
last_filter_idx = file_dialog.filter_idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
file_dialog.entries.clear();
|
||||||
|
file_dialog.entries_is_dir.clear();
|
||||||
|
file_dialog.entries.push_back("../");
|
||||||
|
file_dialog.entries_is_dir.push_back(true);
|
||||||
|
try {
|
||||||
|
std::vector<std::pair<std::string, bool>> raw;
|
||||||
|
for (const auto &e : fs::directory_iterator(file_dialog.dir)) {
|
||||||
|
std::string n = e.path().filename().string();
|
||||||
|
bool is_dir = e.is_directory();
|
||||||
|
raw.emplace_back(is_dir ? n + "/" : n, is_dir);
|
||||||
|
}
|
||||||
|
std::sort(raw.begin(), raw.end(),
|
||||||
|
[](const auto &a, const auto &b) {
|
||||||
|
if (a.second != b.second) return a.second;
|
||||||
|
return NaturalLess(a.first, b.first);
|
||||||
|
});
|
||||||
|
for (auto &p : raw) {
|
||||||
|
file_dialog.entries.push_back(std::move(p.first));
|
||||||
|
file_dialog.entries_is_dir.push_back(p.second);
|
||||||
|
}
|
||||||
|
} catch (const std::exception &) {}
|
||||||
|
if (file_dialog.entry_idx >= (int)file_dialog.entries.size())
|
||||||
|
file_dialog.entry_idx = (int)file_dialog.entries.size() - 1;
|
||||||
|
if (file_dialog.entry_idx < 0) file_dialog.entry_idx = 0;
|
||||||
|
|
||||||
|
Element status = file_dialog.status.empty()
|
||||||
|
? text("") | dim
|
||||||
|
: text(" " + file_dialog.status + " ") | bold;
|
||||||
|
|
||||||
|
Elements rows;
|
||||||
|
rows.push_back(text(" " + file_dialog.title + " ") | bold | center);
|
||||||
|
rows.push_back(separator());
|
||||||
|
if (!file_dialog.filters.empty()) {
|
||||||
|
rows.push_back(hbox({
|
||||||
|
FocusLabel(text(" format: "), file_dialog.focus_idx == 0) | bold,
|
||||||
|
filter_toggle->Render(),
|
||||||
|
}));
|
||||||
|
rows.push_back(separator());
|
||||||
|
}
|
||||||
|
rows.push_back(hbox({text(" dir: ") | dim, text(file_dialog.dir)}));
|
||||||
|
rows.push_back(separator());
|
||||||
|
int entries_focus = file_dialog.filters.empty() ? 0 : 1;
|
||||||
|
int fname_focus = file_dialog.filters.empty() ? 1 : 2;
|
||||||
|
int btn_focus = file_dialog.filters.empty() ? 2 : 3;
|
||||||
|
rows.push_back(FocusLabel(text(" entries "),
|
||||||
|
file_dialog.focus_idx == entries_focus) | bold);
|
||||||
|
rows.push_back(entries_menu->Render() | vscroll_indicator | yframe
|
||||||
|
| size(HEIGHT, LESS_THAN, 16) | size(HEIGHT, GREATER_THAN, 6));
|
||||||
|
rows.push_back(separator());
|
||||||
|
rows.push_back(hbox({
|
||||||
|
FocusLabel(text(" filename: "), file_dialog.focus_idx == fname_focus),
|
||||||
|
filename_input->Render() | flex,
|
||||||
|
}) | border);
|
||||||
|
rows.push_back(separator());
|
||||||
|
rows.push_back(hbox({filler(),
|
||||||
|
FocusLabel(button->Render(),
|
||||||
|
file_dialog.focus_idx == btn_focus),
|
||||||
|
filler()}));
|
||||||
|
rows.push_back(status);
|
||||||
|
rows.push_back(text(" Tab cycle • Enter: cd / pick / confirm • Esc cancel ") | dim);
|
||||||
|
|
||||||
|
return vbox(std::move(rows))
|
||||||
|
| borderRounded
|
||||||
|
| size(WIDTH, LESS_THAN, 80)
|
||||||
|
| size(WIDTH, GREATER_THAN, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -159,6 +159,24 @@ const std::vector<std::pair<std::string, std::string>> &topics() {
|
|||||||
"v1` header. The S-record line tags a signal type only when it "
|
"v1` header. The S-record line tags a signal type only when it "
|
||||||
"differs from the default (Other), keeping snapshots small."},
|
"differs from the default (Other), keeping snapshots small."},
|
||||||
|
|
||||||
|
{"Export",
|
||||||
|
"`export connections <file>` dumps every wire pair to an "
|
||||||
|
"external file. Picking the extension picks the format: "
|
||||||
|
"`.csv` writes one flat file (one row per pin↔pin), `.ods` "
|
||||||
|
"writes a multi-sheet OpenDocument workbook (one sheet per "
|
||||||
|
"connection). Each row carries the signals on both sides, "
|
||||||
|
"their types, a `suspect` flag (name suggests Power but the "
|
||||||
|
"structural check disagreed) and a `mixed` flag (both sides "
|
||||||
|
"typed but disagreeing).\n\n"
|
||||||
|
"Bare `export` (or the dashboard `[x]` shortcut, or the "
|
||||||
|
"palette entry `export`) opens an interactive file-picker "
|
||||||
|
"dialog. A toggle at the top of the dialog switches between "
|
||||||
|
"CSV and ODS — picking either rewrites the filename's "
|
||||||
|
"extension to match. The dialog remembers the last used "
|
||||||
|
"directory and filename for next time.\n\n"
|
||||||
|
"An unknown extension produces an error popup and writes "
|
||||||
|
"nothing — never a silent wrong-format dump."},
|
||||||
|
|
||||||
{"Quitting",
|
{"Quitting",
|
||||||
"From the dashboard: press `q`, or run the `quit` (or `exit`) "
|
"From the dashboard: press `q`, or run the `quit` (or `exit`) "
|
||||||
"command from the console / palette. Quit works from any "
|
"command from the console / palette. Quit works from any "
|
||||||
@@ -216,7 +234,7 @@ Component Tui::BuildHelpScreen() {
|
|||||||
text(" topic ") | bold,
|
text(" topic ") | bold,
|
||||||
separator(),
|
separator(),
|
||||||
topic_menu->Render() | yframe | flex,
|
topic_menu->Render() | yframe | flex,
|
||||||
}) | size(WIDTH, EQUAL, 28);
|
}) | size(WIDTH, LESS_THAN, 30) | size(WIDTH, GREATER_THAN, 16);
|
||||||
|
|
||||||
auto center = vbox(std::move(body))
|
auto center = vbox(std::move(body))
|
||||||
| vscroll_indicator | yframe | flex;
|
| vscroll_indicator | yframe | flex;
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ Component Tui::BuildSettypeScreen() {
|
|||||||
auto left = vbox({
|
auto left = vbox({
|
||||||
FocusLabel(text(" module "), settype_focus_idx == 0) | bold,
|
FocusLabel(text(" module "), settype_focus_idx == 0) | bold,
|
||||||
module_menu->Render() | yframe | flex,
|
module_menu->Render() | yframe | flex,
|
||||||
}) | size(WIDTH, EQUAL, 28);
|
}) | size(WIDTH, LESS_THAN, 30) | size(WIDTH, GREATER_THAN, 16);
|
||||||
|
|
||||||
auto middle = vbox({
|
auto middle = vbox({
|
||||||
hbox({FocusLabel(text(" filter: "), settype_focus_idx == 1),
|
hbox({FocusLabel(text(" filter: "), settype_focus_idx == 1),
|
||||||
|
|||||||
@@ -16,6 +16,21 @@
|
|||||||
#include <system_error>
|
#include <system_error>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
|
||||||
|
void Tui::BootDispatch(const std::string &raw) {
|
||||||
|
// Called before Run() — no FTXUI screen yet, so any `Source()` invoked
|
||||||
|
// from here takes the headless synchronous branch (drains the script
|
||||||
|
// without the event-paced loop). Output goes to `output` and is
|
||||||
|
// visible as soon as the screen starts.
|
||||||
|
//
|
||||||
|
// We pin `screen_idx = 0` (console) for the duration of the dispatch
|
||||||
|
// so the source loop's "is interactive (would open a screen)" guard
|
||||||
|
// doesn't trip on the constructor's boot value of 4 (dashboard).
|
||||||
|
// After execution, restore to 4 so the user lands on the dashboard.
|
||||||
|
screen_idx = 0;
|
||||||
|
Dispatch(raw);
|
||||||
|
screen_idx = 4;
|
||||||
|
}
|
||||||
|
|
||||||
void Tui::OpenSignalTypeDialog(const std::string &mod_name,
|
void Tui::OpenSignalTypeDialog(const std::string &mod_name,
|
||||||
const std::string &sig_name) {
|
const std::string &sig_name) {
|
||||||
if (!sys) return;
|
if (!sys) return;
|
||||||
@@ -228,26 +243,73 @@ std::string Tui::ExpandVars(const std::string &s) const {
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
std::filesystem::path HistoryPath() {
|
// User-data dir, platform-aware. Returns the base directory under which
|
||||||
|
// every persistent file lives (history, export-last, …). Empty if none
|
||||||
|
// of the expected env vars are set.
|
||||||
|
std::filesystem::path UserDataDir() {
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
if (const char *p = std::getenv("LOCALAPPDATA"); p && *p)
|
if (const char *p = std::getenv("LOCALAPPDATA"); p && *p)
|
||||||
return fs::path(p) / "essim" / "history";
|
return fs::path(p) / "essim";
|
||||||
if (const char *p = std::getenv("APPDATA"); p && *p)
|
if (const char *p = std::getenv("APPDATA"); p && *p)
|
||||||
return fs::path(p) / "essim" / "history";
|
return fs::path(p) / "essim";
|
||||||
if (const char *p = std::getenv("USERPROFILE"); p && *p)
|
if (const char *p = std::getenv("USERPROFILE"); p && *p)
|
||||||
return fs::path(p) / "AppData" / "Local" / "essim" / "history";
|
return fs::path(p) / "AppData" / "Local" / "essim";
|
||||||
#else
|
#else
|
||||||
if (const char *p = std::getenv("XDG_DATA_HOME"); p && *p)
|
if (const char *p = std::getenv("XDG_DATA_HOME"); p && *p)
|
||||||
return fs::path(p) / "essim" / "history";
|
return fs::path(p) / "essim";
|
||||||
if (const char *p = std::getenv("HOME"); p && *p)
|
if (const char *p = std::getenv("HOME"); p && *p)
|
||||||
return fs::path(p) / ".local" / "share" / "essim" / "history";
|
return fs::path(p) / ".local" / "share" / "essim";
|
||||||
#endif
|
#endif
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::filesystem::path HistoryPath() {
|
||||||
|
auto d = UserDataDir();
|
||||||
|
return d.empty() ? std::filesystem::path{} : (d / "history");
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
// Free helpers for "last used (dir, filename) per purpose". Stored as a
|
||||||
|
// tiny two-line file per key under the user-data dir. Used by the file
|
||||||
|
// dialog so each call site (export, save, restore, …) gets its own
|
||||||
|
// memory without further plumbing.
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::filesystem::path LastUsedPath(const std::string &key) {
|
||||||
|
auto d = UserDataDir();
|
||||||
|
return d.empty() ? std::filesystem::path{} : (d / (key + ".last"));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void SaveLastUsed(const std::string &key,
|
||||||
|
const std::string &dir,
|
||||||
|
const std::string &filename) {
|
||||||
|
auto p = LastUsedPath(key);
|
||||||
|
if (p.empty()) return;
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::create_directories(p.parent_path(), ec);
|
||||||
|
std::ofstream f(p);
|
||||||
|
if (!f) return;
|
||||||
|
f << dir << '\n' << filename << '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool LoadLastUsed(const std::string &key,
|
||||||
|
std::string &dir, std::string &filename) {
|
||||||
|
auto p = LastUsedPath(key);
|
||||||
|
if (p.empty()) return false;
|
||||||
|
std::ifstream f(p);
|
||||||
|
if (!f) return false;
|
||||||
|
std::string d, n;
|
||||||
|
if (std::getline(f, d) && std::getline(f, n) && !d.empty() && !n.empty()) {
|
||||||
|
dir = d; filename = n;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void Tui::LoadHistory() {
|
void Tui::LoadHistory() {
|
||||||
auto p = HistoryPath();
|
auto p = HistoryPath();
|
||||||
if (p.empty()) return;
|
if (p.empty()) return;
|
||||||
@@ -365,6 +427,7 @@ void Tui::DumpCommandsMd(std::ostream &out) const {
|
|||||||
bool printed_title = false;
|
bool printed_title = false;
|
||||||
for (const auto &kv : commands) {
|
for (const auto &kv : commands) {
|
||||||
const CommandSpec &spec = kv.second;
|
const CommandSpec &spec = kv.second;
|
||||||
|
if (spec.hidden) continue; // aliases & internal entries
|
||||||
if (spec.interactive != want_interactive) continue;
|
if (spec.interactive != want_interactive) continue;
|
||||||
if (!printed_title) {
|
if (!printed_title) {
|
||||||
out << "\n## " << title << "\n\n";
|
out << "\n## " << title << "\n\n";
|
||||||
|
|||||||
@@ -48,12 +48,18 @@ void Tui::Run() {
|
|||||||
dashboard_screen, analyze_screen, help_screen},
|
dashboard_screen, analyze_screen, help_screen},
|
||||||
&screen_idx);
|
&screen_idx);
|
||||||
|
|
||||||
// Palette is a global Modal — overlays the tab on every screen.
|
// Palette + export are global Modals — overlay the tab on every
|
||||||
|
// screen. Stack the decorators (last one wraps innermost).
|
||||||
auto with_palette = tab | Modal(BuildPaletteModal(), &palette_open);
|
auto with_palette = tab | Modal(BuildPaletteModal(), &palette_open);
|
||||||
|
auto with_dialog = with_palette
|
||||||
|
| Modal(BuildFileDialog(), &file_dialog.open);
|
||||||
|
auto with_error = with_dialog
|
||||||
|
| Modal(BuildErrorModal(), &error_open);
|
||||||
|
|
||||||
auto root = CatchEvent(with_palette, [this](Event e) {
|
auto root = CatchEvent(with_error, [this](Event e) {
|
||||||
// Modals (palette + sigtype popup) own their events while open.
|
// Modals own their events while open. Error modal has priority.
|
||||||
if (palette_open || sigtype_dialog_open) return false;
|
if (error_open || palette_open || sigtype_dialog_open
|
||||||
|
|| file_dialog.open) return false;
|
||||||
|
|
||||||
// Ctrl-P opens the palette from any screen.
|
// Ctrl-P opens the palette from any screen.
|
||||||
if (e == Event::CtrlP) { OpenPalette(); return true; }
|
if (e == Event::CtrlP) { OpenPalette(); return true; }
|
||||||
@@ -92,12 +98,15 @@ void Tui::Run() {
|
|||||||
if (e == Event::Character("e")) { Dispatch("explore"); return true; }
|
if (e == Event::Character("e")) { Dispatch("explore"); return true; }
|
||||||
if (e == Event::Character("a")) { screen_idx = 5; return true; }
|
if (e == Event::Character("a")) { screen_idx = 5; return true; }
|
||||||
if (e == Event::Character("h")) { screen_idx = 6; return true; }
|
if (e == Event::Character("h")) { screen_idx = 6; return true; }
|
||||||
|
if (e == Event::Character("x")) { Dispatch("export"); return true; }
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
case 3: // explore
|
case 3: // explore
|
||||||
if (e == Event::Escape) { screen_idx = 4; return true; }
|
if (e == Event::Escape) { screen_idx = 4; return true; }
|
||||||
if (e == Event::Tab) { explore_focus_idx = (explore_focus_idx + 1) % 6; return true; }
|
// 7 focusable fields: module, type, child-filter, children,
|
||||||
if (e == Event::TabReverse) { explore_focus_idx = (explore_focus_idx + 5) % 6; return true; }
|
// detail-filter, detail-pins (top), detail-nets (bottom).
|
||||||
|
if (e == Event::Tab) { explore_focus_idx = (explore_focus_idx + 1) % 7; return true; }
|
||||||
|
if (e == Event::TabReverse) { explore_focus_idx = (explore_focus_idx + 6) % 7; return true; }
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
case 2: // set-connector-type
|
case 2: // set-connector-type
|
||||||
|
|||||||
@@ -84,8 +84,13 @@ class Tui {
|
|||||||
std::string explore_child_filter;
|
std::string explore_child_filter;
|
||||||
std::string explore_detail_filter;
|
std::string explore_detail_filter;
|
||||||
std::vector<std::string> explore_detail;
|
std::vector<std::string> explore_detail;
|
||||||
std::vector<std::string> explore_detail_sig; ///< parallel: signal name per detail line (empty = no signal)
|
std::vector<std::string> explore_detail_sig; ///< parallel: `module\tsignal` per row (empty = no action)
|
||||||
int explore_detail_idx;
|
int explore_detail_idx;
|
||||||
|
// Second detail sub-panel — populated only when the children pane is on
|
||||||
|
// the signals tab (top = local pins, bottom = net members).
|
||||||
|
std::vector<std::string> explore_detail2;
|
||||||
|
std::vector<std::string> explore_detail2_sig;
|
||||||
|
int explore_detail2_idx = 0;
|
||||||
std::string explore_header;
|
std::string explore_header;
|
||||||
int explore_focus_idx;
|
int explore_focus_idx;
|
||||||
|
|
||||||
@@ -103,6 +108,45 @@ class Tui {
|
|||||||
// ---- Dashboard scroll state (0 = top; grows as the user scrolls down) ----
|
// ---- Dashboard scroll state (0 = top; grows as the user scrolls down) ----
|
||||||
int dashboard_scroll_offset = 0;
|
int dashboard_scroll_offset = 0;
|
||||||
|
|
||||||
|
// ---- Generic error-notification dialog (modal) ----
|
||||||
|
bool error_open = false;
|
||||||
|
std::string error_message;
|
||||||
|
|
||||||
|
// ---- Generic file-picker dialog (modal) ----
|
||||||
|
// Reused for any "pick a path" interaction (export, save, restore, …).
|
||||||
|
// `persist_key` ties the dir + filename to a key persisted under the
|
||||||
|
// user-data directory so the next invocation under the same key
|
||||||
|
// re-opens on the same location. `on_confirm` runs the actual action
|
||||||
|
// when the user accepts a path.
|
||||||
|
//
|
||||||
|
// Optional `filters`: a horizontal selector (Toggle) at the top of
|
||||||
|
// the dialog with (label, extension) pairs. Picking a filter rewrites
|
||||||
|
// the filename's extension to match. The action then dispatches on
|
||||||
|
// extension (the dialog stays format-agnostic).
|
||||||
|
public:
|
||||||
|
struct FilenameFilter { std::string label; std::string extension; };
|
||||||
|
private:
|
||||||
|
struct FileDialogState {
|
||||||
|
bool open = false;
|
||||||
|
std::string title;
|
||||||
|
std::string persist_key;
|
||||||
|
std::string dir;
|
||||||
|
std::string filename;
|
||||||
|
std::vector<std::string> entries; ///< rebuilt every frame
|
||||||
|
std::vector<bool> entries_is_dir;
|
||||||
|
int entry_idx = 0;
|
||||||
|
// Focus map (variable: 0=filters [if any], else +1 each next slot):
|
||||||
|
// filters present → 0=filters 1=entries 2=filename 3=button (4 slots)
|
||||||
|
// no filters → 0=entries 1=filename 2=button (3 slots)
|
||||||
|
int focus_idx = 0;
|
||||||
|
std::string status;
|
||||||
|
std::vector<FilenameFilter> filters;
|
||||||
|
std::vector<std::string> filter_labels; ///< parallel to `filters`
|
||||||
|
int filter_idx = 0;
|
||||||
|
std::function<void(const std::string &)> on_confirm;
|
||||||
|
};
|
||||||
|
FileDialogState file_dialog;
|
||||||
|
|
||||||
// ---- Help screen state ----
|
// ---- Help screen state ----
|
||||||
int help_topic_idx = 0;
|
int help_topic_idx = 0;
|
||||||
std::vector<std::string> help_topic_names; ///< populated by BuildHelpScreen
|
std::vector<std::string> help_topic_names; ///< populated by BuildHelpScreen
|
||||||
@@ -149,6 +193,11 @@ public:
|
|||||||
void Run();
|
void Run();
|
||||||
void DumpCommandsMd(std::ostream &out) const;
|
void DumpCommandsMd(std::ostream &out) const;
|
||||||
|
|
||||||
|
// Boot-time hook: dispatch a single command exactly as if the user
|
||||||
|
// typed it (e.g. `restore foo.essim` or `source bring-up.essim`).
|
||||||
|
// Call before `Run()` to seed the system before the event loop starts.
|
||||||
|
void BootDispatch(const std::string &raw);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Lifecycle (commands.cpp)
|
// Lifecycle (commands.cpp)
|
||||||
void RegisterCommands();
|
void RegisterCommands();
|
||||||
@@ -197,6 +246,22 @@ private:
|
|||||||
ftxui::Component BuildDashboardScreen();
|
ftxui::Component BuildDashboardScreen();
|
||||||
ftxui::Component BuildAnalyzeScreen();
|
ftxui::Component BuildAnalyzeScreen();
|
||||||
ftxui::Component BuildHelpScreen();
|
ftxui::Component BuildHelpScreen();
|
||||||
|
ftxui::Component BuildFileDialog();
|
||||||
|
ftxui::Component BuildErrorModal();
|
||||||
|
// Pop a centred modal with `msg` and an OK button. Esc / Enter close
|
||||||
|
// it. Use for actionable failures the user must see (write errors,
|
||||||
|
// bad inputs, etc.) — for normal feedback keep `Print()`.
|
||||||
|
void ShowError(const std::string &msg);
|
||||||
|
// Open the picker modal. `persist_key` controls where the last-used
|
||||||
|
// dir + filename are stored (one tiny file per key under the user
|
||||||
|
// data directory). `on_confirm` runs when the user presses Enter on
|
||||||
|
// the action button — it receives the absolute path the user picked.
|
||||||
|
void OpenFileDialog(std::string title,
|
||||||
|
std::string persist_key,
|
||||||
|
std::string default_filename,
|
||||||
|
std::vector<FilenameFilter> filters,
|
||||||
|
std::function<void(const std::string &)> on_confirm);
|
||||||
|
void ConfirmFileDialog();
|
||||||
ftxui::Component BuildSignalTypeModal();
|
ftxui::Component BuildSignalTypeModal();
|
||||||
ftxui::Component BuildPaletteModal();
|
ftxui::Component BuildPaletteModal();
|
||||||
// Open palette (resets query/index, builds initial list).
|
// Open palette (resets query/index, builds initial list).
|
||||||
|
|||||||
@@ -21,11 +21,18 @@ Element RenderHelpPanel(const std::string &title,
|
|||||||
// borderRounded gives the panel a distinct visual boundary, so the
|
// borderRounded gives the panel a distinct visual boundary, so the
|
||||||
// user can find it without ambiguity even when the main content is
|
// user can find it without ambiguity even when the main content is
|
||||||
// dense (e.g. the analyze screen).
|
// dense (e.g. the analyze screen).
|
||||||
|
//
|
||||||
|
// Width: target ~32 cols but allow shrinking down to ~20 on narrow
|
||||||
|
// terminals. Without the LESS_THAN/GREATER_THAN bounds, a strict
|
||||||
|
// EQUAL would cause the panel to be clipped to nothing when the
|
||||||
|
// viewport is narrower than 32 + main-content-min.
|
||||||
return vbox({
|
return vbox({
|
||||||
text(" " + title + " ") | bold | center,
|
text(" " + title + " ") | bold | center,
|
||||||
separator(),
|
separator(),
|
||||||
vbox(std::move(rows)),
|
vbox(std::move(rows)),
|
||||||
}) | borderRounded | size(WIDTH, EQUAL, 32);
|
}) | borderRounded
|
||||||
|
| size(WIDTH, LESS_THAN, 36)
|
||||||
|
| size(WIDTH, GREATER_THAN, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user