termancy Guide Components Commands API

pager

A full-screen viewport over long text, with search

termancy tui add pager

Also copies what it requires: input

termancy ui demo pager, recorded

A scrollable viewport over long text, with search. Meant for the full screen (tui.Options.alt_screen): it fills the terminal's height, the last row a status line.

var pager: Pager = try .init(gpa, text);
defer pager.deinit();
_ = try tui.run(gpa, io, &pager, .{ .environ = environ, .program = .{ .alt_screen = true } });

Keys: j/k, down/up, and enter scroll a row; space/f/pgdown and b/pgup a screen; d/u half a screen; g/home and G/end jump to either end. / starts a search: type, enter jumps to the first row from the top of the screen down that contains the text (ignoring ASCII case), esc abandons it; n and N jump to the next and previous match. Every match on screen is highlighted. q quits; esc quits as canceled.

Lines are word-wrapped to the terminal width (again on every resize), so each row is a slice of the text; a match broken across two rows is not found. Tabs are not expanded.

pager.zig

//! A scrollable viewport over long text, with search. Meant for the full screen
//! (`tui.Options.alt_screen`): it fills the terminal's height, the last row a status line.
//!
//! ```zig
//! var pager: Pager = try .init(gpa, text);
//! defer pager.deinit();
//! _ = try tui.run(gpa, io, &pager, .{ .environ = environ, .program = .{ .alt_screen = true } });
//! ```
//!
//! Keys: j/k, down/up, and enter scroll a row; space/f/pgdown and b/pgup a screen; d/u half
//! a screen; g/home and G/end jump to either end. / starts a search: type, enter jumps to
//! the first row from the top of the screen down that contains the text (ignoring ASCII
//! case), esc abandons it; n and N jump to the next and previous match. Every match on
//! screen is highlighted. q quits; esc quits as canceled.
//!
//! Lines are word-wrapped to the terminal width (again on every resize), so each row is a
//! slice of the text; a match broken across two rows is not found. Tabs are not expanded.

const std = @import("std");
const tui = @import("tui");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const Input = @import("input.zig");

pub const Status = enum { active, submitted, canceled };

const Pager = @This();

gpa: Allocator,
/// The text shown; borrowed.
text: []const u8,
/// `text` wrapped to the width, one slice per screen row.
rows: std.ArrayList([]const u8) = .empty,
/// The first row on screen.
offset: usize = 0,
size: tui.Size = .{ .width = 80, .height = 24 },
/// The search being typed, or the last one.
search: Input,
/// Whether keys go to `search`.
searching: bool = false,
/// Shown in the status line until the next key, such as "not found".
message: []const u8 = "",
status: Status = .active,
styles: Styles = .{},

pub const Styles = struct {
    /// Matches: reversed, so they show under `NO_COLOR`.
    match: tui.Style = .{ .fg = .{ .indexed = 212 }, .reverse = true },
    status: tui.Style = .{ .fg = .{ .indexed = 240 } },
    message: tui.Style = .{ .fg = .{ .indexed = 203 } },
};

/// A pager over `text`, wrapped for an 80-column terminal until the first resize.
pub fn init(gpa: Allocator, text: []const u8) Allocator.Error!Pager {
    var pager: Pager = .{
        .gpa = gpa,
        .text = text,
        .search = .{ .gpa = gpa, .prompt = "/" },
    };
    errdefer pager.deinit();
    try pager.wrap();
    return pager;
}

/// Frees the rows and the search.
pub fn deinit(pager: *Pager) void {
    pager.rows.deinit(pager.gpa);
    pager.search.deinit();
    pager.* = undefined;
}

/// Shows `text` (borrowed) instead, keeping the position as far as it allows.
pub fn setText(pager: *Pager, text: []const u8) Allocator.Error!void {
    pager.text = text;
    try pager.wrap();
}

/// Splits the text into rows at the current width.
fn wrap(pager: *Pager) Allocator.Error!void {
    pager.rows.clearRetainingCapacity();
    var lines = std.mem.splitScalar(u8, pager.text, '\n');
    while (lines.next()) |raw| {
        if (lines.index == null and raw.len == 0) break; // the text's final newline
        const line = std.mem.trimEnd(u8, raw, "\r");
        var wrapper: tui.width.Wrapper = .init(line, @max(pager.size.width, 1));
        while (wrapper.next()) |row| try pager.rows.append(pager.gpa, row.bytes);
    }
    pager.scrollTo(pager.offset);
}

/// Rows of text on screen: all but the status line.
pub fn bodyHeight(pager: *const Pager) usize {
    return @max(pager.size.height, 2) - 1;
}

