summaryrefslogtreecommitdiff
path: root/src/tagline.zig
blob: 14bf5677a350c9cf68bd11395b9a8c4bb5a46cca (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! The tags (acme's tag half of wind.c): a pane's computed prefix and its
//! default and saved tails, going into a tag and out again, the path drafted
//! as a new name, and the column and workspace tags above the panes.
const pardes = @import("pardes.zig");
const std = @import("std");
const modal = @import("modal.zig");
const ctlfs = @import("ninep/tree.zig");
const panes = @import("panes.zig");
const edit = @import("edit.zig");
const exec = @import("exec.zig");
const config = @import("config.zig");
const pdf_enabled = panes.Pdf.enabled;
const dump = @import("dump.zig");
const Pane = panes.Pane;
const Text = panes.Text;
const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET;
const Pardes = pardes.Pardes;

pub const pane_tail = " " ++ config.pane_builtins_str;
pub const pdf_pane_tail = " " ++ config.pdf_pane_builtins_str;
pub const file_pane_tail = " " ++ config.file_pane_builtins_str;
pub const context_pane_tail = file_pane_tail ++ " TreeContext";
pub const locations_pane_tail = file_pane_tail ++ " LocationsConfig";
pub const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str;
pub const command_pane_tail = " " ++ config.command_pane_builtins_str;
const dirty_marker = " *";
pub const legacy_pane_tail = " Del";
pub const legacy_file_pane_tail = " Save Del";
// The defaults from the release before Newtty joined every tagline. Recognized
// so a dump written then upgrades instead of keeping the old word as a tail.
const prev_pane_tail = " New Del";
pub const prev_file_pane_tail = " Save New Del";
const legacy_terminal_pane_tail = " New Del Filter";
// ...and the terminal default from the release before Save reached it, when a
// scrollback was not yet something you could write to a path.
pub const prev_terminal_pane_tail = " New Newtty Del Filter";
const pre_tty_terminal_pane_tail = " Save New Newtty Del Filter";
const pre_column_pane_tail = " New Newtty Del";
pub const pre_column_file_pane_tail = " Save New Newtty Del";
const pre_column_pdf_pane_tail = " New Newtty Del PdfSections PdfTint";
pub const pre_column_terminal_pane_tail = " Save New Newtty Del Tty Filter";
const pre_collapse_pane_tail = " Tty Del";
pub const pre_collapse_file_pane_tail = " Save Tty Del";
const pre_collapse_pdf_pane_tail = " Tty Del PdfSections PdfTint";
pub const pre_collapse_terminal_pane_tail = " Save Tty Del Togglettymode Filter";
pub const pre_tty_first_terminal_pane_tail = " Save Tty Del Togglettymode Filter Collapse";
pub const pre_mode_terminal_pane_tail = " Tty Save Del Togglettymode Filter Collapse";
// ...and the defaults from before the closing word moved to the end of every
// tagline, where a click cannot land on it by overshooting the one before.
const pre_close_last_pane_tail = " Tty Del Collapse";
const pre_close_last_file_pane_tail = " Save Tty Del Collapse";
const pre_close_last_pdf_pane_tail = " Tty Del PdfSections PdfTint Collapse";
const pre_close_last_terminal_pane_tail = " Tty Save Del Mode Filter Collapse";

/// What a column's tag says until someone edits it.
pub const column_tag_default = "New Tty Find Grep Joincol Delcol";

/// What a header says until someone edits it: for the workspace the
/// fixed builtins, plus `Restore <path>` once a dump exists.
pub fn headerDefault(p: *Pardes, what: Text.What) []const u8 {
    if (what == .column) return column_tag_default;
    const d = p.last_dump orelse return config.topbar_str;
    return std.fmt.allocPrint(p.scratch.allocator(), config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str;
}

/// the topbar line -- render and click dispatch must agree on this exact
/// string
pub fn topbar(p: *Pardes) []const u8 {
    return p.global_tag.own orelse headerDefault(p, .workspace);
}

pub fn columnTag(p: *const Pardes, c: usize) []const u8 {
    return p.col_tags[c].own orelse column_tag_default;
}

/// The header with the keyboard, when one has it.
pub fn focusedHeader(p: *Pardes) ?*Text {
    if (!p.header_focus) return null;
    if (p.header_column) |c| return if (c < p.ncol) &p.col_tags[c] else null;
    return &p.global_tag;
}

pub fn revealHeader(p: *Pardes) void {
    const t = focusedHeader(p) orelse {
        p.header_scroll = 0;
        p.header_top = 0;
        return;
    };
    const text = edit.flatSurface(p, t) catch return;
    const cur: usize = @intCast(@max(0, t.cur_row));
    const rows: usize = if (p.header_column != null) p.columnBarHeight() else p.topBarHeight();
    if (cur < p.header_top) p.header_top = cur;
    if (rows > 0 and cur >= p.header_top + rows) p.header_top = cur + 1 - rows;
    const line = modal.lineSlice(text, cur);
    const width = p.tagCapacity(if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else p.screen_w);
    const col = panes.File.rawDisplayCol(line, @min(@as(usize, @intCast(@max(0, t.cur_col))), line.len));
    if (col < p.header_scroll) p.header_scroll = col;
    if (col >= p.header_scroll + width) p.header_scroll = col -| (width -| 1);
    var raw = panes.File.rawAtDisplay(line, p.header_scroll);
    p.header_scroll = panes.File.rawDisplayCol(line, raw);
    if (width > 0 and col >= p.header_scroll + width) {
        raw = modal.nextGrapheme(line, raw);
        p.header_scroll = panes.File.rawDisplayCol(line, raw);
    }
}

/// The computed start of a pane's tag -- its path and dirty marker, a
/// PDF's page, a terminal's directory -- without the space that parts it
/// from the tag's own text. It is never stored, and never edited.
pub fn pathPrefix(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
    if (comptime pdf_enabled) if (pane.pdf) |pv|
        return std.fmt.allocPrint(arena, "{s} [{d}/{d}]", .{ pv.path, pv.page + 1, pv.page_count });
    if (pane.image) |*state| return panes.Image.tagPrefix(arena, state);
    // A command pane: its directory, the line it runs, and how that went.
    if (pane.command) |line| {
        const shown = line[0..@min(line.len, 48)];
        const more = if (shown.len < line.len) "..." else "";
        var idbuf: [48]u8 = undefined;
        const id = exec.replId(&idbuf, pane);
        const gap = if (id.len > 0) " " else "";
        if (!pane.command_done) return std.fmt.allocPrint(arena, "{s} ({s}{s}) running{s}{s}", .{ pane.cwdSlice(), shown, more, gap, id });
        if (pane.command_status) |n| return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit {d}{s}{s}", .{ pane.cwdSlice(), shown, more, n, gap, id });
        return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit ?{s}{s}", .{ pane.cwdSlice(), shown, more, gap, id });
    }
    // A bound REPL says what it is bound as, beside its Tty word.
    if (pane.repl != null) {
        var idbuf: [48]u8 = undefined;
        return std.fmt.allocPrint(arena, "{s} {s}", .{ pane.cwdSlice(), exec.replId(&idbuf, pane) });
    }
    if (pane.file) |f| {
        const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path;
        if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision)
            return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker });
        return name;
    }
    return pane.cwdSlice();
}

pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 {
    return prefixIn(p.scratch.allocator(), pane);
}

/// The prefix as the tag shows it, built in `arena`.
pub fn prefixIn(arena: std.mem.Allocator, pane: *Pane) ![]u8 {
    const prefix = try pathPrefix(arena, pane);
    // The space between the prefix and the tag's own text is the text's
    // (every default starts with one), but the prefix is computed, so it
    // cannot trust the text to keep it: with it deleted, Look and Exec
    // would see the path and the first word as one. The prefix brings its
    // own then.
    const tail = curTail(pane);
    if (tail.len > 0 and std.mem.indexOfScalar(u8, " \t\n", tail[0]) == null)
        return std.fmt.allocPrint(arena, "{s} ", .{prefix});
    return arena.dupe(u8, prefix);
}

/// The tag's own text: what the user made of it, or until then the
/// default a pane of its kind starts with.
pub fn curTail(pane: *Pane) []const u8 {
    return pane.tag.own orelse defaultTail(pane);
}

pub fn defaultTail(pane: *const Pane) []const u8 {
    if (comptime pdf_enabled) if (pane.pdf != null) return pdf_pane_tail;
    if (pane.file) |file| if (file.output) |out|
        if (panes.Output.usesLocationsConfig(out.from)) return locations_pane_tail;
    if (panes.File.supportsContext(pane)) return context_pane_tail;
    if (pane.file != null) return file_pane_tail;
    if (pane.command != null) return command_pane_tail;
    if (pane.isTerminal()) return if (pane.shell_tail_len > 0) pane.shell_tail[0..pane.shell_tail_len] else terminal_pane_tail;
    return pane_tail;
}

