tally/src/tui/convert.zig

180 lines
7.4 KiB
Zig

//! Unit conversion mode drawing for the TUI.
//!
//! Layout: a wrapping row of category chips, the live conversion result, the
//! conversion factor, and two side-by-side unit columns (from / to). Every
//! chip and unit name is registered as a clickable region, so the whole view is
//! usable with the mouse as well as the keyboard.
const std = @import("std");
const vaxis = @import("vaxis");
const vxfw = vaxis.vxfw;
const engine = @import("engine");
const draw = @import("draw.zig");
const tui = @import("../tui.zig");
const C = draw.C;
const units = engine.units;
/// Column layout constants.
const from_col: u16 = 4;
const to_col_offset: u16 = 26;
pub fn drawConvertMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height: u16) void {
const table = units.unitsIn(app.conv_category);
const pair = app.convUnits();
const zone_active = app.value_zone_active;
// -- Category chips (wrapping) --
draw.writeStr(surface, 2, 2, "Category:", .{ .fg = C.muted });
var row: u16 = 3;
var col: u16 = 4;
for (std.enums.values(engine.UnitCategory)) |category| {
const label = category.label();
const chip_len: u16 = @intCast(label.len + 2);
// Wrap when the chip would run off the right edge.
if (col + chip_len >= width -| 2) {
row += 1;
col = 4;
}
// And stop before the furniture rows, so a narrow terminal does not draw
// chips over the separator and the prompt.
if (row >= height -| 3) break;
const selected = category == app.conv_category;
const focused = selected and zone_active and app.conv_zone == .category;
const style: vaxis.Style = if (focused)
.{ .fg = C.bg, .bg = C.cyan, .bold = true }
else if (selected)
.{ .fg = C.bg, .bg = C.purple, .bold = true }
else
.{ .fg = C.muted };
draw.writeChar(surface, row, col, ' ', style);
draw.writeStr(surface, row, col + 1, label, style);
draw.writeChar(surface, row, col + 1 + @as(u16, @intCast(label.len)), ' ', style);
app.addRegion(row, col, chip_len, .{ .conv_category = category });
col += chip_len + 1;
}
// -- Result --
row += 2;
// Exact conversion, so a terminating case like 12 in -> ft shows 1 rather
// than 0.9999999999999998.
var converted = units.convertExactUnits(app.allocator, app.conv_value, pair.from, pair.to) catch
app.conv_value.clone() catch return;
defer converted.deinit();
const shown_in = app.conv_value.render(app.allocator, tui.display_format) catch return;
defer shown_in.deinit(app.allocator);
const shown_out = converted.render(app.allocator, tui.display_format) catch return;
defer shown_out.deinit(app.allocator);
var line_buf: [192]u8 = undefined;
const input_line = std.fmt.bufPrint(&line_buf, "{s} {s}", .{ shown_in.text, pair.from.name }) catch "?";
draw.writeStr(surface, row, 2, input_line, .{ .fg = C.fg });
row += 1;
var result_buf: [192]u8 = undefined;
const result_line = std.fmt.bufPrint(&result_buf, "= {s} {s}", .{ shown_out.text, pair.to.name }) catch "?";
draw.writeStr(surface, row, 2, result_line, .{ .fg = C.green, .bold = true });
row += 1;
// -- Conversion factor (linear conversions only) --
if (pair.from.isLinear() and pair.to.isLinear()) {
const factor = pair.from.to_base_factor / pair.to.to_base_factor;
const factor_shown = engine.Number.fromFloat(factor).render(app.allocator, tui.compact_format) catch return;
defer factor_shown.deinit(app.allocator);
var factor_buf: [160]u8 = undefined;
const factor_line = std.fmt.bufPrint(&factor_buf, "1 {s} = {s} {s}", .{
pair.from.name, factor_shown.text, pair.to.name,
}) catch "?";
draw.writeStr(surface, row, 2, factor_line, .{ .fg = C.muted });
} else {
// Temperature is affine, so no single factor describes it.
draw.writeStr(surface, row, 2, "affine conversion (offset), no single factor", .{ .fg = C.muted });
}
row += 2;
// -- Unit columns --
const to_col = from_col + to_col_offset;
const from_focused = zone_active and app.conv_zone == .from;
const to_focused = zone_active and app.conv_zone == .to;
draw.writeStr(surface, row, 2, "From", if (from_focused)
.{ .fg = C.cyan, .bold = true }
else
.{ .fg = C.muted });
draw.writeStr(surface, row, to_col -| 2, "To", if (to_focused)
.{ .fg = C.cyan, .bold = true }
else
.{ .fg = C.muted });
row += 1;
const list_start = row;
// One row is reserved for the overflow notice, so a hidden unit gets reported
// rather than the notice being written over the last unit drawn. It used to
// land on `list_end - 1`, erasing the unit already there and undercounting the
// remainder by one.
const list_end = height -| 4;
const room: usize = if (list_end > list_start) list_end - list_start else 0;
const overflows = table.len > room;
const visible: usize = if (overflows) (if (room > 0) room - 1 else 0) else room;
for (table, 0..) |unit, i| {
if (i >= visible) break;
const list_row: u16 = list_start + @as(u16, @intCast(i));
drawUnitCell(app, surface, list_row, from_col, unit, i == app.conv_from_idx, from_focused, .{ .conv_from = i });
drawUnitCell(app, surface, list_row, to_col, unit, i == app.conv_to_idx, to_focused, .{ .conv_to = i });
}
// If the terminal is too short for the whole list, say so rather than silently
// truncating. Drawn even when nothing fits at all, which is the case that used
// to show two empty columns and no explanation.
if (overflows) {
var more_buf: [64]u8 = undefined;
const more = std.fmt.bufPrint(&more_buf, "... {d} more (resize to see all)", .{table.len - visible}) catch "...";
const notice_row = list_start + @as(u16, @intCast(visible));
if (notice_row < list_end) {
draw.writeStr(surface, notice_row, from_col, more, .{ .fg = C.dim });
}
}
// -- Separator, input, status --
draw.fillRow(surface, height -| 3, '-', .{ .fg = C.dim });
app.drawInput(surface, height -| 2);
app.addRegion(height -| 2, 0, width, .focus_input);
draw.fillRow(surface, height -| 1, ' ', .{ .fg = C.muted, .bg = C.bg });
const status = if (zone_active)
"Tab:mode | ?:help | `:input | Arrows:select | Ctrl-S:swap"
else
"?:help | Tab:mode | Type a value + Enter | `:select units | Ctrl-S:swap";
draw.hintRow(surface, height -| 1, 1, status, .{ .fg = C.muted, .bg = C.bg });
}
/// Draw one unit name in a column, highlighting it when selected, and register
/// it as clickable.
fn drawUnitCell(
app: *tui.App,
surface: *vxfw.Surface,
row: u16,
col: u16,
unit: engine.UnitDef,
selected: bool,
column_focused: bool,
action: tui.Action,
) void {
const style: vaxis.Style = if (selected and column_focused)
.{ .fg = C.bg, .bg = C.cyan, .bold = true }
else if (selected)
.{ .fg = C.green, .bold = true }
else
.{ .fg = C.fg };
const marker: u8 = if (selected) '>' else ' ';
draw.writeChar(surface, row, col -| 2, marker, .{ .fg = C.cyan, .bold = true });
draw.writeStr(surface, row, col, unit.name, style);
// Make the whole column slot clickable, not just the name text, so short
// names like "m" are not a one-character target.
app.addRegion(row, col -| 2, to_col_offset -| 2, action);
}