/// Puts row `row` at the top, as far as the end of the text allows.
pub fn scrollTo(pager: *Pager, row: usize) void {
    pager.offset = @min(row, pager.rows.items.len -| pager.bodyHeight());
}

fn scrollBy(pager: *Pager, delta: isize) void {
    const target = @as(isize, @intCast(pager.offset)) + delta;
    pager.scrollTo(@intCast(@max(0, target)));
}

pub fn update(pager: *Pager, msg: tui.Msg) tui.Cmd {
    switch (msg) {
        .key => |key| {
            pager.message = "";
            if (pager.searching) {
                pager.searchKey(key);
                return .none;
            }
            return pager.viewKey(key);
        },
        .paste => |text| if (pager.searching) pager.search.insert(text) catch {},
        .resize => |size| {
            pager.size = size;
            pager.wrap() catch {};
        },
        .mouse => |mouse| switch (mouse.button) {
            .wheel_up => pager.scrollBy(-3),
            .wheel_down => pager.scrollBy(3),
            else => {},
        },
        else => {},
    }
    return .none;
}

fn viewKey(pager: *Pager, key: tui.input.Key) tui.Cmd {
    const page: isize = @intCast(pager.bodyHeight());
    if (key.matches("q")) {
        pager.status = .submitted;
        return .quit;
    } else if (key.matches("esc")) {
        pager.status = .canceled;
        return .quit;
    } else if (key.matches("j") or key.matches("down") or key.matches("enter")) {
        pager.scrollBy(1);
    } else if (key.matches("k") or key.matches("up")) {
        pager.scrollBy(-1);
    } else if (key.matches("space") or key.matches("f") or key.matches("pgdown")) {
        pager.scrollBy(page);
    } else if (key.matches("b") or key.matches("pgup")) {
        pager.scrollBy(-page);
    } else if (key.matches("d")) {
        pager.scrollBy(@divTrunc(page + 1, 2));
    } else if (key.matches("u")) {
        pager.scrollBy(-@divTrunc(page + 1, 2));
    } else if (key.matches("g") or key.matches("home")) {
        pager.scrollTo(0);
    } else if (key.matches("G") or key.matches("end")) {
        pager.scrollTo(pager.rows.items.len);
    } else if (key.matches("/")) {
        pager.searching = true;
        pager.search.setValue("") catch {};
    } else if (key.matches("n")) {
        pager.jump(pager.offset + 1, .forward);
    } else if (key.matches("N")) {
        pager.jump(pager.offset, .backward);
    }
    return .none;
}

fn searchKey(pager: *Pager, key: tui.input.Key) void {
    if (key.matches("enter")) {
        pager.searching = false;
        pager.jump(pager.offset, .forward);
    } else if (key.matches("esc")) {
        pager.searching = false;
        pager.search.setValue("") catch {};
    } else {
        _ = pager.search.handleKey(key);
    }
}

const Direction = enum { forward, backward };

/// Scrolls to the first row with a match at or after `from` (forward), or the last one
/// before it (backward); says so in the status line when there is none.
fn jump(pager: *Pager, from: usize, direction: Direction) void {
    const pattern = pager.search.value();
    if (pattern.len == 0) return;
    const rows = pager.rows.items;
    const found: ?usize = switch (direction) {
        .forward => for (@min(from, rows.len)..rows.len) |i| {
            if (contains(rows[i], pattern)) break i;
        } else null,
        .backward => blk: {
            var i = @min(from, rows.len);
            while (i > 0) {
                i -= 1;
                if (contains(rows[i], pattern)) break :blk i;
            }
            break :blk null;
        },
    };
    if (found) |row| pager.offset = row else pager.message = "Pattern not found";
}

pub fn view(pager: *const Pager, w: *Writer) Writer.Error!void {
    const height = pager.bodyHeight();
    const rows = pager.rows.items;
    const pattern = if (pager.searching) "" else pager.search.value();
    for (0..height) |i| {
        const index = pager.offset + i;
        if (index < rows.len) try pager.writeRow(w, rows[index], pattern);
        try w.writeByte('\n');
    }
    try pager.writeStatus(w);
}

/// Writes `row` with every occurrence of `pattern` highlighted.
fn writeRow(
    pager: *const Pager,
    w: *Writer,
    row: []const u8,
    pattern: []const u8,
) Writer.Error!void {
    var at: usize = 0;
    while (pattern.len > 0) {
        const found = std.ascii.findIgnoreCasePos(row, at, pattern) orelse break;
        try w.writeAll(row[at..found]);
        try pager.styles.match.paint(w, row[found..][0..pattern.len]);
        at = found + pattern.len;
    }
    try w.writeAll(row[at..]);
}

