confirm
A yes/no question with two buttons
termancy tui add confirm
A yes/no question with two buttons.
var confirm: Confirm = .{ .prompt = "Delete it?" };
if (try tui.run(gpa, io, &confirm, .{ .environ = environ }) != 0) return;
if (confirm.status == .submitted and confirm.value) deleteIt();
Keys: left/right, h/l, and tab move between the buttons; enter answers with the one
highlighted; y and n answer directly; esc cancels. The answer is value once status
is .submitted.
confirm.zig
//! A yes/no question with two buttons.
//!
//! ```zig
//! var confirm: Confirm = .{ .prompt = "Delete it?" };
//! if (try tui.run(gpa, io, &confirm, .{ .environ = environ }) != 0) return;
//! if (confirm.status == .submitted and confirm.value) deleteIt();
//! ```
//!
//! Keys: left/right, h/l, and tab move between the buttons; enter answers with the one
//! highlighted; y and n answer directly; esc cancels. The answer is `value` once `status`
//! is `.submitted`.
const std = @import("std");
const tui = @import("tui");
pub const Status = enum { active, submitted, canceled };
const Confirm = @This();
prompt: []const u8 = "Are you sure?",
affirmative: []const u8 = "Yes",
negative: []const u8 = "No",
/// The highlighted button: true for `affirmative`. Its starting value is the default.
value: bool = true,
/// The screen row this is drawn from, for mouse clicks.
top: usize = 0,
status: Status = .active,
styles: Styles = .{},
pub const Styles = struct {
prompt: tui.Style = .{ .bold = true },
/// Bold and underlined as well as colored, so it stands out under `NO_COLOR`.
selected: tui.Style = .{
.fg = .{ .indexed = 230 },
.bg = .{ .indexed = 212 },
.bold = true,
.underline = true,
.padding = .symmetric(0, 3),
},
unselected: tui.Style = .{
.fg = .{ .indexed = 254 },
.bg = .{ .indexed = 235 },
.padding = .symmetric(0, 3),
},
};
pub fn update(model: *Confirm, msg: tui.Msg) tui.Cmd {
const key = switch (msg) {
.key => |key| key,
.mouse => |mouse| return model.click(mouse),
else => return .none,
};
if (key.matches("left") or key.matches("right") or key.matches("tab") or
key.matches("shift+tab") or key.matches("h") or key.matches("l"))
{
model.value = !model.value;
} else if (key.matches("y") or key.matches("Y")) {
return model.answer(true);
} else if (key.matches("n") or key.matches("N")) {
return model.answer(false);
} else if (key.matches("enter")) {
return model.answer(model.value);
} else if (key.matches("esc")) {
model.status = .canceled;
return .quit;
}
return .none;
}
/// A left click on a button answers with it. The buttons are two rows below the prompt,
/// which starts on screen row `top`.
fn click(model: *Confirm, mouse: tui.input.Mouse) tui.Cmd {
if (mouse.button != .left or mouse.action != .press) return .none;
if (mouse.y != model.top + 2) return .none;
const first = buttonWidth(model, model.affirmative, model.value);
if (mouse.x < first) return model.answer(true);
const second = buttonWidth(model, model.negative, !model.value);
if (mouse.x >= first + 2 and mouse.x < first + 2 + second) return model.answer(false);
return .none;
}
fn buttonWidth(model: *const Confirm, label: []const u8, selected: bool) usize {
var buffer: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buffer);
model.button(&w, label, selected) catch return label.len;
var plain: [256]u8 = undefined;
var stripped: std.Io.Writer = .fixed(&plain);
tui.ansi.strip(&stripped, w.buffered()) catch return label.len;
return tui.width.of(stripped.buffered());
}
fn answer(model: *Confirm, value: bool) tui.Cmd {
model.value = value;
model.status = .submitted;
return .quit;
}
pub fn view(model: *const Confirm, w: *std.Io.Writer) std.Io.Writer.Error!void {
try model.styles.prompt.paint(w, model.prompt);
try w.writeAll("\n\n");
try model.button(w, model.affirmative, model.value);
try w.writeAll(" ");
try model.button(w, model.negative, !model.value);
}
fn button(
model: *const Confirm,
w: *std.Io.Writer,
label: []const u8,
selected: bool,
) std.Io.Writer.Error!void {
const style = if (selected) model.styles.selected else model.styles.unselected;
try style.render(w, label);
}
const testing = std.testing;
const Driver = tui.testing.Driver(Confirm);
test "enter answers with the highlighted button" {
var d: Driver = try .init(testing.allocator, .{ .prompt = "Continue?" }, .{});
defer d.deinit();
try d.expectFrame("Continue?\n\n Yes No ");
try d.press("right");
try d.press("enter");
try testing.expect(d.done());
try testing.expectEqual(Status.submitted, d.model().status);
try testing.expect(!d.model().value);
}
test "y and n answer at once" {
var d: Driver = try .init(testing.allocator, .{ .value = false }, .{});
defer d.deinit();
try d.press("y");
try testing.expect(d.done() and d.model().value);
var n: Driver = try .init(testing.allocator, .{}, .{});
defer n.deinit();
try n.press("N");
try testing.expect(n.done() and !n.model().value);
}
test "the arrows toggle, esc cancels" {
var d: Driver = try .init(
testing.allocator,
.{ .affirmative = "Do it", .negative = "Stop" },
.{},
);
defer d.deinit();
try d.press("tab");
try testing.expect(!d.model().value);
try d.press("h");
try testing.expect(d.model().value);
try d.press("esc");
try testing.expect(d.done());
try testing.expectEqual(Status.canceled, d.model().status);
}
test "the highlighted button is styled" {
var buffer: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buffer);
const model: Confirm = .{ .styles = .{
.prompt = .{},
.selected = .{ .reverse = true },
.unselected = .{},
} };
try model.view(&w);
try testing.expectEqualStrings("Are you sure?\n\n\x1b[7mYes\x1b[0m No", w.buffered());
}
test "the mouse: a click on a button answers" {
var d: Driver = try .init(testing.allocator, .{ .prompt = "Sure?" }, .{});
defer d.deinit();
// Not on the buttons' row: nothing.
try d.send(.{ .mouse = .{ .x = 0, .y = 0, .button = .left, .action = .press } });
try testing.expect(!d.done());
try d.send(.{ .mouse = .{ .x = 0, .y = 2, .button = .left, .action = .press } });
try testing.expect(d.done());
try testing.expect(d.model().value);
var e: Driver = try .init(testing.allocator, .{ .prompt = "Sure?", .value = true }, .{});
defer e.deinit();
const first = e.model().buttonWidth(e.model().affirmative, true);
try e.send(.{ .mouse = .{
.x = @intCast(first + 2),
.y = 2,
.button = .left,
.action = .press,
} });
try testing.expect(e.done());
try testing.expect(!e.model().value);
}