diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Text.zig | 4 | ||||
| -rw-r--r-- | src/body_layer.zig | 16 | ||||
| -rw-r--r-- | src/edit.zig | 30 | ||||
| -rw-r--r-- | src/mouse.zig | 21 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 7 | ||||
| -rw-r--r-- | src/panes.zig | 5 | ||||
| -rw-r--r-- | src/pardes.zig | 35 | ||||
| -rw-r--r-- | src/tagline.zig | 90 |
8 files changed, 153 insertions, 55 deletions
diff --git a/src/Text.zig b/src/Text.zig index 61f238ae..43dd8a95 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -74,6 +74,10 @@ ed_undo: [Terminal.history_max]Terminal.Snapshot = undefined, ed_undo_len: usize = 0, ed_redo: [Terminal.history_max]Terminal.Snapshot = undefined, ed_redo_len: usize = 0, +/// Set when an edit would have changed characters this text does not own +/// (a tag's computed prefix) and was refused; the key that tried it puts +/// the cursor back. +refused: bool = false, /// The window this text belongs to (acme's t->w). A column's or the /// workspace's tag belongs to none, and the optional makes every caller diff --git a/src/body_layer.zig b/src/body_layer.zig index ff1f903c..f9e26b10 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -925,8 +925,7 @@ pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) } else if (pane.focus == .tag and pane.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: { const row: usize = @intCast(pane.tag.cur_row - top); - const lead: usize = if (row + top == 0) prefix_len else 0; - break :caret .{ .row = row, .at = @min(lines[row].len, lead + @as(usize, @intCast(@max(0, pane.tag.cur_col)))) }; + break :caret .{ .row = row, .at = @min(lines[row].len, @as(usize, @intCast(@max(0, pane.tag.cur_col)))) }; } else null; if (caret) |at| { const line = lines[at.row]; @@ -968,19 +967,18 @@ pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, // time. A range of one grapheme is the block cursor, not a selection. if (pane.focus == .tag) { var ranges: [Text.max_selections]modal.Selection = undefined; - const got = pane.tag.ranges(tail, 0, &ranges); + const got = pane.tag.ranges(whole, 0, &ranges); for (ranges[0..got.n]) |range| { const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); - if (modal.nextGrapheme(tail, lo) >= hi) continue; - const first = modal.positionAt(tail, lo); - const last = modal.positionAt(tail, hi); + if (modal.nextGrapheme(whole, lo) >= hi) continue; + const first = modal.positionAt(whole, lo); + const last = modal.positionAt(whole, hi); var row = @max(first.row, top); while (row <= last.row and row < top + rows) : (row += 1) { const line = lines[row - top]; - const lead: usize = if (row == 0) prefix_len else 0; - const from = lead + if (row == first.row) first.col else 0; - const to = @min(line.len, lead + if (row == last.row) last.col else modal.lineSlice(tail, row).len); + const from = if (row == first.row) first.col else 0; + const to = @min(line.len, if (row == last.row) last.col else line.len); var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll; const end = panes.File.rawDisplayCol(line, to); const y = tag_y + @as(u16, @intCast(row - top)); diff --git a/src/edit.zig b/src/edit.zig index e21352e1..b2d71ed2 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -112,8 +112,12 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void { /// into, after the cursor's cell in normal mode, as a body's paste is. fn pasteInto(p: *Pardes, t: *Text, bytes: []const u8) void { if (bytes.len == 0) return; - if (t.mode == .insert) return insertKey(p, t, .{ .cp = bytes[0], .text = bytes }); - pasteText(p, t, bytes, false, 1); + const typed: pardes.Key = .{ .cp = bytes[0], .text = bytes }; + if (t.what == .tag and t.mode == .insert) if (t.pane()) |pane| if (tagline.typedIntoName(p, pane, typed)) return; + const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel }; + t.refused = false; + if (t.mode == .insert) insertKey(p, t, typed) else pasteText(p, t, bytes, false, 1); + if (t.refused) t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; } pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { @@ -358,7 +362,8 @@ pub fn pinPaneCursor(p: *Pardes, t: *Text) void { pub fn flatSurface(p: *Pardes, t: *Text) ![]const u8 { switch (t.what) { .body => {}, - .tag => return if (t.pane()) |pane| tagline.curTail(pane) else "", + // the tag as it is shown: the keyboard reaches its prefix too + .tag => return if (t.pane()) |pane| try tagline.tagText(p, p.scratch.allocator(), pane) else "", .input => return t.own orelse "", .column, .workspace => return t.own orelse tagline.headerDefault(p, t.what), } @@ -384,6 +389,10 @@ pub fn paneWrapWidth(p: *const Pardes, t: *Text) usize { fn editText(p: *Pardes, t: *Text, lo: i32, hi: i32, col: i32) ?panes.EditText { // A tag or header nobody has changed shows its default; the first edit // makes that default its own text. + if (t.what == .tag) { + const pane = t.pane() orelse return null; + return .{ .text = tagline.tagText(p, p.scratch.allocator(), pane) catch return null, .row0 = 0 }; + } if (t.what != .body and t.what != .input and t.own == null) t.own = p.gpa.dupe(u8, flatSurface(p, t) catch return null) catch return null; if (t.what != .body) return .{ .text = t.own orelse &.{}, .row0 = 0 }; @@ -403,6 +412,21 @@ fn editTextEol(p: *Pardes, t: *Text, b: Bounds) ?panes.EditText { /// install a rewritten editable text (frees the old one) pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void { + if (t.what == .tag) { + // A tag is edited as it is shown, but only what follows its + // prefix is its own: an edit that changed the prefix is refused. + defer p.gpa.free(new); + const pane = t.pane() orelse return; + const prefix = tagline.pathPrefix(p.scratch.allocator(), pane) catch return; + if (!std.mem.startsWith(u8, new, prefix)) { + t.refused = true; + return; + } + const tail = p.gpa.dupe(u8, new[prefix.len..]) catch return; + if (t.own) |old| p.gpa.free(old); + t.own = tail; + return; + } if (t.what != .body) { if (t.own) |old| p.gpa.free(old); t.own = new; diff --git a/src/mouse.zig b/src/mouse.zig index 69feea29..beb2e624 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -827,11 +827,14 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { if (sl.r0 < pane.tag_rows) { // On the tag the caret goes where the button came up. A // click on the path, if it can be renamed, drafts a name. - const at = tagline.tagOffsetAt(p, pane, @intCast(@max(0, sl.r1)), @intCast(@max(0, sl.c1))); - if (at) |cursor| { + const row: usize = @intCast(@max(0, sl.r1)); + const col: usize = @intCast(@max(0, sl.c1)); + if (!dragged and tagline.onName(pane, row, col)) { + tagline.draftName(p, pane, col); + } else if (tagline.tagOffsetAt(p, pane, row, col)) |cursor| { pane.tag.cur_row = @intCast(cursor.row); pane.tag.cur_col = @intCast(cursor.col); - } else if (!dragged) tagline.draftName(p, pane, @intCast(@max(0, sl.c1))); + } return; } if (dragged) edit.capturePointerSelection(p, pane, sel_slot) catch {}; @@ -932,7 +935,8 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { }; if (@min(pane.sel[sel_slot].r0, pane.sel[sel_slot].r1) < pane.tag_rows) { // A sweep over the tag cuts from and pastes into the tag's own text; - // the part of it over the prefix is not the tag's to cut. + // the part of it over the prefix is not the tag's to cut, so the + // sweep starts after it. const t = &pane.tag; if (!s.chorded) { s.chorded = true; @@ -941,9 +945,12 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { const forward = sl.r0 < sl.r1 or (sl.r0 == sl.r1 and sl.c0 <= sl.c1); const start = if (forward) .{ sl.r0, sl.c0 } else .{ sl.r1, sl.c1 }; const end = if (forward) .{ sl.r1, sl.c1 } else .{ sl.r0, sl.c0 }; - const origin: modal.Cursor = .{ .row = 0, .col = 0 }; - const from = tagline.tagOffsetAt(p, pane, @intCast(@max(0, start[0])), @intCast(@max(0, start[1]))) orelse origin; - const to = tagline.tagOffsetAt(p, pane, @intCast(@max(0, end[0])), @intCast(@max(0, end[1]))) orelse origin; + const lead = pane.tag_lead; + const origin: modal.Cursor = .{ .row = 0, .col = lead }; + var from = tagline.tagOffsetAt(p, pane, @intCast(@max(0, start[0])), @intCast(@max(0, start[1]))) orelse origin; + var to = tagline.tagOffsetAt(p, pane, @intCast(@max(0, end[0])), @intCast(@max(0, end[1]))) orelse origin; + if (from.row == 0) from.col = @max(from.col, lead); + if (to.row == 0) to.col = @max(to.col, lead); t.cur_row = @intCast(to.row); t.cur_col = @intCast(to.col); t.cur_pinned = true; diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 80dd97cd..64355e0e 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -613,8 +613,9 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status { pane.tag.remember(p.gpa, if (pane.tag.own) |own| .{ .text = own } else null); if (pane.tag.own) |own| p.gpa.free(own); pane.tag.own = empty; + // what is left is the prefix; the cursor goes after it pane.tag.cur_row = 0; - pane.tag.cur_col = 0; + pane.tag.cur_col = if (tagline.pathPrefix(p.scratch.allocator(), pane)) |prefix| @intCast(prefix.len) else |_| 0; pane.tag.vsel.active = false; pane.tag.nsel = 0; }, @@ -910,9 +911,9 @@ test "truncating the tag clears its editable tail" { try testing.expectEqualStrings("/test.txt Get", whole); const word = config.wordBounds(whole, whole.len - "Get".len); try testing.expectEqualStrings("Get", whole[word.lo..word.hi]); - // and the keyboard's first column of the text is the G + // and a click on the G puts the tag's cursor on it const at = tagline.tagOffsetAt(p, pane, 0, "/test.txt ".len).?; - try testing.expectEqual(@as(usize, 0), at.col); + try testing.expectEqualStrings("Get", whole[at.col..]); } test "name reads the file name and writing it promotes a scratch without touching its body" { diff --git a/src/panes.zig b/src/panes.zig index a67aa8ac..ab4f5072 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -133,6 +133,11 @@ pub const Pane = struct { /// scrolls to keep its caret in view while it has the keyboard; pointer /// rows count from this line, not the tag's first. tag_top: u16 = 0, + /// How long the tag's computed prefix was when its positions were last + /// placed. They count from the start of the prefix, so when it grows or + /// shrinks (a rename, the dirty marker, a PDF's page) what follows it + /// moves with it. + tag_lead: usize = 0, cwd: Cwd = .none, hscroll: i32 = 0, // Visible rows map to source lines and raw/display column origins. diff --git a/src/pardes.zig b/src/pardes.zig index 65f3aebb..321a6b3c 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4976,16 +4976,14 @@ pub const Pardes = struct { if (t.what != .body) { // An explicit selection, else the word under the cursor in the // text as it is shown: a tag's prefix is part of what a word is. - const arena = p.scratch.allocator(); - const own = arena.dupe(u8, edit.flatSurface(p, t) catch return) catch return; - const shown = if (t.what == .tag) tagline.tagText(p, arena, pane) catch return else own; - const range = t.primaryRange(own, 0); + const shown = p.scratch.allocator().dupe(u8, edit.flatSurface(p, t) catch return) catch return; + const range = t.primaryRange(shown, 0); const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); const txt = if ((t.vsel.active and t.vsel.explicit) or t.msel.active) - own[lo..hi] + shown[lo..hi] else word: { - const b = config.wordBounds(shown, shown.len - own.len + modal.selectionCursor(own, range)); + const b = config.wordBounds(shown, modal.selectionCursor(shown, range)); break :word shown[b.lo..b.hi]; }; // Out of the tag or header FIRST: Del frees the pane, Kill @@ -5013,6 +5011,14 @@ pub const Pardes = struct { return; } if (t.mode == .normal and t.what == .body and pane.hasPdf()) return normal.handlePdfNormal(p, pane, key); + if (t.what == .tag and t.mode == .insert and tagline.typedIntoName(p, pane, key)) return; + // A tag's prefix is read-only: a key whose edit reached into it + // changes nothing, not even where the cursor is. + const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel }; + t.refused = false; + defer if (t.refused) { + t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; + }; switch (t.mode) { .normal => normal.handleNormal(p, t, key), .insert => { @@ -5597,6 +5603,23 @@ pub const Pardes = struct { const r = p.rects[id]; // The tag takes a row per line, leaving the body one row if it // had any; a folded pane shows its first line only. + // Measured on the stack: this runs on every event, and not + // every event may take from the scratch arena. + var room: [2 * limits.host_path_cap + 256]u8 = undefined; + var measure = std.heap.FixedBufferAllocator.init(&room); + const lead = if (tagline.prefixIn(measure.allocator(), pane)) |prefix| prefix.len else |_| pane.tag_lead; + if (lead != pane.tag_lead) { + const t = &pane.tag; + const old: i32 = @intCast(pane.tag_lead); + const new: i32 = @intCast(lead); + if (t.cur_row == 0 and t.cur_col >= old) t.cur_col += new - old; + if (t.vsel.row == 0 and t.vsel.col >= old) t.vsel.col += new - old; + for (t.sels[0..t.nsel]) |*sel| { + if (sel.row == 0 and sel.col >= old) sel.col += new - old; + if (sel.arow == 0 and sel.acol >= old) sel.acol += new - old; + } + pane.tag_lead = lead; + } const lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1; pane.tag_rows = @intCast(@min(lines, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1))); if (pane.collapsed) pane.tag_rows = BOX_H; diff --git a/src/tagline.zig b/src/tagline.zig index 8bd8000a..92c18510 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -102,20 +102,29 @@ pub fn revealHeader(p: *Pardes) void { } } +/// 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); + 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 { - const arena = p.scratch.allocator(); - const prefix = prefix: { - if (comptime pdf_enabled) if (pane.pdf) |pv| - break :prefix try std.fmt.allocPrint(arena, "{s} [{d}/{d}]", .{ pv.path, pv.page + 1, pv.page_count }); - if (pane.image) |*state| break :prefix try panes.Image.tagPrefix(arena, state); - 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) - break :prefix try std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker }); - break :prefix name; - } - break :prefix pane.cwdSlice(); - }; + 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 @@ -159,21 +168,23 @@ pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { /// 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 tail = curTail(pane); + 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 = while (words.next()) |word| { + 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(tail, @min(at, 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 tag = tagText(p, p.scratch.allocator(), pane) catch return; - const line = tag[0 .. std.mem.indexOfScalar(u8, tag, '\n') orelse tag.len]; + 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); } @@ -181,9 +192,9 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void { // 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(tail); + const rows = modal.cursorLineCount(whole); t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1)); - const line = modal.lineSlice(tail, @intCast(t.cur_row)); + 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; @@ -196,15 +207,40 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void { pane.focus = .tag; } -/// Where display column `col` of tag line `row` (tag scroll included) -/// lands in the tag's own text, or null when it is on the prefix, which -/// only the first line has. +/// 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 width = if (at == 0) panes.File.displayWidth(tagPrefix(p, pane) catch return null) else 0; - if (col < width) return null; - const line = modal.lineSlice(curTail(pane), at); - return .{ .row = at, .col = modal.graphemeStart(line, panes.File.rawAtDisplay(line, col - width)) }; + 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 |