/// The tag as it is shown, read and pointed at: its computed prefix and
/// its own text after it.
pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 {
    const prefix = try tagPrefix(p, pane);
    const tail = curTail(pane);
    const out = try arena.alloc(u8, prefix.len + tail.len);
    @memcpy(out[0..prefix.len], prefix);
    @memcpy(out[prefix.len..], tail);
    return out;
}

/// Give the keyboard to the tag in normal mode (`:`), its cursor where
/// the tag last had it. The first time there is nothing to remember, and
/// it starts on Save, so that `:` and the execute key write the file.
pub fn enterTag(p: *Pardes, pane: *Pane) void {
    const t = &pane.tag;
    const whole = tagText(p, p.scratch.allocator(), pane) catch return;
    if (!t.cur_pinned) {
        // Save is looked for in the tag's own text, after the prefix.
        const lead = whole.len - curTail(pane).len;
        const tail = whole[lead..];
        var words = std.mem.tokenizeAny(u8, tail, " \t\n");
        const at = lead + while (words.next()) |word| {
            if (std.mem.eql(u8, word, "Save")) break @intFromPtr(word.ptr) - @intFromPtr(tail.ptr);
        } else tail.len - std.mem.trimStart(u8, tail, " ").len;
        const cursor = modal.positionAt(whole, @min(at, whole.len));
        t.cur_row = @intCast(cursor.row);
        t.cur_col = @intCast(cursor.col);
        t.cur_pinned = true;
        // Reveal the suffix together with its commands, not merely the
        // first command letter at the viewport's far right edge.
        if (p.paneIdOf(pane)) |id| {
            const line = whole[0 .. std.mem.indexOfScalar(u8, whole, '\n') orelse whole.len];
            const width = p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET);
            pane.tag_scroll = @intCast(panes.File.displayWidth(line) + 1 -| width);
        }
    }
    // The text may have changed since (a 9P write, a rename), so the
    // remembered cursor is pulled back inside it. It is the cursor that
    // is remembered, not a selection.
    const rows = modal.cursorLineCount(whole);
    t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1));
    const line = modal.lineSlice(whole, @intCast(t.cur_row));
    t.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, t.cur_col))))));
    t.vsel.active = false;
    t.msel.active = false;
    t.nsel = 0;
    t.select = false;
    t.append_at = null;
    t.sticky_col = -1;
    t.mode = .normal;
    t.normal.clear();
    pane.focus = .tag;
}

/// Where display column `col` of the tag's shown row `row` (tag scroll
/// included) puts its cursor: the tag as shown, prefix and all.
pub fn tagOffsetAt(p: *Pardes, pane: *Pane, row: usize, col: usize) ?modal.Cursor {
    const at = row + pane.tag_top;
    const line = modal.lineSlice(tagText(p, p.scratch.allocator(), pane) catch return null, at);
    return .{ .row = at, .col = modal.graphemeStart(line, panes.File.rawAtDisplay(line, col)) };
}

/// Whether display column `col` of the tag's shown row `row` is on a name
/// a click drafts: a saving file's path.
pub fn onName(pane: *Pane, row: usize, col: usize) bool {
    if (row + pane.tag_top != 0) return false;
    const f = pane.file orelse return false;
    if (!panes.Output.fileTraits(f.output).saves) return false;
    const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path;
    return col < panes.File.displayWidth(name);
}

/// A key typed into a saving file's path, with the tag's cursor there,
/// drafts a new name as a click on it does: the path is computed, so it is
/// never the tag's own text to edit, but a buffer's name can be drafted.
/// True when it did.
pub fn typedIntoName(p: *Pardes, pane: *Pane, key: pardes.Key) bool {
    const f = pane.file orelse return false;
    if (!panes.Output.fileTraits(f.output).saves or pane.tag.cur_row != 0) return false;
    const at: usize = @intCast(@max(0, pane.tag.cur_col));
    const back = key.cp == pardes.Key.backspace or pardes.hit(key, config.insert_backspace_alias);
    const forward = key.cp == pardes.Key.delete or pardes.hit(key, config.insert_delete_alias);
    const typed = key.text.len > 0 and !key.ctrl and !key.alt;
    const inside = if (back) at > 0 and at <= f.path.len else if (forward or typed) at < f.path.len else false;
    if (!inside) return false;
    draftName(p, pane, panes.File.displayWidth(f.path[0..at]));
    edit.handleInsert(p, &pane.input, key);
    return true;
}

/// Start a name draft for a file that can take one, its caret at display
/// column `col` of the path. Nothing changes until it is typed into.
pub fn draftName(p: *Pardes, pane: *Pane, col: usize) void {
    const f = pane.file orelse return;
    if (!panes.Output.fileTraits(f.output).saves) return;
    const name = p.gpa.dupe(u8, f.path) catch return;
    pane.closePrompt();
    pane.prompt = .name;
    pane.input = .{ .what = .input, .own = name, .mode = .insert, .cur_pinned = true, .cur_col = @intCast(@min(name.len, panes.File.rawAtDisplay(name, col))) };
}

/// Commit a drafted buffer name, never a disk rename or write. Keep the
/// draft visible on failure so the user can correct it or cancel with Esc.
pub fn commitTagName(p: *Pardes, pane: *Pane) bool {
    if (pane.prompt != .name) return true;
    const id = p.paneIdOf(pane) orelse return false;
    const name = pane.input.own orelse "";
    if (name.len == 0 or name[name.len - 1] == '/' or !std.unicode.utf8ValidateSlice(name)) {
        p.reportError(id, "name", error.InvalidName);
        return false;
    }
    for (name) |byte| if (byte < ' ') {
        p.reportError(id, "name", error.InvalidName);
        return false;
    };
    const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ Pardes.paneDir(pane), name }) catch |err| {
        p.reportError(id, "name", err);
        return false;
    };
    ctlfs.pane.nameBuffer(p, id, full, true) catch |err| {
        p.reportError(id, "name", err);
        return false;
    };
    pane.closePrompt();
    return true;
}

pub fn exitHeader(p: *Pardes) void {
    p.header_focus = false;
    p.header_column = null;
    p.header_drag = false;
    p.header_scroll = 0;
    p.header_top = 0;
}

/// Give the keyboard to a column's tag, or the workspace's for null, in
/// normal mode, its cursor where that header last had it.
pub fn enterHeader(p: *Pardes, column: ?usize) void {
    exitHeader(p);
    if (p.panes[p.active]) |pane| pane.focus = .body;
    p.header_focus = true;
    p.header_column = column;
    const t = focusedHeader(p) orelse return exitHeader(p);
    const text = edit.flatSurface(p, t) catch "";
    const rows = modal.cursorLineCount(text);
    t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1));
    const line = modal.lineSlice(text, @intCast(t.cur_row));
    t.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, t.cur_col))))));
    t.cur_pinned = true;
    t.vsel.active = false;
    t.msel.active = false;
    t.nsel = 0;
    t.select = false;
    t.append_at = null;
    t.mode = .normal;
    t.normal.clear();
}

