Luigit
repositories / termux-janitor

termux-janitor

Interactive cleanup assistant for Termux: transparent, safe, confirmed disk reclamation.

owned by admin

src/tui.zig

Raw
//! Phone-friendly TUI: human-readable sizes, compact paths, clean layout.
const std = @import("std");
const posix = std.posix;
const scan_mod = @import("scan.zig");
const classify = @import("classify.zig");
const plan_mod = @import("plan.zig");
const runlog_mod = @import("runlog.zig");

pub const terminal_input_buffer_bytes: u32 = @intCast(@import("spec_data").limit_value.terminal_input_buffer_bytes);

pub const Screen = enum {
    checklist,
    help,
    filters,
    plan,
    confirmation,
    executing,
    report,
};

// ── ANSI colors ───────────────────────────────────────────────────────
const Color = struct {
    const reset = "\x1b[0m";
    const bold = "\x1b[1m";
    const dim = "\x1b[2m";
    const fg = [_][]const u8{
        "",         "\x1b[31m", "\x1b[32m", "\x1b[33m", "\x1b[34m",
        "\x1b[35m", "\x1b[36m", "\x1b[37m", "\x1b[90m", "\x1b[91m",
        "\x1b[92m", "\x1b[93m", "\x1b[94m", "\x1b[95m", "\x1b[96m",
        "\x1b[97m",
    };
    const bg = [_][]const u8{
        "",         "\x1b[41m", "\x1b[42m", "\x1b[43m", "\x1b[44m",
        "\x1b[45m", "\x1b[46m", "\x1b[47m",
    };
    const highlight = "\x1b[37m\x1b[44m";
    const sel_on = "\x1b[32m[x]\x1b[0m";
    const sel_off = "\x1b[9m[ ]\x1b[0m";

    pub fn fgColor(n: usize) []const u8 {
        return if (n < fg.len) fg[n] else "";
    }
    pub fn bgColor(n: usize) []const u8 {
        return if (n < bg.len) bg[n] else "";
    }
};

// ── Human-readable size ───────────────────────────────────────────────
fn formatSize(buf: []u8, bytes: u64) []const u8 {
    if (bytes < 1024) return std.fmt.bufPrint(buf, "{d}B", .{bytes}) catch "???B";
    if (bytes < 1024 * 1024) return std.fmt.bufPrint(buf, "{d:.1}KB", .{@as(f64, @floatFromInt(bytes)) / 1024.0}) catch "???KB";
    if (bytes < 1024 * 1024 * 1024) return std.fmt.bufPrint(buf, "{d:.1}MB", .{@as(f64, @floatFromInt(bytes)) / (1024.0 * 1024.0)}) catch "???MB";
    return std.fmt.bufPrint(buf, "{d:.1}GB", .{@as(f64, @floatFromInt(bytes)) / (1024.0 * 1024.0 * 1024.0)}) catch "???GB";
}

// ── Path truncation: last 2-3 components ──────────────────────────────
fn truncatePath(buf: []u8, path: []const u8, max_len: usize) []const u8 {
    if (path.len <= max_len) {
        @memcpy(buf[0..path.len], path);
        return buf[0..path.len];
    }
    var s1: usize = path.len;
    var s2: usize = path.len;
    var s3: usize = path.len;
    var i: usize = path.len;
    while (i > 0) : (i -= 1) {
        if (path[i - 1] == '/') {
            if (s1 == path.len) {
                s1 = i - 1;
            } else if (s2 == path.len) {
                s2 = i - 1;
            } else if (s3 == path.len) {
                s3 = i - 1;
                break;
            }
        }
    }
    const ellipsis = "...";
    const elen = ellipsis.len;
    if (elen + (path.len - s3) <= max_len) {
        var w: usize = 0;
        @memcpy(buf[w..][0..elen], ellipsis);
        w += elen;
        @memcpy(buf[w..][0 .. path.len - s3], path[s3..]);
        w += path.len - s3;
        return buf[0..w];
    }
    if (elen + (path.len - s2) <= max_len) {
        var w: usize = 0;
        @memcpy(buf[w..][0..elen], ellipsis);
        w += elen;
        @memcpy(buf[w..][0 .. path.len - s2], path[s2..]);
        w += path.len - s2;
        return buf[0..w];
    }
    if (elen + (path.len - s1) <= max_len) {
        var w: usize = 0;
        @memcpy(buf[w..][0..elen], ellipsis);
        w += elen;
        @memcpy(buf[w..][0 .. path.len - s1], path[s1..]);
        w += path.len - s1;
        return buf[0..w];
    }
    if (max_len > 3) {
        @memcpy(buf[0..3], ellipsis);
        return buf[0..max_len];
    }
    return buf[0..@min(max_len, path.len)];
}

