Hugo Machet: 1 [WIP] Rewrite tab completion 4 files changed, 393 insertions(+), 104 deletions(-)
Copy & paste the following snippet into your terminal to import this patchset into git:
curl -s https://lists.sr.ht/~leon_plickat/nfm/patches/37905/mbox | git am -3Learn more about email & git
--- Rebased against master Update to new allocator convention src/Completion.zig | 145 +++++++++++++++++++++++++++++++++++++ src/Context.zig | 90 +---------------------- src/EditableUTF8String.zig | 141 ++++++++++++++++++++++++++++++++---- src/mode.zig | 121 +++++++++++++++++++++++++++++-- 4 files changed, 393 insertions(+), 104 deletions(-) create mode 100644 src/Completion.zig diff --git a/src/Completion.zig b/src/Completion.zig new file mode 100644 index 000000000000..412a5e272055 --- /dev/null +++ b/src/Completion.zig @@ -0,0 +1,145 @@ +// This file is part of nfm, the neat file manager. +// +// Copyright © 2022 Hugo Machet +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as published +// by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +const std = @import("std"); +const ascii = std.ascii; +const debug = std.debug; +const fs = std.fs; +const heap = std.heap; +const mem = std.mem; +const unicode = std.unicode; +const log = std.log.scoped(.completion); + +const Self = @This(); + +const Dir = @import("Dir.zig"); +const File = @import("File.zig"); + +const context = &@import("nfm.zig").context; + +pub const DirsList = struct { + dirs: std.TailQueue(Dir) = .{}, + + pub fn deinit(self: *DirsList) void { + while (self.dirs.pop()) |node| { + node.data.deinit(); + context.alloc.destroy(node); + } + } + + /// Add a new dir to DirsList.dirs if needed + pub fn addDir(self: *DirsList, path: []const u8, fname: []const u8) !void { + // TODO: More reliable way to compare them than using name? + if (self.dirs.len > 0) { + const last_name = fs.path.basename(self.dirs.last.?.data.name); + if (mem.eql(u8, fname, last_name)) return; + } + + const abspath = try fs.path.join(context.alloc, &[_][]const u8{ path, "/", fname }); + errdefer context.alloc.free(abspath); + log.debug("completion path: {s}", .{abspath}); + debug.assert(fs.path.isAbsolute(abspath)); + var dir = try fs.openIterableDirAbsolute(abspath, .{}); + defer dir.close(); + + var node = try context.alloc.create(std.TailQueue(Dir).Node); + try node.data.init(&context.dirmap, dir, abspath); + self.dirs.append(node); + } + + pub fn maybeRemoveLast(self: *DirsList) void { + if (self.dirs.len == 0) return; + const last = self.dirs.pop() orelse unreachable; + last.data.deinit(); + context.alloc.destroy(last); + } +}; + +arena: heap.ArenaAllocator, +completions: std.TailQueue(*File) = .{}, +max_name_len: usize = 0, + +/// Amount of codepoints in the compare token. Used for offsetting the menu. +compare_len: usize = 0, + +pub fn newFromDir(dir: *Dir) !Self { + var ret = Self{ + .arena = heap.ArenaAllocator.init(context.alloc), + }; + errdefer ret.arena.deinit(); + const arena = ret.arena.allocator(); + for (dir.files.items) |*file| { + const node = try arena.create(std.TailQueue(*File).Node); + node.data = file; + ret.completions.append(node); + } + return ret; +} + +pub fn deinit(self: *const Self) void { + self.arena.deinit(); +} + +pub fn compare(self: *Self, str: []const u8) usize { + var it = self.completions.first; + while (it) |node| : (it = node.next) { + if (!ascii.startsWithIgnoreCase(node.data.*.name, str)) { + self.completions.remove(node); + } + } + self.setMaxStringLen(); + + return self.completions.len; +} + +fn setMaxStringLen(self: *Self) void { + var len: usize = 0; + var it = self.completions.first; + while (it) |node| : (it = node.next) { + const l = unicode.utf8CountCodepoints(node.data.*.name) catch return; + if (l > len) len = l; + } + self.max_name_len = len; +} + +pub fn getFirst(self: *Self) ?*File { + if (self.completions.len == 0) return null; + return self.completions.first.?.data; +} + +pub fn getCommonStart(self: *Self) ?[]const u8 { + if (self.completions.len == 0) return null; + + var common: []const u8 = self.completions.first.?.data.name; + var it = self.completions.first; + while (it) |node| : (it = node.next) { + common = commonStart(common, node.data.*.name); + } + return common; +} + +/// Compare two strings and return the common beginning part. +/// Case insensitive. +fn commonStart(a: []const u8, b: []const u8) []const u8 { + const len: usize = if (a.len < b.len) a.len else b.len; + + var offset: usize = 0; + while (offset < len) : (offset += 1) { + if (ascii.toLower(a[offset]) != ascii.toLower(b[offset])) break; + } + + return a[0..offset]; +} diff --git a/src/Context.zig b/src/Context.zig index 23fd793909c6..6f05cd5b7372 100644 --- a/src/Context.zig +++ b/src/Context.zig @@ -528,21 +528,17 @@ fn handleSpoonInputUserInput(self: *Self, in: spoon.Input) !void { try self.handleReturnUserInput(); return; }, - '\t' => { - var token = buffer.getCompletionToken() orelse return; - if (try self.completion(token)) |compl| { - buffer.killCompletionToken(); - try buffer.insertQuotedSliceAtCursor(compl); - defer self.alloc.free(compl); - } - }, + '\t' => try self.ui_mode.user_input.updateCompletion(.force), 127 => { // Backspace. if (self.ui_mode.user_input.history_index != null) return; buffer.deleteOneBackward(); + self.ui_mode.user_input.maybeGoPrevCmpDir(); + try self.ui_mode.user_input.updateCompletion(.passive); }, else => { if (self.ui_mode.user_input.history_index != null) return; try buffer.insertCodepointAtCursor(in.content.codepoint); + try self.ui_mode.user_input.updateCompletion(.passive); }, } } @@ -713,81 +709,3 @@ fn mouseClickPathFromTitle(self: *Self, _x: usize) !void { } } } - -/// Compare current input with files/directories names. Complete the name if -/// there is only one match else only complete the common part. -/// Case insensitive. -fn completion(self: *Self, input: []const u8) !?[]const u8 { - var found: std.ArrayListUnmanaged(*File) = .{}; - defer found.deinit(self.alloc); - - const fdv = self.dirmap.focusedDirView() orelse return null; - for (fdv.dir.files.items) |*file| { - if (file.name.len <= input.len) continue; - if (ascii.startsWithIgnoreCase(file.name, input)) { - try found.append(self.alloc, file); - } - } - - if (found.items.len == 0) { - self.completion_count = null; - return null; - } else if (found.items.len == 1) { - self.completion_count = null; - const escaped = try self.escapeSpecialChar(found.items[0].name); - return escaped; - } else { - self.completion_count = found.items.len; - var common: []const u8 = found.items[0].name; - var i: usize = 1; - while (i < found.items.len) : (i += 1) { - common = commonStart(common, found.items[i].name); - } - const escaped = try self.escapeSpecialChar(common); - return escaped; - } -} - -/// Compare two strings and return the common beginning part. -/// Case insensitive. -fn commonStart(a: []const u8, b: []const u8) []const u8 { - const len: usize = if (a.len < b.len) a.len else b.len; - - var offset: usize = 0; - while (offset < len) : (offset += 1) { - if (ascii.toLower(a[offset]) != ascii.toLower(b[offset])) break; - } - - return a[0..offset]; -} - -/// Escape special characters in file names. -/// foo bar -> foo\ bar -/// foo\ bar -> foo\\\ bar -/// foo"bar -> foo\"bar -/// foo'bar -> foo"'"bar -fn escapeSpecialChar(self: *Self, s: []const u8) ![]const u8 { - var ret: std.ArrayListUnmanaged(u8) = .{}; - - var i: usize = 0; - while (i < s.len) : (i += 1) { - if (s[i] == '\\') { - if (s[i + 1] == ' ') { - try ret.appendSlice(self.alloc, "\\\\\\ "); - i += 1; - } else { - try ret.append(self.alloc, s[i]); - } - } else if (s[i] == ' ') { - try ret.appendSlice(self.alloc, "\\ "); - } else if (s[i] == '"') { - try ret.appendSlice(self.alloc, "\\\""); - } else if (s[i] == '\'') { - try ret.appendSlice(self.alloc, "\"'\""); - } else { - try ret.append(self.alloc, s[i]); - } - } - - return ret.toOwnedSlice(self.alloc); -} diff --git a/src/EditableUTF8String.zig b/src/EditableUTF8String.zig index 12a365716e0a..edb9e7b7c70f 100644 --- a/src/EditableUTF8String.zig +++ b/src/EditableUTF8String.zig @@ -874,18 +874,75 @@ test "EditableUTF8String: killToEndOfLine()" { } } +pub const CompletionToken = struct { + const Kind = enum { cwd, absolute }; + kind: Kind = .cwd, + bytes: ?[]const u8 = null, + cursor: ?Cursor = null, +}; + // TODO a line may be 'word "word %c word', in which case the completion token should be 'word ' // TODO escaped spaces -pub fn getCompletionToken(self: *Self) ?[]const u8 { - if (self.cursor.byte == 0) return null; - if (ascii.isWhitespace(self.buffer.items[self.cursor.byte - 1])) return null; - const c = self.prevWordStart(self.cursor) orelse return null; - return self.buffer.items[c.byte..self.cursor.byte]; +pub fn getCompletionToken(self: *Self) CompletionToken { + var tok = CompletionToken{}; + if (self.cursor.byte == 0) return tok; + if (ascii.isWhitespace(self.buffer.items[self.cursor.byte - 1])) { + return tok; + } else if (self.buffer.items[self.cursor.byte - 1] == '/') { + tok.kind = .absolute; + return tok; + } + tok = self.completionStart(self.cursor); + if (tok.cursor) |c| { + tok.bytes = self.buffer.items[c.byte..self.cursor.byte]; + } + return tok; } // TODO update this when improving getCompletionToken() pub fn killCompletionToken(self: *Self) void { - self.killToPrevWordStart(); + const tok = self.completionStart(self.cursor); + if (tok.cursor) |c| { + var to_remove = self.cursor.byte - c.byte; // TODO this is inefficient + while (to_remove > 0) : (to_remove -= 1) _ = self.buffer.orderedRemove(c.byte); + self.codepoint_len -= self.cursor.codepoint - c.codepoint; + self.cursor = c; + } else return; +} + +/// Find the start of the word to complete in the buffer. +/// Delimiter: whitespace / +fn completionStart(self: *Self, cursor: Cursor) CompletionToken { + var tok = CompletionToken{}; + tok.cursor = cursor; + + // The cursor may be at the end of the line or we may already be at a completion start. + if (tok.cursor.?.byte == self.buffer.items.len or !ascii.isWhitespace(self.buffer.items[tok.cursor.?.byte]) or + self.buffer.items[tok.cursor.?.byte] != '/') + { + tok.cursor = self.cursorLeftOf(tok.cursor.?) orelse return tok; + } + + // Skip delimiter. + while (self.cursorLeftOf(tok.cursor.?)) |c| { + if (!ascii.isWhitespace(self.buffer.items[tok.cursor.?.byte]) or self.buffer.items[tok.cursor.?.byte] != '/') { + break; + } + tok.cursor = c; + } else return tok; + + // Skip non delimiter. + while (self.cursorLeftOf(tok.cursor.?)) |c| { + if (ascii.isWhitespace(self.buffer.items[c.byte])) { + break; + } else if (self.buffer.items[c.byte] == '/') { + tok.kind = .absolute; + break; + } + tok.cursor = c; + } + + return tok; } test "EditableUTF8String: getCompletionToken()" { @@ -895,7 +952,7 @@ test "EditableUTF8String: getCompletionToken()" { a.moveCursorOneBackward(); a.moveCursorOneForward(); const token = a.getCompletionToken(); - try std.testing.expect(token == null); + try std.testing.expect(token.bytes == null); a.deinit(); } @@ -903,8 +960,8 @@ test "EditableUTF8String: getCompletionToken()" { const str = "word word"; var a = try Self.from(str); const token = a.getCompletionToken(); - try std.testing.expect(token != null); - try std.testing.expectEqualSlices(u8, "word", token.?); + try std.testing.expect(token.bytes != null); + try std.testing.expectEqualSlices(u8, "word", token.bytes.?); a.deinit(); } @@ -913,8 +970,8 @@ test "EditableUTF8String: getCompletionToken()" { var a = try Self.from(str); a.moveCursorOneBackward(); const token = a.getCompletionToken(); - try std.testing.expect(token != null); - try std.testing.expectEqualSlices(u8, "öäü", token.?); + try std.testing.expect(token.bytes != null); + try std.testing.expectEqualSlices(u8, "öäü", token.bytes.?); a.deinit(); } @@ -922,7 +979,7 @@ test "EditableUTF8String: getCompletionToken()" { const str = "word "; var a = try Self.from(str); const token = a.getCompletionToken(); - try std.testing.expect(token == null); + try std.testing.expect(token.bytes == null); a.deinit(); } @@ -931,7 +988,65 @@ test "EditableUTF8String: getCompletionToken()" { var a = try Self.from(str); a.moveCursorOneBackward(); const token = a.getCompletionToken(); - try std.testing.expect(token == null); + try std.testing.expect(token.bytes == null); + a.deinit(); + } + + { + const str = "/word"; + var a = try Self.from(str); + const token = a.getCompletionToken(); + try std.testing.expect(token.bytes != null); + try std.testing.expect(token.kind == .absolute); + try std.testing.expectEqualSlices(u8, "word", token.bytes.?); + a.deinit(); + } + + { + const str = "word/foo"; + var a = try Self.from(str); + const token = a.getCompletionToken(); + try std.testing.expect(token.bytes != null); + try std.testing.expect(token.kind == .absolute); + try std.testing.expectEqualSlices(u8, "foo", token.bytes.?); + a.deinit(); + } + + { + const str = "word/"; + var a = try Self.from(str); + const token = a.getCompletionToken(); + try std.testing.expect(token.kind == .absolute); + try std.testing.expect(token.bytes == null); + a.deinit(); + } + + { + const str = "/word"; + var a = try Self.from(str); + const token = a.getCompletionToken(); + try std.testing.expect(token.bytes != null); + try std.testing.expect(token.kind == .absolute); + try std.testing.expectEqualSlices(u8, "word", token.bytes.?); + a.deinit(); + } + + { + const str = "word/foo"; + var a = try Self.from(str); + const token = a.getCompletionToken(); + try std.testing.expect(token.bytes != null); + try std.testing.expect(token.kind == .absolute); + try std.testing.expectEqualSlices(u8, "foo", token.bytes.?); + a.deinit(); + } + + { + const str = "word/"; + var a = try Self.from(str); + const token = a.getCompletionToken(); + try std.testing.expect(token.kind == .absolute); + try std.testing.expect(token.bytes == null); a.deinit(); } } diff --git a/src/mode.zig b/src/mode.zig index 55154dc07e37..7c9a08d3d8e3 100644 --- a/src/mode.zig +++ b/src/mode.zig @@ -15,14 +15,18 @@ // along with this program. If not, see <https://www.gnu.org/licenses/>. const spoon = @import("spoon"); -const build_options = @import("build_options"); const std = @import("std"); const ascii = std.ascii; +const debug = std.debug; +const fs = std.fs; const math = std.math; const mem = std.mem; -const logger = std.log.scoped(.mode); +const log = std.log.scoped(.mode); const EditableUTF8String = @import("EditableUTF8String.zig"); +const Dir = @import("Dir.zig"); +const File = @import("File.zig"); +const Completion = @import("Completion.zig"); const context = &@import("nfm.zig").context; @@ -54,21 +58,125 @@ pub const UIMode = union(enum) { /// User input mode: Title bar is mini-editor for user input. user_input: struct { + const UpdateCompletionMode = enum { force, passive }; + const UserInput = @This(); operation: UserInputOperation, buffer: EditableUTF8String, history_index: ?usize, + completion: ?Completion = null, + cmp_dirs: Completion.DirsList, + + /// Wrapper around UserInput.complete() to handle files errors. + pub fn updateCompletion(self: *UserInput, mode: UpdateCompletionMode) !void { + self.complete(mode) catch |err| switch (err) { + error.AccessDenied => return, + else => return err, + }; + } + + fn complete(self: *UserInput, mode: UpdateCompletionMode) !void { + const token = self.buffer.getCompletionToken(); + // log.debug("token kind: {s}, bytes: {?s}", .{ @tagName(token.kind), token.bytes }); + if (self.completion == null) { + if (self.cmp_dirs.dirs.len > 0) { + self.completion = try Completion.newFromDir(&self.cmp_dirs.dirs.last.?.data); + } else { + switch (token.kind) { + .cwd => { + if (context.dirmap.focusedDirView()) |fdv| { + self.completion = try Completion.newFromDir(fdv.dir); + } + }, + .absolute => { + try self.cmp_dirs.addDir("/", ""); + self.completion = try Completion.newFromDir(&self.cmp_dirs.dirs.last.?.data); + }, + } + } + } + + if (token.bytes) |b| { + self.completion.?.compare_len = b.len; + switch (mode) { + .force => try self.forceCompletion(b), + .passive => try self.passiveCompletion(b), + } + } + } + + pub fn abortCompletion(self: *UserInput) void { + if (self.completion) |cmp| { + cmp.deinit(); + self.completion = null; + } + } + + /// Remove the last dir from the cmp_dirs list if we are at the begining + /// of the token. + // TODO: Find a better way to know when we need to remove a child dir + // For example, having 'foo/bar', removing bar will work correctly but then + // adding 'b' and deleting 'b' will remove 'foo' from childs + pub fn maybeGoPrevCmpDir(self: *UserInput) void { + const token = self.buffer.getCompletionToken(); + if (token.bytes == null) self.cmp_dirs.maybeRemoveLast(); + self.abortCompletion(); + } + + /// Update the completion on 'Tab' and insert the suggestion in the input buffer. + fn forceCompletion(self: *UserInput, bytes: []const u8) !void { + switch (context.ui_mode.user_input.completion.?.compare(bytes)) { + 0 => self.abortCompletion(), + 1 => { + self.buffer.killCompletionToken(); + const compl = self.completion.?.getFirst() orelse unreachable; + try self.buffer.insertQuotedSliceAtCursor(compl.name); + if (compl.kind == .Directory) try self.cmp_dirs.addDir(compl.dir.name, compl.name); + self.abortCompletion(); + }, + else => { + self.buffer.killCompletionToken(); + const compl = self.completion.?.getCommonStart() orelse unreachable; + try self.buffer.insertQuotedSliceAtCursor(compl); + context.completion_count = self.completion.?.completions.len; + self.abortCompletion(); + }, + } + } + + /// Only update the count of suggestions without inserting anything in + /// the input buffer. + // TODO: Diplay the suggestions + fn passiveCompletion(self: *UserInput, bytes: []const u8) !void { + switch (context.ui_mode.user_input.completion.?.compare(bytes)) { + 0 => self.abortCompletion(), + 1 => { + const compl = self.completion.?.getFirst() orelse unreachable; + if (compl.kind == .Directory and mem.eql(u8, bytes, compl.name)) { + try self.cmp_dirs.addDir(compl.dir.name, compl.name); + } + context.completion_count = self.completion.?.completions.len; + }, + else => { + // TODO: Should use the full file name instead of the + // common start to use in a widget. + _ = self.completion.?.getCommonStart() orelse unreachable; + context.completion_count = self.completion.?.completions.len; + self.abortCompletion(); + }, + } + } }, pub fn setNormal(self: *Self) void { if (self.* == .normal) return; - logger.debug("normal mode", .{}); + log.debug("normal mode", .{}); self.reset(); self.* = .normal; } pub fn setMessage(self: *Self, comptime level: MessageLevel, comptime message: []const u8) void { - logger.debug("message mode: {}, '{s}'", .{ level, message }); + log.debug("message mode: {}, '{s}'", .{ level, message }); self.reset(); self.* = .{ .message = .{ .message = message, @@ -77,12 +185,13 @@ pub const UIMode = union(enum) { } pub fn setUserInput(self: *Self, operation: UserInputOperation) !void { - logger.debug("user input mode mode: {}", .{operation}); + log.debug("user input mode mode: {}", .{operation}); self.reset(); self.* = .{ .user_input = .{ .operation = operation, .buffer = try EditableUTF8String.newWithCapacity(1024), .history_index = null, + .cmp_dirs = .{}, } }; } @@ -90,6 +199,8 @@ pub const UIMode = union(enum) { context.ui.title_dirty = true; if (self.* == .user_input) { self.user_input.buffer.deinit(); + if (self.user_input.completion) |cmp| cmp.deinit(); + self.user_input.cmp_dirs.deinit(); } } }; -- 2.39.0
builds.sr.ht <builds@sr.ht>nfm/patches/alpine.yml: SUCCESS in 49s [[WIP] Rewrite tab completion][0] v3 from [Hugo Machet][1] [0]: https://lists.sr.ht/~leon_plickat/nfm/patches/37905 [1]: mailto:mail@hmachet.com ✓ #915375 SUCCESS nfm/patches/alpine.yml https://builds.sr.ht/~leon_plickat/job/915375