use single quote to toggle last symbol in tui

This commit is contained in:
Emil Lerch 2026-06-24 15:48:52 -07:00
parent a9b5b8fe19
commit 1fa9649bd6
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 163 additions and 12 deletions

View file

@ -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

View file

@ -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" {

View file

@ -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",
}); });