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,
showing all monthlies expanded by default, or filtering by strategy
(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

View file

@ -258,6 +258,57 @@ pub fn buildHelpLines(
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 private state aggregator, derived at comptime from the
@ -454,6 +505,14 @@ pub const App = struct {
// is unobservable.
symbol_buf: [16]u8 = undefined,
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.
/// Empty when the overlay is closed. Bytes live in
/// `overlay_symbol_buf`. Set/cleared via `toggleOverlay`.
@ -978,14 +1037,10 @@ pub const App = struct {
.edited => return ctx.consumeAndRedraw(),
.ignored => {},
.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) {
for (self.input_buf[0..self.input_len]) |*ch| ch.* = std.ascii.toUpper(ch.*);
@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.setActiveSymbol(self.input_buf[0..self.input_len]);
self.active_tab = .quote;
self.loadTabData();
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
@ -1022,6 +1077,13 @@ pub const App = struct {
self.input_len = 0;
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 => {
// Two-phase so the "Refreshing..." indicator paints
// 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 {
// 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);
@memcpy(self.symbol_buf[0..len], sym[0..len]);
for (self.symbol_buf[0..len]) |*c| c.* = std.ascii.toUpper(c.*);
@ -1210,6 +1279,28 @@ pub const App = struct {
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:
/// - overlay closed open with `sym`.
/// - 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);
}
// 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
test "parseArgs: no args yields defaults" {

View file

@ -25,6 +25,7 @@ pub const Action = enum {
select_next,
select_prev,
symbol_input,
toggle_last_symbol,
help,
reload_portfolio,
};
@ -143,6 +144,7 @@ pub const global_default_bindings = [_]Binding{
.{ .action = .select_prev, .key = .{ .codepoint = 'k' } },
.{ .action = .select_prev, .key = .{ .codepoint = vaxis.Key.up } },
.{ .action = .symbol_input, .key = .{ .codepoint = '/' } },
.{ .action = .toggle_last_symbol, .key = .{ .codepoint = '\'' } },
.{ .action = .help, .key = .{ .codepoint = '?' } },
.{ .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_prev = "Select previous",
.symbol_input = "Symbol input",
.toggle_last_symbol = "Toggle last symbol",
.help = "Help",
.reload_portfolio = "Reload portfolio",
});