use single quote to toggle last symbol in tui
This commit is contained in:
parent
a9b5b8fe19
commit
1fa9649bd6
3 changed files with 163 additions and 12 deletions
5
TODO.md
5
TODO.md
|
|
@ -554,11 +554,6 @@ so they don't get lost; pick up opportunistically.
|
||||||
the interaction model — e.g. allow specifying an expiration date,
|
the interaction model — e.g. allow specifying an expiration date,
|
||||||
showing all monthlies expanded by default, or filtering by strategy
|
showing all monthlies expanded by default, or filtering by strategy
|
||||||
(covered calls, spreads).
|
(covered calls, spreads).
|
||||||
- **TUI: toggle to last symbol keybind.** A single-key toggle that
|
|
||||||
flips between the current symbol and the previously selected one
|
|
||||||
(like `cd -` in bash or `Ctrl+^` in vim). Store `last_symbol` on
|
|
||||||
`App`; on symbol change, stash the previous. Useful for
|
|
||||||
eyeball-comparing performance/risk data between two symbols.
|
|
||||||
|
|
||||||
### Options / valuation
|
### Options / valuation
|
||||||
|
|
||||||
|
|
|
||||||
167
src/tui.zig
167
src/tui.zig
|
|
@ -258,6 +258,57 @@ pub fn buildHelpLines(
|
||||||
return lines.toOwnedSlice(arena);
|
return lines.toOwnedSlice(arena);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decide whether the outgoing active symbol should be stashed as the
|
||||||
|
/// "last symbol" when switching to `incoming`. Stash only a non-empty
|
||||||
|
/// symbol that actually differs (case-insensitively) from the one
|
||||||
|
/// we're switching to, so `toggle_last_symbol` never records the
|
||||||
|
/// symbol you're already on as the thing to flip back to.
|
||||||
|
fn shouldStashSymbol(outgoing: []const u8, incoming: []const u8) bool {
|
||||||
|
return outgoing.len > 0 and !std.ascii.eqlIgnoreCase(outgoing, incoming);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post-swap state from `computeSymbolToggle`: the new active symbol
|
||||||
|
/// and the new last symbol, each slicing into the buffer passed for it.
|
||||||
|
const SymbolToggle = struct {
|
||||||
|
current: []const u8,
|
||||||
|
last: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Swap the active symbol with the last-active symbol, writing the
|
||||||
|
/// results into the two backing buffers and returning slices into
|
||||||
|
/// them. Returns `null` when there is no previous symbol to toggle to
|
||||||
|
/// (`last` empty).
|
||||||
|
///
|
||||||
|
/// Aliasing-safe: `current` and/or `last` may already point into
|
||||||
|
/// `current_buf` / `last_buf`. The outgoing current is copied to a
|
||||||
|
/// stack temp before either buffer is overwritten, so the swap holds
|
||||||
|
/// regardless of where the inputs live. Inputs longer than a buffer
|
||||||
|
/// are clamped to the buffer length (symbols are <= 16 bytes in
|
||||||
|
/// practice; the clamp just prevents an out-of-bounds copy).
|
||||||
|
fn computeSymbolToggle(
|
||||||
|
current: []const u8,
|
||||||
|
last: []const u8,
|
||||||
|
current_buf: *[16]u8,
|
||||||
|
last_buf: *[16]u8,
|
||||||
|
) ?SymbolToggle {
|
||||||
|
if (last.len == 0) return null;
|
||||||
|
|
||||||
|
// SAFETY: only tmp[0..cur_len] is written (below) before being
|
||||||
|
// read back; the tail is never observed.
|
||||||
|
var tmp: [16]u8 = undefined;
|
||||||
|
const cur_len = @min(current.len, tmp.len);
|
||||||
|
@memcpy(tmp[0..cur_len], current[0..cur_len]);
|
||||||
|
|
||||||
|
const new_cur_len = @min(last.len, current_buf.len);
|
||||||
|
@memcpy(current_buf[0..new_cur_len], last[0..new_cur_len]);
|
||||||
|
@memcpy(last_buf[0..cur_len], tmp[0..cur_len]);
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.current = current_buf[0..new_cur_len],
|
||||||
|
.last = last_buf[0..cur_len],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-tab state, owned by `App` and accessed as `app.states.<tab>`.
|
/// Per-tab state, owned by `App` and accessed as `app.states.<tab>`.
|
||||||
///
|
///
|
||||||
/// Per-tab private state aggregator, derived at comptime from the
|
/// Per-tab private state aggregator, derived at comptime from the
|
||||||
|
|
@ -454,6 +505,14 @@ pub const App = struct {
|
||||||
// is unobservable.
|
// is unobservable.
|
||||||
symbol_buf: [16]u8 = undefined,
|
symbol_buf: [16]u8 = undefined,
|
||||||
symbol_owned: bool = false,
|
symbol_owned: bool = false,
|
||||||
|
/// The previously-active symbol, for the `toggle_last_symbol`
|
||||||
|
/// keybind (flip between current and prior, like `cd -`). Empty
|
||||||
|
/// until at least one symbol change has occurred. Bytes live in
|
||||||
|
/// `last_symbol_buf`.
|
||||||
|
last_symbol: []const u8 = "",
|
||||||
|
// SAFETY: paired with `last_symbol`; only the prefix
|
||||||
|
// last_symbol_buf[0..last_symbol.len] is ever read.
|
||||||
|
last_symbol_buf: [16]u8 = undefined,
|
||||||
/// Symbol the overlay popup is currently showing details for.
|
/// Symbol the overlay popup is currently showing details for.
|
||||||
/// Empty when the overlay is closed. Bytes live in
|
/// Empty when the overlay is closed. Bytes live in
|
||||||
/// `overlay_symbol_buf`. Set/cleared via `toggleOverlay`.
|
/// `overlay_symbol_buf`. Set/cleared via `toggleOverlay`.
|
||||||
|
|
@ -978,14 +1037,10 @@ pub const App = struct {
|
||||||
.edited => return ctx.consumeAndRedraw(),
|
.edited => return ctx.consumeAndRedraw(),
|
||||||
.ignored => {},
|
.ignored => {},
|
||||||
.committed => {
|
.committed => {
|
||||||
// Commit: uppercase the input, set as active symbol, switch to quote tab
|
// Commit: set as active symbol (uppercased + stashed
|
||||||
|
// by setActiveSymbol), then switch to the quote tab.
|
||||||
if (self.input_len > 0) {
|
if (self.input_len > 0) {
|
||||||
for (self.input_buf[0..self.input_len]) |*ch| ch.* = std.ascii.toUpper(ch.*);
|
self.setActiveSymbol(self.input_buf[0..self.input_len]);
|
||||||
@memcpy(self.symbol_buf[0..self.input_len], self.input_buf[0..self.input_len]);
|
|
||||||
self.symbol = self.symbol_buf[0..self.input_len];
|
|
||||||
self.symbol_owned = true;
|
|
||||||
self.has_explicit_symbol = true;
|
|
||||||
self.resetSymbolData();
|
|
||||||
self.active_tab = .quote;
|
self.active_tab = .quote;
|
||||||
self.loadTabData();
|
self.loadTabData();
|
||||||
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
|
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
|
||||||
|
|
@ -1022,6 +1077,13 @@ pub const App = struct {
|
||||||
self.input_len = 0;
|
self.input_len = 0;
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
},
|
},
|
||||||
|
.toggle_last_symbol => {
|
||||||
|
if (self.toggleLastSymbol()) {
|
||||||
|
self.loadTabData();
|
||||||
|
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
|
||||||
|
}
|
||||||
|
return ctx.consumeAndRedraw();
|
||||||
|
},
|
||||||
.refresh => {
|
.refresh => {
|
||||||
// Two-phase so the "Refreshing..." indicator paints
|
// Two-phase so the "Refreshing..." indicator paints
|
||||||
// before the (blocking) refresh runs. Flag it and
|
// before the (blocking) refresh runs. Flag it and
|
||||||
|
|
@ -1201,6 +1263,13 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn setActiveSymbol(self: *App, sym: []const u8) void {
|
pub fn setActiveSymbol(self: *App, sym: []const u8) void {
|
||||||
|
// Stash the outgoing symbol so `toggle_last_symbol` can flip
|
||||||
|
// back to it. Must run before symbol_buf is overwritten below.
|
||||||
|
if (shouldStashSymbol(self.symbol, sym)) {
|
||||||
|
const llen = @min(self.symbol.len, self.last_symbol_buf.len);
|
||||||
|
@memcpy(self.last_symbol_buf[0..llen], self.symbol[0..llen]);
|
||||||
|
self.last_symbol = self.last_symbol_buf[0..llen];
|
||||||
|
}
|
||||||
const len = @min(sym.len, self.symbol_buf.len);
|
const len = @min(sym.len, self.symbol_buf.len);
|
||||||
@memcpy(self.symbol_buf[0..len], sym[0..len]);
|
@memcpy(self.symbol_buf[0..len], sym[0..len]);
|
||||||
for (self.symbol_buf[0..len]) |*c| c.* = std.ascii.toUpper(c.*);
|
for (self.symbol_buf[0..len]) |*c| c.* = std.ascii.toUpper(c.*);
|
||||||
|
|
@ -1210,6 +1279,28 @@ pub const App = struct {
|
||||||
self.resetSymbolData();
|
self.resetSymbolData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Flip the active symbol to the previously-active one (and vice
|
||||||
|
/// versa). Backs the `toggle_last_symbol` keybind. Returns false
|
||||||
|
/// when there's no previous symbol yet (nothing to toggle to).
|
||||||
|
///
|
||||||
|
/// Does NOT route through `setActiveSymbol`: the swap inherently
|
||||||
|
/// stashes the outgoing symbol as the new `last_symbol`, so an
|
||||||
|
/// extra stash would be redundant (and would alias the buffers).
|
||||||
|
fn toggleLastSymbol(self: *App) bool {
|
||||||
|
const swapped = computeSymbolToggle(
|
||||||
|
self.symbol,
|
||||||
|
self.last_symbol,
|
||||||
|
&self.symbol_buf,
|
||||||
|
&self.last_symbol_buf,
|
||||||
|
) orelse return false;
|
||||||
|
self.symbol = swapped.current;
|
||||||
|
self.last_symbol = swapped.last;
|
||||||
|
self.symbol_owned = true;
|
||||||
|
self.has_explicit_symbol = true;
|
||||||
|
self.resetSymbolData();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Open / close / re-target the symbol-info overlay. Behavior:
|
/// Open / close / re-target the symbol-info overlay. Behavior:
|
||||||
/// - overlay closed → open with `sym`.
|
/// - overlay closed → open with `sym`.
|
||||||
/// - overlay open, same `sym` as cursor → close.
|
/// - overlay open, same `sym` as cursor → close.
|
||||||
|
|
@ -2722,6 +2813,68 @@ test "formatStatusHint: single fragment has no separator" {
|
||||||
try testing.expectEqualStrings("ctrl+s save", out);
|
try testing.expectEqualStrings("ctrl+s save", out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── symbol toggle helpers ─────────────────────────────────────
|
||||||
|
|
||||||
|
test "shouldStashSymbol: stashes a differing non-empty symbol" {
|
||||||
|
try testing.expect(shouldStashSymbol("AAPL", "MSFT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "shouldStashSymbol: skips empty outgoing symbol" {
|
||||||
|
try testing.expect(!shouldStashSymbol("", "MSFT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "shouldStashSymbol: skips when unchanged (case-insensitive)" {
|
||||||
|
try testing.expect(!shouldStashSymbol("AAPL", "aapl"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "computeSymbolToggle: null when there is no previous symbol" {
|
||||||
|
var cur = [_]u8{0} ** 16;
|
||||||
|
var last = [_]u8{0} ** 16;
|
||||||
|
try testing.expect(computeSymbolToggle("AAPL", "", &cur, &last) == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "computeSymbolToggle: swaps non-aliased inputs" {
|
||||||
|
var cur = [_]u8{0} ** 16;
|
||||||
|
var last = [_]u8{0} ** 16;
|
||||||
|
const r = computeSymbolToggle("AAPL", "MSFT", &cur, &last).?;
|
||||||
|
try testing.expectEqualStrings("MSFT", r.current);
|
||||||
|
try testing.expectEqualStrings("AAPL", r.last);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "computeSymbolToggle: aliasing-safe when slices point into the buffers" {
|
||||||
|
var cur = [_]u8{0} ** 16;
|
||||||
|
var last = [_]u8{0} ** 16;
|
||||||
|
@memcpy(cur[0..4], "AAPL");
|
||||||
|
@memcpy(last[0..4], "MSFT");
|
||||||
|
const r = computeSymbolToggle(cur[0..4], last[0..4], &cur, &last).?;
|
||||||
|
try testing.expectEqualStrings("MSFT", r.current);
|
||||||
|
try testing.expectEqualStrings("AAPL", r.last);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "computeSymbolToggle: double toggle returns to the original" {
|
||||||
|
var cur = [_]u8{0} ** 16;
|
||||||
|
var last = [_]u8{0} ** 16;
|
||||||
|
@memcpy(cur[0..4], "AAPL");
|
||||||
|
@memcpy(last[0..4], "MSFT");
|
||||||
|
|
||||||
|
var r = computeSymbolToggle(cur[0..4], last[0..4], &cur, &last).?;
|
||||||
|
try testing.expectEqualStrings("MSFT", r.current);
|
||||||
|
try testing.expectEqualStrings("AAPL", r.last);
|
||||||
|
|
||||||
|
r = computeSymbolToggle(r.current, r.last, &cur, &last).?;
|
||||||
|
try testing.expectEqualStrings("AAPL", r.current);
|
||||||
|
try testing.expectEqualStrings("MSFT", r.last);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "computeSymbolToggle: clamps inputs longer than the buffer" {
|
||||||
|
var cur = [_]u8{0} ** 16;
|
||||||
|
var last = [_]u8{0} ** 16;
|
||||||
|
const r = computeSymbolToggle("X", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", &cur, &last).?;
|
||||||
|
try testing.expectEqual(@as(usize, 16), r.current.len);
|
||||||
|
try testing.expectEqualStrings("ABCDEFGHIJKLMNOP", r.current);
|
||||||
|
try testing.expectEqualStrings("X", r.last);
|
||||||
|
}
|
||||||
|
|
||||||
// ── interactive parseArgs ─────────────────────────────────────
|
// ── interactive parseArgs ─────────────────────────────────────
|
||||||
|
|
||||||
test "parseArgs: no args yields defaults" {
|
test "parseArgs: no args yields defaults" {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ pub const Action = enum {
|
||||||
select_next,
|
select_next,
|
||||||
select_prev,
|
select_prev,
|
||||||
symbol_input,
|
symbol_input,
|
||||||
|
toggle_last_symbol,
|
||||||
help,
|
help,
|
||||||
reload_portfolio,
|
reload_portfolio,
|
||||||
};
|
};
|
||||||
|
|
@ -143,6 +144,7 @@ pub const global_default_bindings = [_]Binding{
|
||||||
.{ .action = .select_prev, .key = .{ .codepoint = 'k' } },
|
.{ .action = .select_prev, .key = .{ .codepoint = 'k' } },
|
||||||
.{ .action = .select_prev, .key = .{ .codepoint = vaxis.Key.up } },
|
.{ .action = .select_prev, .key = .{ .codepoint = vaxis.Key.up } },
|
||||||
.{ .action = .symbol_input, .key = .{ .codepoint = '/' } },
|
.{ .action = .symbol_input, .key = .{ .codepoint = '/' } },
|
||||||
|
.{ .action = .toggle_last_symbol, .key = .{ .codepoint = '\'' } },
|
||||||
.{ .action = .help, .key = .{ .codepoint = '?' } },
|
.{ .action = .help, .key = .{ .codepoint = '?' } },
|
||||||
.{ .action = .reload_portfolio, .key = .{ .codepoint = 'R' } },
|
.{ .action = .reload_portfolio, .key = .{ .codepoint = 'R' } },
|
||||||
};
|
};
|
||||||
|
|
@ -178,6 +180,7 @@ pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{
|
||||||
.select_next = "Select next",
|
.select_next = "Select next",
|
||||||
.select_prev = "Select previous",
|
.select_prev = "Select previous",
|
||||||
.symbol_input = "Symbol input",
|
.symbol_input = "Symbol input",
|
||||||
|
.toggle_last_symbol = "Toggle last symbol",
|
||||||
.help = "Help",
|
.help = "Help",
|
||||||
.reload_portfolio = "Reload portfolio",
|
.reload_portfolio = "Reload portfolio",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue