Hugo Machet: 1 [WIP] Rewrite tab completion 4 files changed, 376 insertions(+), 103 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/37879/mbox | git am -3Learn more about email & git
--- src/Completion.zig | 103 ++++++++++++++++++++++++++ src/Context.zig | 93 ++---------------------- src/EditableUTF8String.zig | 140 ++++++++++++++++++++++++++++++++---- src/mode.zig | 143 ++++++++++++++++++++++++++++++++++++- 4 files changed, 376 insertions(+), 103 deletions(-) create mode 100644 src/Completion.zig diff --git a/src/Completion.zig b/src/Completion.zig new file mode 100644 index 000000000000..f68785b01a1f --- /dev/null +++ b/src/Completion.zig @@ -0,0 +1,103 @@ +// This file is part of nfm, the neat file manager. +// +// Copyright © 2022 Leon Henrik Plickat +//
Probably want to put your own name here :)
+// 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 heap = std.heap; +const ascii = std.ascii; +const unicode = std.unicode; + +const Self = @This(); + +const Dir = @import("Dir.zig"); +const File = @import("File.zig"); + +const context = &@import("nfm.zig").context; + +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.gpa.allocator()), + }; + errdefer ret.arena.deinit(); + const alloc = ret.arena.allocator(); + for (dir.files.items) |*file| { + const node = try alloc.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 b7409df523ab..2412897dee4d 100644 --- a/src/Context.zig +++ b/src/Context.zig @@ -509,21 +509,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.gpa.allocator().free(compl); - } - }, + '\t' => try self.mode.user_input.updateCompletion(.force), 127 => { // Backspace. if (self.mode.user_input.history_index != null) return; buffer.deleteOneBackward(); + self.mode.user_input.maybeGoPrevCmpDir(); + try self.mode.user_input.updateCompletion(.passive); }, else => { if (self.mode.user_input.history_index != null) return; try buffer.insertCodepointAtCursor(in.content.codepoint); + try self.mode.user_input.updateCompletion(.passive); }, } } @@ -695,84 +691,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 { - const alloc = self.gpa.allocator(); - - var found: std.ArrayListUnmanaged(*File) = .{}; - defer found.deinit(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(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 { - const alloc = self.gpa.allocator(); - 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(alloc, "\\\\\\ "); - i += 1; - } else { - try ret.append(alloc, s[i]); - } - } else if (s[i] == ' ') { - try ret.appendSlice(alloc, "\\ "); - } else if (s[i] == '"') { - try ret.appendSlice(alloc, "\\\""); - } else if (s[i] == '\'') { - try ret.appendSlice(alloc, "\"'\""); - } else { - try ret.append(alloc, s[i]); - } - } - - return ret.toOwnedSlice(alloc); -} diff --git a/src/EditableUTF8String.zig b/src/EditableUTF8String.zig index d6156768ddf1..708a3d685018 100644 --- a/src/EditableUTF8String.zig +++ b/src/EditableUTF8String.zig @@ -878,18 +878,74 @@ test "EditableUTF8String: killToEndOfLine()" { } } +pub const CompletionToken = struct { + kind: enum { cwd, absolute } = .cwd, + bytes: ?[]const u8 = null, + cursor: ?Cursor = null, +};
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.isSpace(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
FWIW you don't need to focus on this TODO too much in this patch. This is something that can always be improved later on.
+ 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()" { @@ -899,7 +955,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(); } @@ -907,8 +963,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(); } @@ -917,8 +973,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(); } @@ -926,7 +982,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(); } @@ -935,7 +991,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 9b83359a39b5..3eb54aa32830 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 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; @@ -56,10 +60,141 @@ pub const Mode = 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: std.TailQueue(Dir), + + /// 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 => { + context.mode.setMessage(.err, "Access Denied"); + return; + }, + else => return err, + }; + }
This is a bit problematic, becaus setMessage() will leave input mode, meaning the command the user just typed will be deleted. For now I'd say on error just do nothing. I'll think about a way to show errors and messages in user_input mode later. Friendly greetings, Leon Henrik Plickat
+ + fn complete(self: *UserInput, mode: UpdateCompletionMode) !void { + const token = self.buffer.getCompletionToken(); + if (self.completion == null) { + if (self.cmp_dirs.len > 0) { + self.completion = try Completion.newFromDir(&self.cmp_dirs.last.?.data); + } else { + switch (token.kind) { + .cwd => { + if (context.dirmap.focusedDirView()) |fdv| { + self.completion = try Completion.newFromDir(fdv.dir); + } + }, + .absolute => { + try self.addCmpDir("/", ""); + self.completion = try Completion.newFromDir(&self.cmp_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 and self.cmp_dirs.len > 0) { + const last = self.cmp_dirs.pop() orelse unreachable; + last.data.deinit(); + context.gpa.allocator().destroy(last); + } + 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.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.addCmpDir(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.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.addCmpDir(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(); + }, + } + } + + /// Add a new dir to UserInput.cmp_dirs if needed + fn addCmpDir(self: *UserInput, path: []const u8, fname: []const u8) !void { + // TODO: More reliable way to compare them than using name? + if (self.cmp_dirs.len > 0) { + const last_name = fs.path.basename(self.cmp_dirs.last.?.data.name); + if (mem.eql(u8, fname, last_name)) return; + } + + const alloc = context.gpa.allocator(); + const abspath = try fs.path.join(alloc, &[_][]const u8{ path, "/", fname }); + errdefer alloc.free(abspath); + logger.debug("completion path: {s}", .{abspath}); + debug.assert(fs.path.isAbsolute(abspath)); + var dir = try fs.openIterableDirAbsolute(abspath, .{}); + defer dir.close(); + + var node = try alloc.create(std.TailQueue(Dir).Node); + try node.data.init(&context.dirmap, alloc, dir, abspath); + self.cmp_dirs.append(node); + } }, pub fn setNav(self: *Self) void { @@ -85,6 +220,7 @@ pub const Mode = union(enum) { .operation = operation, .buffer = try EditableUTF8String.newWithCapacity(1024), .history_index = null, + .cmp_dirs = .{}, } }; } @@ -92,6 +228,11 @@ pub const Mode = union(enum) { context.ui.title_dirty = true; if (self.* == .user_input) { self.user_input.buffer.deinit(); + if (self.user_input.completion) |cmp| cmp.deinit(); + while (self.user_input.cmp_dirs.pop()) |node| { + node.data.deinit(); + context.gpa.allocator().destroy(node); + } } } }; -- 2.39.0
builds.sr.ht <builds@sr.ht>nfm/patches/alpine.yml: SUCCESS in 50s [[WIP] Rewrite tab completion][0] from [Hugo Machet][1] [0]: https://lists.sr.ht/~leon_plickat/nfm/patches/37879 [1]: mailto:mail@hmachet.com ✓ #914500 SUCCESS nfm/patches/alpine.yml https://builds.sr.ht/~leon_plickat/job/914500
Took a small peek and seems to be fine overall. Just some small notes. I haven't looked at the logic in detail yet, will do that later.