~leon_plickat/nfm

aac4a46c24141fc627476d8d544c62daf6192d89 — Leon Henrik Plickat 4 months ago 5a21ea5
Support readline-esque keybinds for the input buffer
6 files changed, 272 insertions(+), 118 deletions(-)

M README.md
M doc/nfm.1
A src/InputBuffer.zig
M src/UserInterface.zig
M src/mode.zig
M src/nfm.zig
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' => {