M README.md => README.md +1 -4
@@ 21,10 21,7 @@ to be used as a file selector for other programs.
* Maybe rename "selection" to "cursor" to not conflict with the "select prompt"?
* refresh dirmap when directory content change
- inotify?
-* command/search/select input
- - keybinds to insert current selection or all marked files into buffer
- - readline-esque keybinds
- - command history
+* command/search history
* file preview
- keep a hashmap of text preview buffers with file names as key
- only keep ~10 preview buffers
M doc/nfm.1 => doc/nfm.1 +62 -0
@@ 252,6 252,68 @@ Otherwise write the full path of the selection to stdout and exit.
.RE
.
.
+.SH KEYBINDS IN THE INPUT PROMPT
+.P
+\fBEscape\fR, \fBCtrl-c\fR, \fBCtrl-g\fR
+.RS
+Abort operation and exit input prompt
+.RE
+.
+.P
+\fBEnter\fR
+.RS
+Commit operation.
+.RE
+.
+.P
+\fBCtrl-a\fR
+.RS
+Move the cursor to the beginning of the line.
+.RE
+.
+.P
+\fBCtrl-e\fR
+.RS
+Move the cursor to the end of the line.
+.RE
+.
+.P
+\fBCtrl-s\fR
+.RS
+Insert the name of the selected file.
+.RE
+.
+.P
+\fBCtrl-i\fR
+.RS
+Insert the paths of all marked files.
+.RE
+.
+.P
+\fBCtrl-w\fR
+.RS
+Delete all characters from the cursor to the start of the previous word.
+.RE
+.
+.P
+\fBCtrl-k\fR
+.RS
+Delete all characters from the cursor to the end of the line.
+.RE
+.
+.P
+\fBAlt-f\fR
+.RS
+Move cursor to the start of the previous word.
+.RE
+.
+.P
+\fBAlt-f\fR
+.RS
+Move cursor to the start of the next word.
+.RE
+.
+.
.SH AUTHOR
.P
.MT leonhenrik.plickat@stud.uni-goettingen.de
A src/InputBuffer.zig => src/InputBuffer.zig +118 -0
@@ 0,0 1,118 @@
+// 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 as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// 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 math = std.math;
+const mem = std.mem;
+
+const Self = @This();
+
+const Direction = enum {
+ left,
+ right,
+};
+
+const InsertMode = enum {
+ /// Slice is a single word and should be quoted if it contains whitespace.
+ single_word,
+
+ /// Slice should be inserted as is.
+ pure,
+};
+
+buffer: std.ArrayList(u8),
+cursor: usize = 0,
+
+pub fn init(alloc: mem.Allocator) !Self {
+ return Self{
+ .buffer = try std.ArrayList(u8).initCapacity(alloc, 1024),
+ };
+}
+
+pub fn deinit(self: *Self) void {
+ self.buffer.deinit();
+}
+
+pub fn toOwnedSlice(self: *Self) []const u8 {
+ return self.buffer.toOwnedSlice();
+}
+
+pub fn moveCursor(self: *Self, comptime direction: Direction, amount: usize) void {
+ switch (direction) {
+ .right => self.cursor = math.min(self.cursor +| amount, self.buffer.items.len),
+ .left => self.cursor -|= amount,
+ }
+}
+
+pub fn delete(self: *Self, comptime direction: Direction, amount: usize) void {
+ var i: usize = 0;
+ switch (direction) {
+ .right => while (i < amount) : (i += 1) {
+ if (self.cursor < self.buffer.items.len) {
+ _ = self.buffer.orderedRemove(self.cursor);
+ }
+ },
+ .left => while (i < amount) : (i += 1) {
+ if (self.cursor > 0) {
+ self.cursor -= 1;
+ _ = self.buffer.orderedRemove(self.cursor);
+ }
+ },
+ }
+}
+
+pub fn lenToNextWordStart(self: *Self, comptime direction: Direction) ?usize {
+ if (direction == .right and self.cursor == self.buffer.items.len) return null;
+ if (direction == .left and self.cursor == 0) return null;
+
+ var i: usize = self.cursor;
+ switch (direction) {
+ .left => {
+ while (i > 0 and ascii.isSpace(self.buffer.items[i - 1])) : (i -= 1) {}
+ while (i > 0 and !ascii.isSpace(self.buffer.items[i - 1])) : (i -= 1) {}
+ return self.cursor - i;
+ },
+ .right => {
+ while (i < self.buffer.items.len - 1 and !ascii.isSpace(self.buffer.items[i])) : (i += 1) {}
+ while (i < self.buffer.items.len - 1 and ascii.isSpace(self.buffer.items[i])) : (i += 1) {}
+ if (ascii.isSpace(self.buffer.items[i])) i += 1;
+ return i - self.cursor;
+ },
+ }
+}
+
+pub fn insertChar(self: *Self, char: u8) !void {
+ try self.buffer.insert(self.cursor, char);
+ self.cursor += 1;
+}
+
+pub fn insertSlice(self: *Self, slice: []const u8, comptime mode: InsertMode) !void {
+ const quote = blk: {
+ if (mode != .single_word) break :blk false;
+ for (slice) |ch| {
+ if (ascii.isSpace(ch)) {
+ try self.insertChar('"');
+ break :blk true;
+ }
+ }
+ break :blk false;
+ };
+ try self.buffer.insertSlice(self.cursor, slice);
+ self.cursor += slice.len;
+ if (quote) try self.insertChar('"');
+}
M src/UserInterface.zig => src/UserInterface.zig +19 -16
@@ 96,11 96,11 @@ const escape_key_codes = std.ComptimeStringMap(
//.{ "[23~", .{ .function = 11 } },
//.{ "[24~", .{ .function = 12 } },
//.{ "a", .{ .alt = 'a' } },
- //.{ "b", .{ .alt = 'b' } },
+ .{ "b", .{ .alt = 'b' } },
//.{ "c", .{ .alt = 'c' } },
//.{ "d", .{ .alt = 'd' } },
//.{ "e", .{ .alt = 'e' } },
- //.{ "f", .{ .alt = 'f' } },
+ .{ "f", .{ .alt = 'f' } },
//.{ "g", .{ .alt = 'g' } },
//.{ "h", .{ .alt = 'h' } },
//.{ "i", .{ .alt = 'i' } },
@@ 124,17 124,17 @@ const escape_key_codes = std.ComptimeStringMap(
// Kitty
.{ "[27u", .escape },
- //.{ "[97;5u", .{ .ctrl = 'a' } },
+ .{ "[97;5u", .{ .ctrl = 'a' } },
//.{ "[98;5u", .{ .ctrl = 'b' } },
.{ "[99;5u", .{ .ctrl = 'c' } },
//.{ "[100;5u", .{ .ctrl = 'd' } },
- //.{ "[101;5u", .{ .ctrl = 'e' } },
+ .{ "[101;5u", .{ .ctrl = 'e' } },
//.{ "[102;5u", .{ .ctrl = 'f' } },
- //.{ "[103;5u", .{ .ctrl = 'g' } },
+ .{ "[103;5u", .{ .ctrl = 'g' } },
//.{ "[104;5u", .{ .ctrl = 'h' } },
- //.{ "[105;5u", .{ .ctrl = 'i' } },
+ .{ "[105;5u", .{ .ctrl = 'i' } },
//.{ "[106;5u", .{ .ctrl = 'j' } },
- //.{ "[107;5u", .{ .ctrl = 'k' } },
+ .{ "[107;5u", .{ .ctrl = 'k' } },
//.{ "[108;5u", .{ .ctrl = 'l' } },
//.{ "[109;5u", .{ .ctrl = 'm' } },
//.{ "[110;5u", .{ .ctrl = 'n' } },
@@ 142,20 142,20 @@ const escape_key_codes = std.ComptimeStringMap(
//.{ "[112;5u", .{ .ctrl = 'p' } },
//.{ "[113;5u", .{ .ctrl = 'q' } },
//.{ "[114;5u", .{ .ctrl = 'r' } },
- //.{ "[115;5u", .{ .ctrl = 's' } },
+ .{ "[115;5u", .{ .ctrl = 's' } },
//.{ "[116;5u", .{ .ctrl = 't' } },
//.{ "[117;5u", .{ .ctrl = 'u' } },
//.{ "[118;5u", .{ .ctrl = 'v' } },
- //.{ "[119;5u", .{ .ctrl = 'w' } },
+ .{ "[119;5u", .{ .ctrl = 'w' } },
//.{ "[120;5u", .{ .ctrl = 'x' } },
//.{ "[121;5u", .{ .ctrl = 'y' } },
//.{ "[122;5u", .{ .ctrl = 'z' } },
//.{ "[97;3u", .{ .alt = 'a' } },
- //.{ "[98;3u", .{ .alt = 'b' } },
+ .{ "[98;3u", .{ .alt = 'b' } },
//.{ "[99;3u", .{ .alt = 'c' } },
//.{ "[100;3u", .{ .alt = 'd' } },
//.{ "[101;3u", .{ .alt = 'e' } },
- //.{ "[102;3u", .{ .alt = 'f' } },
+ .{ "[102;3u", .{ .alt = 'f' } },
//.{ "[103;3u", .{ .alt = 'g' } },
//.{ "[104;3u", .{ .alt = 'h' } },
//.{ "[105;3u", .{ .alt = 'i' } },
@@ 408,9 408,8 @@ pub fn nextEvent(self: *Self) !?Event {
return escape_key_codes.get(esc_buffer[0..esc_read]) orelse .unknown;
}
- // Legacy codes for Ctrl-[a-z].
- // TODO Is there something in the std to take care of this for us?
- const chars = [_]u8{ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w' };
+ // Legacy codes for Ctrl-[a-z]. This is missing 'm', as that would match Enter.
+ const chars = [_]u8{ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w' };
for (chars) |char| {
if (buffer[0] == char & '\x1f') return Event{ .ctrl = char };
}
@@ 578,12 577,16 @@ fn drawTitle(self: *Self, writer: anytype, indicator: ?[]const u8) !void {
try writeLine(
writer,
self.width - prefix_len,
- context.mode.user_input.buffer.items,
+ context.mode.user_input.buffer.buffer.items,
);
// The title bar must be the last thing we draw, otherwise the
// cursor will end up in an unexpected position.
- try escape.moveCursor(writer, 0, prefix_len + context.mode.user_input.cursor);
+ try escape.moveCursor(
+ writer,
+ 0,
+ prefix_len + context.mode.user_input.buffer.cursor,
+ );
},
.message => {
try context.mode.message.level.getAttr().dump(writer);
M src/mode.zig => src/mode.zig +5 -58
@@ 21,18 21,12 @@ const ascii = std.ascii;
const math = std.math;
const mem = std.mem;
+const InputBuffer = @import("InputBuffer.zig");
+
const Attr = @import("UserInterface.zig").Attr;
const context = &@import("nfm.zig").context;
-const InsertMode = enum {
- /// Slice is a single word and should be quoted if it contains whitespace.
- single_word,
-
- /// Slice should be inserted as is.
- pure,
-};
-
const MessageLevel = enum {
info,
err,
@@ 55,61 49,14 @@ pub const Mode = union(enum) {
const Self = @This();
nav: void,
-
message: struct {
level: MessageLevel,
message: []const u8,
},
-
user_input: struct {
const UserInput = @This();
-
- // TODO handle unicode
operation: UserInputOperation,
- buffer: std.ArrayListUnmanaged(u8),
- cursor: usize = 0,
-
- pub fn moveCursorLeft(self: *UserInput) void {
- self.cursor -|= 1;
- }
-
- pub fn moveCursorRight(self: *UserInput) void {
- self.cursor = math.min(self.cursor + 1, self.buffer.items.len);
- }
-
- pub fn deleteRight(self: *UserInput) void {
- if (self.cursor < self.buffer.items.len) {
- _ = self.buffer.orderedRemove(context.mode.user_input.cursor);
- }
- }
-
- pub fn deleteLeft(self: *UserInput) void {
- if (self.cursor > 0) {
- self.cursor -= 1;
- _ = self.buffer.orderedRemove(self.cursor);
- }
- }
-
- pub fn insertChar(self: *UserInput, char: u8) !void {
- try self.buffer.insert(context.gpa, self.cursor, char);
- context.mode.user_input.cursor += 1;
- }
-
- pub fn insertSlice(self: *UserInput, slice: []const u8, comptime mode: InsertMode) !void {
- const quote = blk: {
- if (mode != .single_word) break :blk false;
- for (slice) |ch| {
- if (ascii.isSpace(ch)) {
- try self.insertChar('"');
- break :blk true;
- }
- }
- break :blk false;
- };
- try self.buffer.insertSlice(context.gpa, self.cursor, slice);
- context.mode.user_input.cursor += slice.len;
- if (quote) try self.insertChar('"');
- }
+ buffer: InputBuffer,
},
pub fn setNav(self: *Self) void {
@@ 129,13 76,13 @@ pub const Mode = union(enum) {
self.reset();
self.* = .{ .user_input = .{
.operation = operation,
- .buffer = try std.ArrayListUnmanaged(u8).initCapacity(context.gpa, 1024),
+ .buffer = try InputBuffer.init(context.gpa),
} };
}
fn reset(self: *Self) void {
if (self.* == .user_input) {
- self.user_input.buffer.deinit(context.gpa);
+ self.user_input.buffer.deinit();
}
}
};
M src/nfm.zig => src/nfm.zig +67 -40
@@ 184,42 184,69 @@ fn handleUiEvents() !void {
}
fn handleEventUserInput(ev: UserInterface.Event) !void {
- const user_input = &context.mode.user_input;
+ const buffer = &context.mode.user_input.buffer;
switch (ev) {
.sigwinch => {
try context.ui.resize();
try context.ui.render(true, true);
+ return;
},
.ascii => |key| switch (key) {
- '\n', '\r' => try handleReturnUserInput(),
- 127 => { // Backspace
- user_input.deleteLeft();
- try context.ui.render(false, true);
+ '\n', '\r' => {
+ try handleReturnUserInput();
+ return;
},
- else => {
- try user_input.insertChar(key);
- try context.ui.render(false, true);
+ 127 => buffer.delete(.left, 1), // Backspace
+ else => try buffer.insertChar(key),
+ },
+ .ctrl => |key| switch (key) {
+ 'c', 'g' => context.mode.setNav(),
+ 'a' => buffer.cursor = 0,
+ 'e' => buffer.cursor = buffer.buffer.items.len,
+ 's' => {
+ const file = &context.cwd.files.items[context.cwd.selection];
+ try buffer.insertSlice(file.name, .single_word);
+ try buffer.insertChar(' ');
},
- },
- .escape => {
- context.mode.setNav();
- try context.ui.render(false, true);
- },
- .arrow_left => {
- user_input.moveCursorLeft();
- try context.ui.render(false, true);
- },
- .arrow_right => {
- user_input.moveCursorRight();
- try context.ui.render(false, true);
- },
- .delete => {
- user_input.deleteRight();
- try context.ui.render(false, true);
- },
- .ctrl => {}, // TODO support the typical readline-esque keybinds for user_input mode
- else => {},
+ 'i' => {
+ var it = context.dirmap.markIterator();
+ while (it.next()) |file| {
+ if (file.dir != context.cwd) {
+ const path = try mem.concat(
+ context.gpa,
+ u8,
+ &[_][]const u8{ file.dir.name, "/", file.name },
+ );
+ defer context.gpa.free(path);
+ try buffer.insertSlice(path, .single_word);
+ } else {
+ try buffer.insertSlice(file.name, .single_word);
+ }
+ try buffer.insertChar(' ');
+ }
+ },
+ 'w' => buffer.delete(
+ .left,
+ buffer.lenToNextWordStart(.left) orelse return,
+ ),
+ 'k' => buffer.delete(
+ .right,
+ buffer.buffer.items.len - buffer.cursor,
+ ),
+ else => return,
+ },
+ .alt => |key| switch (key) {
+ 'f' => buffer.cursor +|= buffer.lenToNextWordStart(.right) orelse return,
+ 'b' => buffer.cursor -|= buffer.lenToNextWordStart(.left) orelse return,
+ else => return,
+ },
+ .escape => context.mode.setNav(),
+ .arrow_left => buffer.moveCursor(.left, 1),
+ .arrow_right => buffer.moveCursor(.right, 1),
+ .delete => buffer.delete(.right, 1),
+ else => return,
}
+ try context.ui.render(false, true);
}
fn handleReturnUserInput() !void {
@@ 228,14 255,14 @@ fn handleReturnUserInput() !void {
const user_input = &context.mode.user_input;
switch (user_input.operation) {
.command => {
- if (user_input.buffer.items.len == 0) {
+ if (user_input.buffer.buffer.items.len == 0) {
context.mode.setNav();
try context.ui.render(false, true);
return;
}
// Intercept 'cd'.
- var it = mem.tokenize(u8, user_input.buffer.items, &ascii.spaces);
+ var it = mem.tokenize(u8, user_input.buffer.buffer.items, &ascii.spaces);
if (it.next()) |token| {
if (mem.eql(u8, token, "cd")) {
if (it.next()) |dir| {
@@ 260,11 287,11 @@ fn handleReturnUserInput() !void {
}
// Try to execute command.
- try run(user_input.buffer.items, null, null, "/bin/sh or subprocess");
+ try run(user_input.buffer.buffer.items, null, null, "/bin/sh or subprocess");
},
.search => {
if (context.search) |*search| search.deinit();
- context.search = Regex.compile(context.gpa, user_input.buffer.items) catch {
+ context.search = Regex.compile(context.gpa, user_input.buffer.buffer.items) catch {
// TODO find out the exact errors that indicate a bad regex
context.mode.setMessage(.err, "Invalid regex");
try context.ui.render(false, true);
@@ 273,7 300,7 @@ fn handleReturnUserInput() !void {
try handleAsciiKeyNav('n');
},
.select => {
- var select = Regex.compile(context.gpa, user_input.buffer.items) catch {
+ var select = Regex.compile(context.gpa, user_input.buffer.buffer.items) catch {
// TODO find out the exact errors that indicate a bad regex
context.mode.setMessage(.err, "Invalid regex");
try context.ui.render(false, true);
@@ 432,16 459,16 @@ fn handleAsciiKeyNav(key: u8) !void {
// position.
const file = &context.cwd.files.items[context.cwd.selection];
try context.mode.setUserInput(.command);
- try context.mode.user_input.insertChar(' ');
- try context.mode.user_input.insertSlice(file.name, .single_word);
- context.mode.user_input.cursor = 0;
+ try context.mode.user_input.buffer.insertChar(' ');
+ try context.mode.user_input.buffer.insertSlice(file.name, .single_word);
+ context.mode.user_input.buffer.cursor = 0;
try context.ui.render(false, true);
},
'x' => {
const file = &context.cwd.files.items[context.cwd.selection];
try context.mode.setUserInput(.command);
- try context.mode.user_input.insertSlice("rm ", .pure);
- try context.mode.user_input.insertSlice(file.name, .single_word);
+ try context.mode.user_input.buffer.insertSlice("rm ", .pure);
+ try context.mode.user_input.buffer.insertSlice(file.name, .single_word);
try context.ui.render(false, true);
},
'X' => {
@@ 452,9 479,9 @@ fn handleAsciiKeyNav(key: u8) !void {
'c' => {
const file = &context.cwd.files.items[context.cwd.selection];
try context.mode.setUserInput(.command);
- try context.mode.user_input.insertSlice("mv ", .pure);
- try context.mode.user_input.insertSlice(file.name, .single_word);
- try context.mode.user_input.insertChar(' ');
+ try context.mode.user_input.buffer.insertSlice("mv ", .pure);
+ try context.mode.user_input.buffer.insertSlice(file.name, .single_word);
+ try context.mode.user_input.buffer.insertChar(' ');
try context.ui.render(false, true);
},
'S' => {