From ebef9c52e4989c2d1d45f633a66ad3a7f59dbc91 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 25 Jul 2026 10:33:44 -0700 Subject: [PATCH] endianness/more prog mode --- .kiro/specs/calculator/design.md | 32 ++++++++-- .kiro/specs/calculator/tasks.md | 6 +- engine/src/formatter.zig | 102 +++++++++++++++++++++---------- engine/src/types.zig | 5 +- src/main.zig | 4 +- src/tui.zig | 23 ++++++- src/tui/help.zig | 8 +-- src/tui/programmer.zig | 19 ++++-- 8 files changed, 146 insertions(+), 53 deletions(-) diff --git a/.kiro/specs/calculator/design.md b/.kiro/specs/calculator/design.md index fb9b458..dae9693 100644 --- a/.kiro/specs/calculator/design.md +++ b/.kiro/specs/calculator/design.md @@ -635,15 +635,35 @@ Navigation: value updates on Enter (or live as you type - TBD) - When bit grid is focused: left/right/up/down navigate, Space toggles - Tab cycles between Standard/Programmer mode -- Ctrl-W cycles bit width (8/16/32/64) +- Ctrl-W cycles bit width (8/16/32/64/128) - Ctrl-E toggles endianness display (LE/BE) +Bit width is a non-destructive display lens: +- The stored value keeps full precision (u128). Cycling the width never + truncates it, so narrowing to a smaller width and then widening again + restores the hidden upper bits. +- The DEC/HEX/OCT/BIN/ASCII rows and the bit grid show the value masked to + the current width. +- While the stored value has bits set beyond the current width, a warning + line is shown ("value exceeds N bits - showing low bits (widen to + restore)"). It is a warning, not an error: the value is still usable. +- Explicit value edits (typing a decimal digit, toggling a bit, editing a + hex/oct/bin digit) commit to the current width, clearing any hidden upper + bits. Only width cycling is non-destructive; deliberately editing at a + narrow width means you are now working at that width. + Endianness: -- Default: little-endian (matches x86-64) -- Toggle with Ctrl-E -- Affects: HEX byte display order, ASCII interpretation order -- Does NOT affect: the underlying u64 value, DEC display, BIN display -- Header shows current setting: `Endian: LE` or `Endian: BE` +- Toggle with Ctrl-E; header shows current setting (`Endian: LE` / `Endian: BE`) +- Default: big-endian. The HEX row's primary job is to show the value in hex, + so by default it reads MSB-first and matches DEC/OCT/BIN. Little-endian is + the opt-in "how it sits in x86-64 memory" view. +- Affects: HEX byte display order and ASCII byte order only. In LE the display + bytes are reversed (e.g. 0xDEADBEEF -> `EF BE AD DE`). +- Does NOT affect: the underlying value, DEC, OCT, or BIN display, nor the + clipboard `raw` form of HEX (which stays the canonical MSB-first number). +- Rationale for BE default (revised from an earlier LE default): defaulting to + LE would reverse the HEX row so it no longer matches the number shown in the + DEC/BIN rows directly beside it, which is surprising in a calculator context. Text/ASCII literals: - Single-quoted strings in expressions: `'ELF'`, `'RIFF'`, `'a'` diff --git a/.kiro/specs/calculator/tasks.md b/.kiro/specs/calculator/tasks.md index 217af0c..2dd27f8 100644 --- a/.kiro/specs/calculator/tasks.md +++ b/.kiro/specs/calculator/tasks.md @@ -263,7 +263,11 @@ Subcommands deferred until Phase 2 engine work is done. - Focused field is visually highlighted (label gets inverse color) - Removed Ctrl-B (field navigation replaces it) - Expression field works as before (full programmer-mode expression evaluation) -- Endianness indicator in header (display only, toggle not yet implemented) +- ASCII row under HEX (read-only, xxd-style, aligned column-for-column) +- Endianness toggle (Ctrl-E): flips HEX and ASCII byte order, header shows LE/BE. + Default big-endian so HEX reads as the number (matching DEC/OCT/BIN); LE is the + opt-in x86-64 memory-layout view. DEC/OCT/BIN and the hex `raw`/clipboard form + are unaffected. Covered by formatter unit tests (BE and LE for hex and ascii). - Verify: can arrow between fields, type hex value and see all fields update, toggle bits ### Task 5.3.2: Implement text/ASCII literal parsing [DONE] diff --git a/engine/src/formatter.zig b/engine/src/formatter.zig index 6938dd4..8e00858 100644 --- a/engine/src/formatter.zig +++ b/engine/src/formatter.zig @@ -14,6 +14,7 @@ const std = @import("std"); const types = @import("types.zig"); const BitWidth = types.BitWidth; +const Endianness = types.Endianness; /// A formatted value with both display and clipboard representations. pub const FormattedValue = struct { @@ -55,13 +56,15 @@ pub fn formatFloat(buf: []u8, value: f64) FormattedValue { } /// Format an integer for programmer mode hex display. -/// Display: "FF FF FF FF" (space per byte) -/// Raw: "0xFFFFFFFF" (no separators, with prefix) -pub fn formatHex(buf: []u8, value: u128, bit_width: BitWidth) FormattedValue { +/// Display: "FF FF FF FF" (space per byte), byte order per `endian`. +/// Raw: "0xFFFFFFFF" (no separators, canonical MSB-first value regardless of +/// endian, since raw is the number itself and copies to the clipboard as such). +pub fn formatHex(buf: []u8, value: u128, bit_width: BitWidth, endian: Endianness) FormattedValue { const width = bit_width.bits(); const hex_digits: usize = @as(usize, width) / 4; + const num_bytes: usize = @as(usize, width) / 8; - // Write raw first: "0x" + hex digits + // Write raw first: "0x" + hex digits, canonical MSB-first. buf[0] = '0'; buf[1] = 'x'; var pos: usize = 2; @@ -74,17 +77,20 @@ pub fn formatHex(buf: []u8, value: u128, bit_width: BitWidth) FormattedValue { } const raw = buf[0..pos]; - // Write display after raw: hex digits with space per byte (every 2 digits) + // Write display after raw: one byte at a time in `endian` order, + // space-separated. Only the byte order swaps; nibble order within a + // byte is always high-then-low. const display_start = pos; - i = 0; - while (i < hex_digits) : (i += 1) { - if (i > 0 and i % 2 == 0) { + var k: usize = 0; + while (k < num_bytes) : (k += 1) { + if (k > 0) { buf[pos] = ' '; pos += 1; } - const shift_amt: u7 = @intCast((hex_digits - 1 - i) * 4); - const nibble: u4 = @intCast((value >> shift_amt) & 0xF); - buf[pos] = hexDigit(nibble); + const byte = byteAt(value, num_bytes, k, endian); + buf[pos] = hexDigit(@intCast(byte >> 4)); + pos += 1; + buf[pos] = hexDigit(@intCast(byte & 0xF)); pos += 1; } const display = buf[display_start..pos]; @@ -92,6 +98,15 @@ pub fn formatHex(buf: []u8, value: u128, bit_width: BitWidth) FormattedValue { return .{ .display = display, .raw = raw }; } +/// Return the byte to display at position `k` (0 = leftmost) for a value of +/// `num_bytes` width, honoring endianness. Big-endian shows the most +/// significant byte first; little-endian shows the least significant first. +fn byteAt(value: u128, num_bytes: usize, k: usize, endian: Endianness) u8 { + const msb_index: usize = if (endian == .big) k else num_bytes - 1 - k; + const shift_amt: u7 = @intCast((num_bytes - 1 - msb_index) * 8); + return @intCast((value >> shift_amt) & 0xFF); +} + /// Format an integer for programmer mode binary display. /// Display: "1111 0000 1010 1100" (space per nibble) /// Raw: "0b1111000010101100" (no separators) @@ -170,23 +185,21 @@ pub fn formatOctal(buf: []u8, value: u128, bit_width: BitWidth) FormattedValue { return .{ .display = display, .raw = raw }; } /// Format an integer's bytes as an ASCII representation for programmer mode. -/// One glyph per byte, most-significant byte first (matching the hex byte view). +/// One glyph per byte in `endian` order (matching the hex byte view). /// Printable bytes (0x20-0x7E) render as themselves; every other byte renders /// as '.', matching the convention used by `xxd` and similar hex dumps. /// /// Display: " . . . a s c i i" - each byte is a leading space + glyph, /// space-separated, so the row lines up column-for-column under formatHex. /// Raw: "...ascii" - contiguous glyphs, clipboard-friendly. -pub fn formatAscii(buf: []u8, value: u128, bit_width: BitWidth) FormattedValue { +pub fn formatAscii(buf: []u8, value: u128, bit_width: BitWidth, endian: Endianness) FormattedValue { const num_bytes: usize = @as(usize, bit_width.bits()) / 8; - // Raw: contiguous glyphs, MSB first. + // Raw: contiguous glyphs, in display (endian) order. var pos: usize = 0; var k: usize = 0; while (k < num_bytes) : (k += 1) { - const shift_amt: u7 = @intCast((num_bytes - 1 - k) * 8); - const byte: u8 = @intCast((value >> shift_amt) & 0xFF); - buf[pos] = asciiGlyph(byte); + buf[pos] = asciiGlyph(byteAt(value, num_bytes, k, endian)); pos += 1; } const raw = buf[0..pos]; @@ -202,9 +215,7 @@ pub fn formatAscii(buf: []u8, value: u128, bit_width: BitWidth) FormattedValue { } buf[pos] = ' '; pos += 1; - const shift_amt: u7 = @intCast((num_bytes - 1 - k) * 8); - const byte: u8 = @intCast((value >> shift_amt) & 0xFF); - buf[pos] = asciiGlyph(byte); + buf[pos] = asciiGlyph(byteAt(value, num_bytes, k, endian)); pos += 1; } const display = buf[display_start..pos]; @@ -361,39 +372,61 @@ test "formatFloat: zero" { test "formatHex: 8-bit" { var buf: [256]u8 = undefined; - const result = formatHex(&buf, 0xFF, .bits8); + const result = formatHex(&buf, 0xFF, .bits8, .big); try testing.expectEqualStrings("FF", result.display); try testing.expectEqualStrings("0xFF", result.raw); } test "formatHex: 16-bit" { var buf: [256]u8 = undefined; - const result = formatHex(&buf, 0xABCD, .bits16); + const result = formatHex(&buf, 0xABCD, .bits16, .big); try testing.expectEqualStrings("AB CD", result.display); try testing.expectEqualStrings("0xABCD", result.raw); } test "formatHex: 32-bit with grouping" { var buf: [256]u8 = undefined; - const result = formatHex(&buf, 0xDEADBEEF, .bits32); + const result = formatHex(&buf, 0xDEADBEEF, .bits32, .big); try testing.expectEqualStrings("DE AD BE EF", result.display); try testing.expectEqualStrings("0xDEADBEEF", result.raw); } test "formatHex: 64-bit with grouping" { var buf: [256]u8 = undefined; - const result = formatHex(&buf, 0xDEAD_BEEF_CAFE_BABE, .bits64); + const result = formatHex(&buf, 0xDEAD_BEEF_CAFE_BABE, .bits64, .big); try testing.expectEqualStrings("DE AD BE EF CA FE BA BE", result.display); try testing.expectEqualStrings("0xDEADBEEFCAFEBABE", result.raw); } test "formatHex: zero 32-bit" { var buf: [256]u8 = undefined; - const result = formatHex(&buf, 0, .bits32); + const result = formatHex(&buf, 0, .bits32, .big); try testing.expectEqualStrings("00 00 00 00", result.display); try testing.expectEqualStrings("0x00000000", result.raw); } +test "formatHex: little-endian reverses byte display" { + var buf: [256]u8 = undefined; + const result = formatHex(&buf, 0xDEADBEEF, .bits32, .little); + // Bytes shown least-significant first (x86 memory order) + try testing.expectEqualStrings("EF BE AD DE", result.display); + // Raw stays the canonical number + try testing.expectEqualStrings("0xDEADBEEF", result.raw); +} + +test "formatHex: little-endian 64-bit" { + var buf: [256]u8 = undefined; + const result = formatHex(&buf, 0xDEAD_BEEF_CAFE_BABE, .bits64, .little); + try testing.expectEqualStrings("BE BA FE CA EF BE AD DE", result.display); + try testing.expectEqualStrings("0xDEADBEEFCAFEBABE", result.raw); +} + +test "formatHex: little-endian 8-bit is identical (single byte)" { + var buf: [256]u8 = undefined; + const result = formatHex(&buf, 0xFF, .bits8, .little); + try testing.expectEqualStrings("FF", result.display); +} + test "formatBinary: 8-bit" { var buf: [256]u8 = undefined; const result = formatBinary(&buf, 0xFF, .bits8); @@ -491,41 +524,48 @@ test "formatFloat: regular float" { test "formatAscii: printable word 64-bit" { var buf: [128]u8 = undefined; // 0x0000006173636969 -> bytes 00 00 00 61 73 63 69 69 -> "...ascii" - const result = formatAscii(&buf, 0x0000006173636969, .bits64); + const result = formatAscii(&buf, 0x0000006173636969, .bits64, .big); try testing.expectEqualStrings("...ascii", result.raw); // Display aligns one glyph per byte, space-separated try testing.expectEqualStrings(" . . . a s c i i", result.display); } +test "formatAscii: little-endian reverses byte order" { + var buf: [128]u8 = undefined; + // Same value, little-endian: bytes reversed -> "iicsa..." with trailing dots + const result = formatAscii(&buf, 0x0000006173636969, .bits64, .little); + try testing.expectEqualStrings("iicsa...", result.raw); +} + test "formatAscii: 8-bit printable" { var buf: [128]u8 = undefined; - const result = formatAscii(&buf, 0x41, .bits8); + const result = formatAscii(&buf, 0x41, .bits8, .big); try testing.expectEqualStrings("A", result.raw); try testing.expectEqualStrings(" A", result.display); } test "formatAscii: 8-bit non-printable becomes dot" { var buf: [128]u8 = undefined; - const result = formatAscii(&buf, 0x00, .bits8); + const result = formatAscii(&buf, 0x00, .bits8, .big); try testing.expectEqualStrings(".", result.raw); } test "formatAscii: control and high bytes become dots" { var buf: [128]u8 = undefined; // 0x1B (ESC) and 0xFF are both non-printable - const result = formatAscii(&buf, 0x1BFF, .bits16); + const result = formatAscii(&buf, 0x1BFF, .bits16, .big); try testing.expectEqualStrings("..", result.raw); } test "formatAscii: boundary bytes 0x20 and 0x7E are printable" { var buf: [128]u8 = undefined; // 0x20 = space, 0x7E = '~' - const result = formatAscii(&buf, 0x207E, .bits16); + const result = formatAscii(&buf, 0x207E, .bits16, .big); try testing.expectEqualStrings(" ~", result.raw); } test "formatAscii: 0x7F is non-printable" { var buf: [128]u8 = undefined; - const result = formatAscii(&buf, 0x7F, .bits8); + const result = formatAscii(&buf, 0x7F, .bits8, .big); try testing.expectEqualStrings(".", result.raw); } diff --git a/engine/src/types.zig b/engine/src/types.zig index 1826287..7bf4c50 100644 --- a/engine/src/types.zig +++ b/engine/src/types.zig @@ -105,7 +105,10 @@ pub const Value = union(enum) { pub const ProgrammerConfig = struct { bit_width: BitWidth = .bits64, signedness: Signedness = .signed, - display_endian: Endianness = .little, + /// Byte order for the HEX and ASCII rows only. Defaults to big-endian so + /// the HEX row reads as the number itself (matching DEC/OCT/BIN); the + /// little-endian view (x86 memory layout) is available via the toggle. + display_endian: Endianness = .big, }; /// All possible engine errors. diff --git a/src/main.zig b/src/main.zig index 69d367d..b6d0ff4 100644 --- a/src/main.zig +++ b/src/main.zig @@ -96,7 +96,7 @@ fn formatStandardMultiBase(buf: []u8, dec_display: []const u8, value: f64) CliRe var hex_buf: [256]u8 = undefined; var oct_buf: [256]u8 = undefined; var bin_buf: [512]u8 = undefined; - const hex = engine.formatter.formatHex(&hex_buf, int_val, bw); + const hex = engine.formatter.formatHex(&hex_buf, int_val, bw, .big); const oct = engine.formatter.formatOctal(&oct_buf, int_val, bw); const bin = engine.formatter.formatBinary(&bin_buf, int_val, bw); @@ -127,7 +127,7 @@ fn formatProgrammerResult(buf: []u8, result: engine.types.Integer, config: engin var oct_buf: [256]u8 = undefined; var bin_buf: [512]u8 = undefined; - const hex = engine.formatter.formatHex(&hex_buf, value, config.bit_width); + const hex = engine.formatter.formatHex(&hex_buf, value, config.bit_width, config.display_endian); const dec = engine.formatter.formatDecimalUnsigned(&dec_buf, value); const sdec = engine.formatter.formatDecimalSigned(&sdec_buf, signed); const oct = engine.formatter.formatOctal(&oct_buf, value, config.bit_width); diff --git a/src/tui.zig b/src/tui.zig index 8a286fc..c00933e 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -195,7 +195,11 @@ pub const App = struct { .bits64 => .bits128, .bits128 => .bits8, }; - self.prog_value &= self.prog_config.bit_width.mask(); + // NOTE: prog_value is intentionally NOT masked here. Width is a + // display lens over the full value, so narrowing then widening + // restores the hidden upper bits. The display masks to width and + // a warning is shown while the value does not fit (see + // drawProgrammerMode). Explicit value edits still commit to width. if (self.bit_cursor >= self.prog_config.bit_width.bits()) { self.bit_cursor = @intCast(self.prog_config.bit_width.bits() - 1); } @@ -203,6 +207,16 @@ pub const App = struct { return; } + // Ctrl-E: toggle display endianness (programmer mode) + if (self.mode == .programmer and key.matches('e', .{ .ctrl = true })) { + self.prog_config.display_endian = switch (self.prog_config.display_endian) { + .little => .big, + .big => .little, + }; + ctx.redraw = true; + return; + } + // VALUE ZONE key handling if (self.value_zone_active) { self.handleValueZoneKey(key); @@ -382,7 +396,10 @@ pub const App = struct { }, .dec_unsigned, .dec_signed => { if (char >= '0' and char <= '9') { - self.prog_value = ((self.prog_value *% 10) +% (char - '0')) & self.prog_config.bit_width.mask(); + // Operate on the in-width portion so hidden upper bits do + // not corrupt the arithmetic; the edit commits to width. + const m = self.prog_config.bit_width.mask(); + self.prog_value = (((self.prog_value & m) *% 10) +% (char - '0')) & m; } }, .expression => {}, @@ -455,7 +472,7 @@ pub const App = struct { var hex_buf: [256]u8 = undefined; var oct_buf: [256]u8 = undefined; var bin_buf: [512]u8 = undefined; - const hex = engine.formatter.formatHex(&hex_buf, int_val, bw); + const hex = engine.formatter.formatHex(&hex_buf, int_val, bw, .big); const oct = engine.formatter.formatOctal(&oct_buf, int_val, bw); const bin = engine.formatter.formatBinary(&bin_buf, int_val, bw); details = .{ diff --git a/src/tui/help.zig b/src/tui/help.zig index 856acfb..c25bd2d 100644 --- a/src/tui/help.zig +++ b/src/tui/help.zig @@ -34,11 +34,11 @@ pub fn drawHelp(surface: *vxfw.Surface, width: u16, height: u16) void { draw.writeStr(surface, row, 2, "Programmer Mode", .{ .fg = C.purple, .bold = true }); row += 1; const prog_keys = [_][2][]const u8{ - .{ "Ctrl-B", "Toggle bit grid focus" }, - .{ "Ctrl-W", "Cycle bit width (8/16/32/64)" }, + .{ "`", "Toggle input / value zone" }, + .{ "Ctrl-W", "Cycle bit width (8/16/32/64/128)" }, + .{ "Ctrl-E", "Toggle endianness (BE / LE)" }, .{ "Space", "Toggle bit (in grid)" }, - .{ "Arrows", "Navigate bits (in grid)" }, - .{ "Esc", "Return to input (from grid)" }, + .{ "Arrows", "Navigate fields and bits (value zone)" }, }; for (prog_keys) |kv| { if (row >= height -| 4) break; diff --git a/src/tui/programmer.zig b/src/tui/programmer.zig index 776a71c..9d26613 100644 --- a/src/tui/programmer.zig +++ b/src/tui/programmer.zig @@ -16,12 +16,21 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei // Config display var config_buf: [80]u8 = undefined; - const config_str = std.fmt.bufPrint(&config_buf, "Bits: {d} Signed: {s} Endian: LE", .{ + const config_str = std.fmt.bufPrint(&config_buf, "Bits: {d} Signed: {s} Endian: {s}", .{ bw.bits(), if (app.prog_config.signedness == .signed) "yes" else "no", + if (app.prog_config.display_endian == .little) "LE" else "BE", }) catch "Bits: ??"; draw.writeStr(surface, 2, 2, config_str, .{ .fg = C.muted }); + // Truncation warning: the stored value has bits beyond the current width, + // so the display below shows only the low bits. Widening restores them. + if (app.prog_value & ~bw.mask() != 0) { + var warn_buf: [96]u8 = undefined; + const warn = std.fmt.bufPrint(&warn_buf, "! value exceeds {d} bits - showing low bits (widen to restore)", .{bw.bits()}) catch "! value exceeds width"; + draw.writeStr(surface, 3, 2, warn, .{ .fg = C.pink, .bold = true }); + } + // Bit grid const grid_start: u16 = 4; drawBitGrid(app, surface, grid_start, val, bw); @@ -53,7 +62,7 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei // HEX var hex_buf: [256]u8 = undefined; - const hex = engine.formatter.formatHex(&hex_buf, val, bw); + const hex = engine.formatter.formatHex(&hex_buf, val, bw, app.prog_config.display_endian); const hex_style: vaxis.Style = if (focused == .hex) .{ .fg = C.bg, .bg = C.green, .bold = true } else @@ -67,7 +76,7 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei // ASCII (derived, read-only): one glyph per byte, aligned under HEX. var ascii_buf: [128]u8 = undefined; - const ascii = engine.formatter.formatAscii(&ascii_buf, val, bw); + const ascii = engine.formatter.formatAscii(&ascii_buf, val, bw, app.prog_config.display_endian); draw.writeStr(surface, base_start + 3, 2, "ASCII:", .{ .fg = C.cyan }); draw.writeStr(surface, base_start + 3, 11, ascii.display, .{ .fg = C.orange }); @@ -111,9 +120,9 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei app.drawInput(surface, height -| 2); draw.fillRow(surface, height -| 1, ' ', .{ .fg = C.muted, .bg = C.bg }); const status = if (focused == .bits) - "Arrows:nav | Space:toggle | Up/Down:field | Ctrl-W:width | Tab:mode | Ctrl-C:quit" + "Arrows:nav | Space:toggle | Up/Down:field | Ctrl-W:width | Ctrl-E:endian | Tab:mode | Ctrl-C:quit" else - "Up/Down:field | Enter:set value | Ctrl-W:width | Tab:mode | ?:help | Ctrl-C:quit"; + "Up/Down:field | Enter:set value | Ctrl-W:width | Ctrl-E:endian | Tab:mode | ?:help | Ctrl-C:quit"; draw.writeStr(surface, height -| 1, 1, status, .{ .fg = C.muted, .bg = C.bg }); }