// ── Constants ─────────────────────────────────────────────────────────
const MAX_LIST_ROWS: u32 = 12;
const PATH_MAX_LEN: usize = 30;

// ── Termios / raw mode ────────────────────────────────────────────────
pub const Termios = struct {
    original: posix.termios,
    saved: bool = false,

    pub fn enterRaw(fd: posix.fd_t) !Termios {
        const state: Termios = .{ .original = try posix.tcgetattr(fd), .saved = true };
        var raw = state.original;
        raw.lflag.ECHO = false;
        raw.lflag.ICANON = false;
        raw.lflag.ISIG = false;
        raw.iflag.IXON = false;
        raw.iflag.ICRNL = false;
        raw.cc[@intFromEnum(std.os.linux.V.MIN)] = 1;
        raw.cc[@intFromEnum(std.os.linux.V.TIME)] = 0;
        try posix.tcsetattr(fd, .NOW, raw);
        return state;
    }

    pub fn restore(self: *Termios, fd: posix.fd_t) void {
        if (!self.saved) return;
        posix.tcsetattr(fd, .NOW, self.original) catch {};
        self.saved = false;
    }
};

// ── TUI state ─────────────────────────────────────────────────────────
pub const Tui = struct {
    stdin_fd: posix.fd_t,
    stdout_fd: posix.fd_t,
    termios: Termios = .{ .original = undefined },
    screen: Screen = .checklist,
    focus: u32 = 0,
    confirm: plan_mod.Confirm = .{},
    message: []const u8 = "",
    ascii: bool = false,
    log_broken: bool = false,
    plan: ?*const plan_mod.Plan = null,
    report_ok: u32 = 0,
    report_failed: u32 = 0,
    search_open: bool = false,
    search_query: [64]u8 = undefined,
    search_len: u32 = 0,
    visible: [scan_mod.retained_findings_max]u32 = undefined,
    visible_len: u32 = 0,
    category_filter: u32 = 0,
    filters_draft: u32 = 0,
    selection_filter_selected: bool = false,
    selection_filter_unselected: bool = false,
    selection_draft_selected: bool = false,
    selection_draft_unselected: bool = false,
    filter_focus: u32 = 0,
    filter_summary_buffer: [96]u8 = undefined,
    package_focus: u32 = 0,
    io: std.Io,

    pub fn init(io: std.Io, stdin_fd: posix.fd_t, stdout_fd: posix.fd_t) !Tui {
        return .{ .stdin_fd = stdin_fd, .stdout_fd = stdout_fd, .io = io };
    }

    pub fn enterAlternateScreen(self: *Tui) void {
        _ = self;
        writeAll("\x1b[?1049h\x1b[H\x1b[2J");
    }

    pub fn leaveAlternateScreen(self: *Tui) void {
        _ = self;
        writeAll("\x1b[?1049l");
    }

    pub fn writeAll(text: []const u8) void {
        var stdout = std.Io.File.stdout();
        stdout.writeStreamingAll(std.Io.Threaded.global_single_threaded.io(), text) catch {};
    }

    pub fn readKey(self: *Tui) ?Key {
        var byte_buffer: [1]u8 = undefined;
        const stdin = std.Io.File{ .handle = self.stdin_fd, .flags = .{ .nonblocking = false } };
        const read = stdin.readStreaming(self.io, &.{&byte_buffer}) catch return null;
        if (read == 0) return null;
        const byte = byte_buffer[0];
        if (byte != 0x1B) return .{ .byte = byte };
        var escape_probe = [_]posix.pollfd{.{ .fd = self.stdin_fd, .events = posix.POLL.IN, .revents = 0 }};
        const pending = posix.poll(&escape_probe, 25) catch return .{ .byte = 0x1B };
        if (pending == 0) return .{ .byte = 0x1B };
        const sequence = stdin.readStreaming(self.io, &.{&byte_buffer}) catch return null;
        if (sequence == 0) return .{ .byte = 0x1B };
        if (byte_buffer[0] != '[') return .{ .byte = byte_buffer[0] };
        const final = stdin.readStreaming(self.io, &.{&byte_buffer}) catch return null;
        if (final == 0) return null;
        return switch (byte_buffer[0]) {
            'A' => .up,
            'B' => .down,
            'C' => .right,
            'D' => .left,
            else => .{ .byte = byte_buffer[0] },
        };
    }

    pub fn render(self: *Tui, scan: *const scan_mod.Scan, paths: [][]const u8) void {
        var out: [16384]u8 = undefined;
        var writer = std.Io.Writer.fixed(&out);
        writer.writeAll("\x1b[H\x1b[2J") catch return;
        switch (self.screen) {
            .checklist => self.renderChecklist(scan, paths, &writer),
            .help => self.renderHelp(scan, &writer),
            .filters => self.renderFilters(&writer),
            .confirmation => self.renderConfirmation(scan, paths, &writer),
            .report => self.renderReport(&writer),
            .executing, .plan => self.renderChecklist(scan, paths, &writer),
        }
        self.writeFrame(out[0..writer.end]);
    }

    fn renderChecklist(self: *Tui, scan: *const scan_mod.Scan, paths: [][]const u8, writer: *std.Io.Writer) void {
        self.rebuildVisibility(scan, paths);
        if (self.visible_len == 0) self.focus = 0;
        if (self.visible_len > 0 and self.focus >= self.visible_len) self.focus = self.visible_len - 1;

        var selected_count: u32 = 0;
        var unavailable_count: u32 = 0;
        var hidden_count: u32 = 0;
        var counted: u32 = 0;
        while (counted < scan.findings_len) : (counted += 1) {
            const f = scan.findings[counted];
            if (!f.selected) continue;
            selected_count += 1;
            const ineligible = f.kind != .file and f.kind != .symlink and f.kind != .directory;
            if (self.log_broken or ineligible) unavailable_count += 1;
            if (!self.visibleContains(counted)) hidden_count += 1;
        }
        var pkg_selected: u32 = 0;
        counted = 0;
        while (counted < scan.package_records_len) : (counted += 1) {
            if (scan.package_records[counted].available) pkg_selected += 1;
        }

        // Header
        writer.writeAll(Color.bold) catch return;
        writer.writeAll("  Termux Janitor") catch return;
        writer.writeAll(Color.reset) catch return;
        writer.writeAll("\n") catch return;

        // Status
        writer.writeAll(Color.dim) catch return;
        var status_buf: [120]u8 = undefined;
        const status = std.fmt.bufPrint(&status_buf, "  {d} findings  {d} selected  {d} hidden  {d} unavail", .{ scan.findings_len, selected_count, hidden_count, unavailable_count }) catch "  ---";
        writer.writeAll(status) catch return;
        if (scan.package_records_len > 0) {
            writer.writeAll("  ") catch return;
            writer.print("{d} pkgs", .{pkg_selected}) catch return;
        }
        if (!scan.complete) {
            writer.writeAll("  ") catch return;
            writer.writeAll(Color.fgColor(8)) catch return;
            writer.writeAll("incomplete") catch return;
            writer.writeAll(Color.reset) catch return;
        }
        writer.writeAll(Color.reset) catch return;
        writer.writeAll("\n") catch return;

        // List
        var rows: u32 = 2;
        if (self.visible_len == 0) {
            writer.writeAll(Color.dim) catch return;
            writer.writeAll("  ( no matches )") catch return;
            writer.writeAll(Color.reset) catch return;
            writer.writeAll("\n") catch return;
            rows += 1;
        }

        var position: u32 = self.focus;
        var shown: u32 = 0;
        while (shown < MAX_LIST_ROWS and position < self.visible_len) : (shown += 1) {
            const idx = self.visible[position];
            const finding = scan.findings[idx];
            const path = paths[idx];
            const is_selected = finding.selected;
            const is_focus = (position == self.focus);

            const cursor: []const u8 = if (is_focus) ">" else " ";
            const cat: classify.Category = @enumFromInt(finding.category);
            const cat_label = classify.Category.label(cat);
            const col = categoryColor(cat);

            var path_buf: [64]u8 = undefined;
            const disp_path = truncatePath(&path_buf, path, PATH_MAX_LEN);

            var size_buf: [16]u8 = undefined;
            const size_str = formatSize(&size_buf, if (finding.blocks_known) finding.allocated_bytes else 0);

            writer.writeAll("  ") catch break;
            if (is_focus) {
                writer.writeAll(Color.highlight) catch break;
                writer.writeAll(cursor) catch break;
                writer.writeAll(Color.reset) catch break;
            } else {
                writer.writeAll(cursor) catch break;
            }

            if (is_selected) {
                writer.writeAll(Color.sel_on) catch break;
            } else {
                writer.writeAll(Color.sel_off) catch break;
            }
            writer.writeAll("  ") catch break;

            writer.writeAll(Color.fgColor(col)) catch break;
            writer.writeAll(cat_label) catch break;
            writer.writeAll(Color.reset) catch break;
            writer.writeAll("  ") catch break;

            writer.writeAll(disp_path) catch break;
            writer.writeAll("  ") catch break;

            writer.writeAll(Color.fgColor(col)) catch break;
            writer.writeAll(size_str) catch break;
            writer.writeAll(Color.reset) catch break;
            writer.writeAll("\n") catch break;

            rows += 1;
            position += 1;
        }

        // Packages
        if (rows < 2 + MAX_LIST_ROWS and scan.package_records_len > 0 and self.visible_len == 0) {
            writer.writeAll(Color.dim) catch return;
            writer.writeAll("  Packages:\n") catch return;
            writer.writeAll(Color.reset) catch return;
            var pkg_pos: u32 = 0;
            while (pkg_pos < scan.package_records_len and rows < 2 + MAX_LIST_ROWS) : (pkg_pos += 1) {
                const pkg = scan.package_records[pkg_pos];
                const name = pkg.name(scan.package_name_pool[0..]);
                const is_focus = (pkg_pos == self.package_focus);
                const cursor: []const u8 = if (is_focus) ">" else " ";
                const state_label: []const u8 = switch (pkg.state) {
                    .active => "active",
                    .orphan => "orphan",
                    .obsolete => "obsolete",
                    .cache_removable => "cache",
                    else => "???",
                };
                const col: usize = switch (pkg.state) {
                    .active => 2,
                    .orphan => 1,
                    .obsolete => 8,
                    .cache_removable => 3,
                    else => 8,
                };

                writer.writeAll("  ") catch break;
                if (is_focus) {
                    writer.writeAll(Color.highlight) catch break;
                    writer.writeAll(cursor) catch break;
                    writer.writeAll(Color.reset) catch break;
                } else {
                    writer.writeAll(cursor) catch break;
                }
                writer.writeAll(Color.fgColor(col)) catch break;
                writer.writeAll(state_label) catch break;
                writer.writeAll(Color.reset) catch break;
                writer.writeAll("  ") catch break;
                writer.writeAll(name) catch break;
                writer.writeAll("\n") catch break;
                rows += 1;
            }
        }

        // Footer
        writer.writeAll(Color.dim) catch return;
        writer.writeAll("  j/k:nav  space:select  /:search  c:confirm  q:quit  f:filters") catch return;
        writer.writeAll(Color.reset) catch return;
        writer.writeAll("\n") catch return;

        if (self.search_open) {
            const query = self.search_query[0..self.search_len];
            writer.writeAll(Color.fgColor(5)) catch return;
            writer.print("  search: {s}_", .{query}) catch return;
            writer.writeAll(Color.reset) catch return;
        } else if (self.search_len > 0 or self.category_filter != 0 or
            self.selection_filter_selected or self.selection_filter_unselected)
        {
            const parts = self.filterSummary();
            writer.writeAll(Color.fgColor(6)) catch return;
            writer.print("  filter: {s}", .{parts}) catch return;
            writer.writeAll(Color.reset) catch return;
        } else {
            writer.writeAll(Color.dim) catch return;
            writer.writeAll("  ? help") catch return;
            writer.writeAll(Color.reset) catch return;
        }
        writer.writeAll("\n") catch return;
    }

    fn renderHelp(_: *Tui, scan: *const scan_mod.Scan, writer: *std.Io.Writer) void {
        writer.writeAll(Color.bold) catch return;
        writer.writeAll("  Help\n") catch return;
        writer.writeAll(Color.reset) catch return;
        writer.writeAll("\n") catch return;

        writer.writeAll("  Navigation:\n") catch return;
        writer.writeAll(Color.fgColor(6)) catch return;
        writer.writeAll("    j / k / up / down    Move cursor\n") catch return;
        writer.writeAll(Color.reset) catch return;

        writer.writeAll("\n  Actions:\n") catch return;
        writer.writeAll(Color.fgColor(6)) catch return;
        writer.writeAll("    space            Toggle selection\n") catch return;
        writer.writeAll("    /                Start search\n") catch return;
        writer.writeAll("    c                Confirm & execute\n") catch return;
        writer.writeAll("    q                Quit\n") catch return;
        writer.writeAll(Color.reset) catch return;

        writer.writeAll("\n  Filters:\n") catch return;
        writer.writeAll(Color.fgColor(6)) catch return;
        writer.writeAll("    f                Open filter overlay\n") catch return;
        writer.writeAll("    +                Select visible only\n") catch return;
        writer.writeAll("    -                Clear filters\n") catch return;
        writer.writeAll(Color.reset) catch return;

        writer.writeAll("\n  Categories:\n") catch return;
        writer.writeAll("    green  = cache/temp/download\n") catch return;
        writer.writeAll("    red    = backup/log/lock/broken\n") catch return;
        writer.writeAll("    blue   = vcs/source\n") catch return;
        writer.writeAll("    magenta = large files\n") catch return;
        writer.writeAll("    cyan   = config/document\n") catch return;

        writer.writeAll("\n") catch return;
        writer.writeAll(Color.dim) catch return;
        writer.print("  {d} findings  {d} warnings", .{ scan.findings_len, scan.warnings_len }) catch return;
        writer.writeAll(Color.reset) catch return;
        writer.writeAll("\n  ? close  q quit\n") catch return;
    }

    fn renderFilters(self: *Tui, writer: *std.Io.Writer) void {
        writer.writeAll(Color.bold) catch return;
        writer.writeAll("  Filters\n") catch return;
        writer.writeAll(Color.reset) catch return;
        writer.writeAll("\n") catch return;

        const categories = @typeInfo(classify.Category).@"enum".fields;
        var rows: u32 = 0;
        inline for (categories, 0..) |field, index| {
            if (rows >= MAX_LIST_ROWS) break;
            const bit = @as(u32, 1) << @intCast(index);
            const active = self.filters_draft & bit != 0;
            const cursor: []const u8 = if (self.filter_focus == index) ">" else " ";
            const col = categoryColor(@enumFromInt(index));

            writer.writeAll("  ") catch break;
            if (self.filter_focus == index) {
                writer.writeAll(Color.highlight) catch break;
                writer.writeAll(cursor) catch break;
                writer.writeAll(Color.reset) catch break;
            } else {
                writer.writeAll(cursor) catch break;
            }
            if (active) {
                writer.writeAll(Color.fgColor(1)) catch break;
                writer.writeAll("[x]") catch break;
            } else {
                writer.writeAll(Color.fgColor(8)) catch break;
                writer.writeAll("[ ]") catch break;
            }
            writer.writeAll(Color.reset) catch break;
            writer.writeAll("  ") catch break;
            writer.writeAll(Color.fgColor(col)) catch break;
            writer.writeAll(field.name) catch break;
            writer.writeAll(Color.reset) catch break;
            writer.writeAll("\n") catch break;
            rows += 1;
        }

        const sel_base: u32 = categories.len;
        writer.writeAll("\n") catch return;

        const cursor_sel: []const u8 = if (self.filter_focus == sel_base) ">" else " ";
        const cursor_unsel: []const u8 = if (self.filter_focus == sel_base + 1) ">" else " ";

        writer.writeAll("  ") catch return;
        if (self.filter_focus == sel_base) {
            writer.writeAll(Color.highlight) catch return;
            writer.writeAll(cursor_sel) catch return;
            writer.writeAll(Color.reset) catch return;
        } else {
            writer.writeAll(cursor_sel) catch return;
        }
        if (self.selection_draft_selected) {
            writer.writeAll(Color.fgColor(1)) catch return;
            writer.writeAll("[x]") catch return;
        } else {
            writer.writeAll(Color.fgColor(8)) catch return;
            writer.writeAll("[ ]") catch return;
        }
        writer.writeAll(Color.reset) catch return;
        writer.writeAll("  selected\n") catch return;

        writer.writeAll("  ") catch return;
        if (self.filter_focus == sel_base + 1) {
            writer.writeAll(Color.highlight) catch return;
            writer.writeAll(cursor_unsel) catch return;
            writer.writeAll(Color.reset) catch return;
        } else {
            writer.writeAll(cursor_unsel) catch return;
        }
        if (self.selection_draft_unselected) {
            writer.writeAll(Color.fgColor(1)) catch return;
            writer.writeAll("[x]") catch return;
        } else {
            writer.writeAll(Color.fgColor(8)) catch return;
            writer.writeAll("[ ]") catch return;
        }
        writer.writeAll(Color.reset) catch return;
        writer.writeAll("  unselected\n") catch return;

        writer.writeAll("\n") catch return;
        writer.writeAll(Color.dim) catch return;
        writer.writeAll("  space:toggle  Enter:apply  Esc:cancel") catch return;
        writer.writeAll(Color.reset) catch return;
    }

    fn renderConfirmation(self: *Tui, scan: *const scan_mod.Scan, paths: [][]const u8, writer: *std.Io.Writer) void {
        writer.writeAll(Color.bold) catch return;
        writer.writeAll("  Confirm Plan\n") catch return;
        writer.writeAll(Color.reset) catch return;
        writer.writeAll("\n") catch return;

        var selected_count: u32 = 0;
        var unavailable_count: u32 = 0;
        var counted: u32 = 0;
        while (counted < scan.findings_len) : (counted += 1) {
            const f = scan.findings[counted];
            if (!f.selected) continue;
            selected_count += 1;
            const ineligible = f.kind != .file and f.kind != .symlink and f.kind != .directory;
            if (self.log_broken or ineligible) unavailable_count += 1;
        }

        writer.writeAll("  Actions: ") catch return;
        writer.writeAll(Color.fgColor(15)) catch return;
        writer.print("{d}", .{selected_count}) catch return;
        writer.writeAll(Color.reset) catch return;
        if (unavailable_count > 0) {
            writer.writeAll(" (") catch return;
            writer.writeAll(Color.fgColor(1)) catch return;
            writer.print("{d} unavailable", .{unavailable_count}) catch return;
            writer.writeAll(Color.reset) catch return;
            writer.writeAll(")") catch return;
        }
        writer.writeAll("\n\n") catch return;

        if (self.plan) |plan| {
            var index: u32 = 0;
            var rows: u32 = 0;
            while (index < plan.actions_len and rows < MAX_LIST_ROWS) : (index += 1) {
                const action = plan.actions[index];
                const is_complete = action.complete;
                const status: []const u8 = if (is_complete) "x" else "!";
                const col: usize = if (is_complete) 2 else 1;

                const kind_label: []const u8 = switch (action.kind) {
                    .file => "file",
                    .symlink => "link",
                    .directory => "dir",
                    .package_purge => "purge",
                    .package_clean => "clean",
                };

                var label_buf: [128]u8 = undefined;
                const label: []const u8 = switch (action.kind) {
                    .file, .symlink, .directory => blk: {
                        break :blk truncatePath(&label_buf, paths[action.finding], 40);
                    },
                    .package_purge => blk: {
                        if (action.finding < scan.package_records_len) {
                            const pkg = scan.package_records[action.finding];
                            const name = pkg.name(scan.package_name_pool[0..]);
                            @memcpy(label_buf[0..name.len], name);
                            break :blk label_buf[0..name.len];
                        }
                        break :blk "?";
                    },
                    .package_clean => "apt clean (cache)",
                };

                writer.writeAll("  ") catch break;
                writer.writeAll(Color.fgColor(col)) catch break;
                writer.writeAll(status) catch break;
                writer.writeAll(Color.reset) catch break;
                writer.writeAll(" [") catch break;
                writer.writeAll(Color.fgColor(6)) catch break;
                writer.writeAll(kind_label) catch break;
                writer.writeAll(Color.reset) catch break;
                writer.writeAll("] ") catch break;
                writer.writeAll(label) catch break;
                writer.writeAll("\n") catch break;
                rows += 1;
            }
        }

        writer.writeAll("\n") catch return;
        writer.writeAll(Color.dim) catch return;
        writer.writeAll("  This will delete selected items permanently.\n") catch return;
        writer.writeAll(Color.reset) catch return;

        writer.writeAll("\n  confirm: ") catch return;
        writer.writeAll(Color.fgColor(5)) catch return;
        writer.writeAll(self.confirm.text()) catch return;
        writer.writeAll(Color.reset) catch return;
        writer.writeAll("  ") catch return;
        writer.writeAll(Color.dim) catch return;
        writer.writeAll("Enter:accept  Esc:cancel") catch return;
        writer.writeAll(Color.reset) catch return;
    }

    fn renderReport(self: *Tui, writer: *std.Io.Writer) void {
        writer.writeAll(Color.bold) catch return;
        writer.writeAll("  Results\n") catch return;
        writer.writeAll(Color.reset) catch return;
        writer.writeAll("\n") catch return;

        writer.writeAll("  ") catch return;
        writer.writeAll(Color.fgColor(2)) catch return;
        writer.writeAll("x ") catch return;
        writer.writeAll(Color.reset) catch return;
        writer.writeAll(" Success: ") catch return;
        writer.writeAll(Color.fgColor(2)) catch return;
        writer.print("{d}", .{self.report_ok}) catch return;
        writer.writeAll(Color.reset) catch return;
        writer.writeAll("\n") catch return;

        writer.writeAll("  ") catch return;
        writer.writeAll(Color.fgColor(1)) catch return;
        writer.writeAll("! ") catch return;
        writer.writeAll(Color.reset) catch return;
        writer.writeAll(" Failed:  ") catch return;
        writer.writeAll(Color.fgColor(1)) catch return;
        writer.print("{d}", .{self.report_failed}) catch return;
        writer.writeAll(Color.reset) catch return;
        writer.writeAll("\n") catch return;

        if (self.message.len > 0) {
            writer.writeAll("  ") catch return;
            writer.writeAll(Color.fgColor(3)) catch return;
            writer.writeAll(self.message) catch return;
            writer.writeAll(Color.reset) catch return;
            writer.writeAll("\n") catch return;
        }

        writer.writeAll("\n") catch return;
        writer.writeAll(Color.dim) catch return;
        writer.writeAll("  q quit") catch return;
        writer.writeAll(Color.reset) catch return;
    }

    pub fn rebuildVisibility(self: *Tui, scan: *const scan_mod.Scan, paths: [][]const u8) void {
        const query = self.search_query[0..self.search_len];
        const category_active = self.category_filter != 0;
        const selection_active = self.selection_filter_selected or self.selection_filter_unselected;
        self.visible_len = 0;
        var index: u32 = 0;
        while (index < scan.findings_len) : (index += 1) {
            if (category_active and self.category_filter & (@as(u32, 1) << @intCast(scan.findings[index].category)) == 0) continue;
            if (selection_active) {
                const want_selected = if (scan.findings[index].selected) self.selection_filter_selected else self.selection_filter_unselected;
                if (!want_selected) continue;
            }
            if (query.len > 0 and std.mem.indexOf(u8, scan.findings[index].name, query) == null) continue;
            if (self.visible_len >= self.visible.len) break;
            self.visible[self.visible_len] = index;
            self.visible_len += 1;
        }
        const Order = struct {
            scan: *const scan_mod.Scan,
            paths: [][]const u8,
            fn sizeKnown(finding: scan_mod.Finding) bool {
                return finding.blocks_known and finding.allocated_bytes != scan_mod.unknown_size;
            }
            fn lessThan(ctx: @This(), a: u32, b: u32) bool {
                const first = ctx.scan.findings[a];
                const second = ctx.scan.findings[b];
                const first_known = sizeKnown(first);
                const second_known = sizeKnown(second);
                if (first_known != second_known) return first_known;
                if (first_known and first.allocated_bytes != second.allocated_bytes) {
                    return first.allocated_bytes > second.allocated_bytes;
                }
                if (first.category != second.category) return first.category < second.category;
                return std.mem.order(u8, ctx.paths[a], ctx.paths[b]) == .lt;
            }
        };
        std.mem.sort(u32, self.visible[0..self.visible_len], Order{ .scan = scan, .paths = paths }, Order.lessThan);
    }

    fn filterSummary(self: *Tui) []const u8 {
        var buffer: [96]u8 = undefined;
        var writer = std.Io.Writer.fixed(&buffer);
        if (self.category_filter != 0) {
            writer.print("category#{x:0>8}", .{self.category_filter}) catch {};
        }
        if (self.selection_filter_selected) {
            if (writer.end > 0) writer.writeAll("+") catch {};
            writer.writeAll("sel") catch {};
        }
        if (self.selection_filter_unselected) {
            if (writer.end > 0) writer.writeAll("+") catch {};
            writer.writeAll("unsel") catch {};
        }
        const written = buffer[0..writer.end];
        const stored = self.filter_summary_buffer[0..written.len];
        @memcpy(stored, written);
        return stored;
    }

    fn visibleContains(self: *Tui, finding: u32) bool {
        var position: u32 = 0;
        while (position < self.visible_len) : (position += 1) {
            if (self.visible[position] == finding) return true;
        }
        return false;
    }

    fn writeFrame(self: *Tui, bytes: []const u8) void {
        _ = self;
        var stdout = std.Io.File.stdout();
        stdout.writeStreamingAll(std.Io.Threaded.global_single_threaded.io(), bytes) catch {};
    }
};

fn categoryColor(cat: classify.Category) usize {
    return switch (cat) {
        .stale_temporary, .heuristic_temporary => 2,
        .broken_symlink => 1,
        .rotated_log, .live_log => 1,
        .lock_file => 1,
        .other => 8,
        .download => 2,
        .workspace => 5,
        .source => 4,
        .document => 6,
        .trash => 8,
    };
}

pub const Key = union(enum) {
    byte: u8,
    up,
    down,
    left,
    right,
};