---
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
+//
+// 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,
+};
+
// 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
+ 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,
+ };
+ }
+
+ 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
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.
On Sat Dec 31, 2022 at 4:52 PM CET, Hugo Machet wrote:
> +// This file is part of nfm, the neat file manager.
> +//
> +// Copyright © 2022 Leon Henrik Plickat
> +//
Probably want to put your own name here :)
> 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 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.
> + /// 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