fn writeStatus(pager: *const Pager, w: *Writer) Writer.Error!void {
    if (pager.searching) return pager.search.view(w);
    if (pager.message.len > 0) return pager.styles.message.paint(w, pager.message);
    const total = pager.rows.items.len;
    const last = @min(pager.offset + pager.bodyHeight(), total);
    const percent = if (total == 0) 100 else last * 100 / total;
    try pager.styles.status.writeSgr(w);
    const first = @min(pager.offset + 1, total);
    try w.print("{d}-{d} of {d} ({d}%)", .{ first, last, total, percent });
    if (pager.styles.status.hasSgr()) try w.writeAll(tui.ansi.reset);
}

fn contains(row: []const u8, pattern: []const u8) bool {
    return std.ascii.findIgnoreCase(row, pattern) != null;
}

const testing = std.testing;
const Driver = tui.testing.Driver(Pager);
const poem =
    \\one
    \\two
    \\three
    \\four
    \\five
    \\six
    \\
;

fn startPager(d: *Driver, text: []const u8, size: tui.Size) !void {
    const pager: Pager = try .init(testing.allocator, text);
    d.* = try .init(
        testing.allocator,
        pager,
        .{ .program = .{ .size = size, .alt_screen = true } },
    );
}

fn finish(d: *Driver) void {
    d.deinit();
}

test "scrolling by row, page, and to either end" {
    var d: Driver = undefined;
    try startPager(&d, poem, .{ .width = 20, .height = 4 });
    defer finish(&d);
    try d.expectFrame("one\ntwo\nthree\n1-3 of 6 (50%)");
    try d.press("j");
    try d.expectFrame("two\nthree\nfour\n2-4 of 6 (66%)");
    try d.press("space");
    try d.expectFrame("four\nfive\nsix\n4-6 of 6 (100%)");
    try d.press("j"); // already at the end
    try testing.expectEqual(3, d.model().offset);
    try d.press("b");
    try testing.expectEqual(0, d.model().offset);
    try d.press("G");
    try testing.expectEqual(3, d.model().offset);
    try d.press("u");
    try testing.expectEqual(1, d.model().offset);
    try d.press("g");
    try d.press("d");
    try testing.expectEqual(2, d.model().offset);
    try d.press("q");
    try testing.expect(d.done());
    try testing.expectEqual(Status.submitted, d.model().status);
}

test "long lines wrap, and wrap again on resize" {
    var d: Driver = undefined;
    try startPager(&d, "alpha beta gamma\nx", .{ .width = 11, .height = 5 });
    defer finish(&d);
    try d.expectFrame("alpha beta\ngamma\nx\n\n1-3 of 3 (100%)");
    try d.resize(.{ .width = 6, .height = 5 });
    try d.expectFrame("alpha\nbeta\ngamma\nx\n1-4 of 4 (100%)");
}

test "search jumps to matches and highlights them" {
    var d: Driver = undefined;
    try startPager(&d, poem, .{ .width = 20, .height = 3 });
    defer finish(&d);
    try d.press("/");
    try d.typeText("F");
    try d.expectFrame("one\ntwo\n/F ");
    try d.press("enter");
    try d.expectFrame("four\nfive\n4-5 of 6 (83%)");
    try testing.expect(std.mem.indexOf(u8, d.frameBytes(), "\x1b[7;38;5;212mf\x1b[0mour") != null);
    try d.press("n");
    try testing.expectEqual(4, d.model().offset);
    try d.press("n");
    try d.expectFrame("five\nsix\nPattern not found");
    try d.press("N");
    try testing.expectEqual(3, d.model().offset);
    try d.press("g");
    try d.press("/");
    try d.typeText("zzz");
    try d.press("enter");
    try d.expectFrame("one\ntwo\nPattern not found");
    try d.press("/");
    try d.typeText("x");
    try d.press("esc"); // abandons the search, not the pager
    try testing.expect(!d.done());
    try testing.expectEqualStrings("", d.model().search.value());
    try d.press("esc");
    try testing.expectEqual(Status.canceled, d.model().status);
}

test "empty text" {
    var d: Driver = undefined;
    try startPager(&d, "", .{ .width = 10, .height = 2 });
    defer finish(&d);
    try d.expectFrame("\n0-0 of 0 (100%)");
}

test "the mouse wheel scrolls" {
    var d: Driver = undefined;
    try startPager(&d, poem, .{ .width = 20, .height = 4 });
    defer finish(&d);
    try d.send(.{ .mouse = .{ .x = 0, .y = 0, .button = .wheel_down, .action = .press } });
    try testing.expectEqual(3, d.model().offset);
    try d.send(.{ .mouse = .{ .x = 0, .y = 0, .button = .wheel_up, .action = .press } });
    try testing.expectEqual(0, d.model().offset);
}