pub fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void {
    if (src.tag_tail) |tail| {
        pane.tag.own = p.gpa.dupe(u8, tail) catch return;
        return;
    }
    const tail_class = tailClass(pane);
    if (src.kind == .image and panes.Pdf.isPath(src.image.?.path)) {
        if (panes.Pdf.legacySavedPrefix(src.image.?.path, src.tag)) |prefix|
            return restoreTailAt(p, pane, src.tag, prefix, .generic);
    }
    if (src.kind == .terminal and std.mem.startsWith(u8, src.tag, "TTY ")) {
        const legacy = std.fmt.allocPrint(p.scratch.allocator(), "TTY {s}", .{src.terminal.?.cwd}) catch return;
        if (std.mem.startsWith(u8, src.tag, legacy))
            return restoreTailAt(p, pane, src.tag, legacy, .terminal);
    }
    const current = tagPrefix(p, pane) catch return;
    if (std.mem.startsWith(u8, src.tag, current))
        return restoreTailAt(p, pane, src.tag, savedPrefix(src.tag, current, tail_class), tail_class);
    if (src.kind != .image) return;
    const saved = src.image.?;
    if (pane.image) |*state| if (panes.Image.legacySavedPrefix(state, src.tag)) |legacy|
        return restoreTailAt(p, pane, src.tag, legacy, .generic);
    if (panes.Pdf.isPath(saved.path)) if (panes.Pdf.legacySavedPrefix(saved.path, src.tag)) |legacy|
        restoreTailAt(p, pane, src.tag, legacy, .generic);
}

pub fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void {
    const pfx = tagPrefix(p, pane) catch return;
    const class = tailClass(pane);
    restoreTailAt(p, pane, saved_tag, savedPrefix(saved_tag, pfx, class), class);
}

const TailClass = enum { generic, file, output, terminal };

fn tailClass(pane: *const Pane) TailClass {
    if (pane.file) |f| return if (f.output == null) .file else .output;
    if (pane.isTerminal()) return .terminal;
    return .generic;
}

fn savedPrefix(saved_tag: []const u8, live: []const u8, class: TailClass) []const u8 {
    if (class == .generic or class == .terminal) return live;
    if (!std.mem.startsWith(u8, saved_tag, live)) return live;
    const marked_len = live.len + dirty_marker.len;
    if (saved_tag.len >= marked_len and std.mem.eql(u8, saved_tag[live.len..marked_len], dirty_marker))
        return saved_tag[0..marked_len];
    return live;
}

pub fn restoreTailAt(
    p: *Pardes,
    pane: *Pane,
    saved_tag: []const u8,
    pfx: []const u8,
    class: TailClass,
) void {
    if (!std.mem.startsWith(u8, saved_tag, pfx)) return;
    const rest = saved_tag[pfx.len..];
    const said = std.mem.trimStart(u8, rest, " ");
    const defaults: []const []const u8 = switch (class) {
        .generic => &.{ pdf_pane_tail, pane_tail, pre_close_last_pdf_pane_tail, pre_close_last_pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail },
        .file => &.{ context_pane_tail, file_pane_tail, pre_close_last_file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail },
        .output => &.{
            locations_pane_tail,
            file_pane_tail,
            pre_close_last_file_pane_tail,
            pre_close_last_pane_tail,
            prev_file_pane_tail,
            legacy_file_pane_tail,
            pane_tail,
            prev_pane_tail,
            legacy_pane_tail,
            pre_column_file_pane_tail,
            pre_column_pane_tail,
            pre_collapse_file_pane_tail,
            pre_collapse_pane_tail,
        },
        .terminal => &.{ terminal_pane_tail, pre_close_last_terminal_pane_tail, pre_close_last_pane_tail, pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail },
    };
    for (defaults) |default|
        if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return;
    pane.tag.own = p.gpa.dupe(u8, rest) catch return;
}

/// The span a header's selection covers on line `row` of its text, in
/// that line's bytes: up to the caret while typing, and over the
/// cursor's own cell too in normal mode, where a cursor is a cell.
pub fn headerSelection(t: *const Text, text: []const u8, row: usize) ?struct { lo: usize, hi: usize } {
    if (!t.vsel.active) return null;
    const anchor = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.vsel.row)), .col = @intCast(@max(0, t.vsel.col)) });
    const cursor = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) });
    const lo = @min(anchor, cursor);
    const hi = if (t.mode == .insert) @max(anchor, cursor) else modal.nextGrapheme(text, @max(anchor, cursor));
    const start = modal.lineStartOffset(text, row);
    const end = modal.lineEndOffset(text, row);
    if (hi <= start or lo >= end) return null;
    return .{ .lo = @max(lo, start) - start, .hi = @min(hi, end) - start };
}