diff --git a/Makefile b/Makefile index 1dc0167..d29598e 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,10 @@ DOCDIR ?= $(PREFIX)/share/doc/sfm CXX ?= g++ CXXFLAGS ?= -std=c++17 -O2 -Wall -Wextra -LDFLAGS ?= -lncursesw +# Pick a curses library: prefer ncursesw, fall back to ncurses, then plain +# curses (netbsd-curses and BSD base systems provide only libcurses). +CURSES_LIBS ?= $(shell pkg-config --libs ncursesw 2>/dev/null || pkg-config --libs ncurses 2>/dev/null || echo -lcurses) +LDLIBS ?= $(CURSES_LIBS) INSTALL ?= install RM ?= rm -f @@ -17,7 +20,7 @@ RM ?= rm -f all: sfm sfm: sfm.cpp - $(CXX) $(CXXFLAGS) -o $@ $< $(LDFLAGS) + $(CXX) $(CXXFLAGS) -o $@ $< $(LDLIBS) install: all @echo "Installing sfm to $(DESTDIR)$(BINDIR)/sfm ..." diff --git a/README b/README index 310bfe4..212ade5 100644 --- a/README +++ b/README @@ -5,15 +5,16 @@ sfm - Simple File Manager DESCRIPTION ----------- sfm is a lightweight, terminal-based file manager with a vim-inspired -key layout. The primary version is a fast C++/ncurses binary. A portable -POSIX sh version (sfm.sh) is also included, requiring only standard Unix -tools (ls, awk, tput, stty, mv, cp, rm). +key layout. The primary version is a fast C++ binary that works with +both ncurses and netbsd-curses. A portable POSIX sh version (sfm.sh) is +also included, requiring only standard Unix tools (ls, awk, tput, stty, +mv, cp, rm). REQUIREMENTS ------------ C++ version (sfm): -- ncursesw (libncursesw) +- A curses library: ncursesw, ncurses, or netbsd-curses (libcurses) - A C++17 compiler (g++ or compatible) Shell version (sfm.sh): diff --git a/sfm.cpp b/sfm.cpp index 8ba5204..6218484 100644 --- a/sfm.cpp +++ b/sfm.cpp @@ -1,6 +1,7 @@ -// sfm - Simple File Manager in C++17 with ncurses +// sfm - Simple File Manager in C++17 with curses +// Works with both ncurses and netbsd-curses. // Rewrite of the POSIX sh original for speed and efficiency -#include +#include #include #include @@ -18,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +29,140 @@ namespace fs = std::filesystem; // ─── terminal helpers ─────────────────────────────────────────────────────── [[nodiscard]] bool can_color() { return has_colors(); } +// ─── key input normalization ──────────────────────────────────────────────── +// ncurses decodes escape sequences (arrows, Home/End, ...) into KEY_* codes +// itself. netbsd-curses returns the raw bytes (e.g. ESC [ B for the down +// arrow), which would leak stray characters into the command handling. +// get_key() presents KEY_* codes (or 27 for a bare ESC) on both. + +#ifdef NCURSES_VERSION + +static int get_key() { return getch(); } + +#else // manual escape-sequence decoding (netbsd-curses etc.) + +static void nap_ms(int ms) { + struct timespec ts; + ts.tv_sec = ms / 1000; + ts.tv_nsec = (ms % 1000) * 1000000L; + ::nanosleep(&ts, nullptr); +} + +static int g_pushback[32]; +static int g_pushback_n = 0; + +static void queue_key(int k) { + if (g_pushback_n < static_cast(sizeof(g_pushback) / sizeof(g_pushback[0]))) + g_pushback[g_pushback_n++] = k; +} + +// seq holds the bytes after ESC; seq[0] is '[' (CSI) or 'O' (SS3). +static int decode_escape_seq(const std::string &seq) { + if (seq.size() < 2) return ERR; + char lead = seq[0]; + char fin = seq.back(); + if (lead == 'O') { + if (seq.size() != 2) return ERR; + switch (fin) { + case 'A': return KEY_UP; + case 'B': return KEY_DOWN; + case 'C': return KEY_RIGHT; + case 'D': return KEY_LEFT; + case 'H': return KEY_HOME; + case 'F': return KEY_END; + case 'P': case 'Q': case 'R': case 'S': return KEY_F(fin - 'P' + 1); + } + return ERR; + } + if (lead != '[') return ERR; + switch (fin) { + case 'A': return KEY_UP; + case 'B': return KEY_DOWN; + case 'C': return KEY_RIGHT; + case 'D': return KEY_LEFT; + case 'H': return KEY_HOME; + case 'F': return KEY_END; + case 'Z': return KEY_BTAB; + } + // CSI n~ : Home/Insert/Delete/End/PgUp/PgDn (single digit only; multi- + // digit and modified variants fall through and are swallowed). + if (seq.size() == 3 && fin == '~' && seq[1] >= '0' && seq[1] <= '9') { + switch (seq[1]) { + case '1': case '7': return KEY_HOME; + case '2': return KEY_IC; + case '3': return KEY_DC; + case '4': case '8': return KEY_END; + case '5': return KEY_PPAGE; + case '6': return KEY_NPAGE; + } + } + return ERR; +} + +// Like getch(), but assembles raw ESC sequences into KEY_* codes. A bare ESC +// is returned as 27 (possibly after a short wait for the rest of a sequence). +static int get_key() { + if (g_pushback_n > 0) return g_pushback[--g_pushback_n]; + + int k = getch(); + if (k != 27) return k; + + // ESC — check whether an escape sequence follows. Real terminals send + // the whole sequence in one burst, so the bytes are already queued. + nodelay(stdscr, TRUE); + std::string seq; + int c; + for (int i = 0; i < 4 && seq.empty(); ++i) { + c = getch(); + if (c == ERR) nap_ms(10); + else seq += static_cast(c); + } + if (seq.empty()) { nodelay(stdscr, FALSE); return 27; } // bare ESC + + if (seq[0] == '[') { + // CSI: parameter/intermediate bytes, then a final byte (0x40-0x7E). + // The lead '[' itself is in that range, so collect at least one more. + for (int i = 0; i < 8; ++i) { + if (seq.size() >= 2 && seq.back() >= 0x40 && seq.back() <= 0x7E) break; + c = getch(); + if (c == ERR) { nap_ms(10); c = getch(); } + if (c == ERR) break; + seq += static_cast(c); + } + } else if (seq[0] == 'O') { + // SS3: exactly one final byte follows. + for (int i = 0; i < 4 && seq.size() < 2; ++i) { + c = getch(); + if (c == ERR) { nap_ms(10); c = getch(); } + if (c == ERR) break; + seq += static_cast(c); + } + } + nodelay(stdscr, FALSE); + + int key = decode_escape_seq(seq); + if (key == ERR) { + bool complete = seq.back() >= 0x40 && seq.back() <= 0x7E; + if (!complete) { + // Truncated sequence — replay it as raw keys (ESC first). + for (size_t i = seq.size(); i-- > 0;) + queue_key(static_cast(seq[i])); + queue_key(27); + return 27; + } + // Unknown but well-formed sequence (e.g. an F-key): swallow it, + // like curses implementations do. + return ERR; + } + return key; +} + +// netbsd-curses never reports KEY_RESIZE; catch SIGWINCH ourselves. +static volatile sig_atomic_t g_winch = 0; +static void on_sigwinch(int) { g_winch = 1; } + +#endif // NCURSES_VERSION + // Color pair indices enum { CP_DIR = 1, @@ -200,6 +336,9 @@ private: const std::vector &items, bool allow_cancel = true); + // ── input ────────────────────────────────────────────────────────────── + bool handle_key(int key); // returns false to quit + // ── line input ───────────────────────────────────────────────────────── bool read_line(std::string &out, const std::string &prompt, const std::string &initial = ""); @@ -945,7 +1084,7 @@ bool FileManager::confirm_overlay(const std::string &prompt) { refresh(); - int ch = getch(); + int ch = get_key(); switch (ch) { case KEY_LEFT: case 'h': sel_yes = true; break; case KEY_RIGHT: case 'l': sel_yes = false; break; @@ -956,18 +1095,7 @@ bool FileManager::confirm_overlay(const std::string &prompt) { case '\n': case '\r': case KEY_ENTER: need_full_redraw_ = true; return sel_yes; - case 27: { - nodelay(stdscr, TRUE); - int n = getch(); - nodelay(stdscr, FALSE); - if (n == ERR) { need_full_redraw_ = true; return false; } - if (n == '[') { - int m = getch(); - if (m == 'D' || m == 'C') { sel_yes = !sel_yes; break; } - } - need_full_redraw_ = true; - return false; - } + case 27: need_full_redraw_ = true; return false; default: break; } } @@ -1066,7 +1194,7 @@ std::string FileManager::overlay_picker(const std::string &title, refresh(); - int ch = getch(); + int ch = get_key(); switch (ch) { case 'j': case KEY_DOWN: psel++; break; case 'k': case KEY_UP: psel--; break; @@ -1077,20 +1205,7 @@ std::string FileManager::overlay_picker(const std::string &title, case 'q': case 'Q': case 'h': if (allow_cancel) { need_full_redraw_ = true; return ""; } break; - case 27: { - nodelay(stdscr, TRUE); - int n = getch(); - nodelay(stdscr, FALSE); - if (n == ERR) { need_full_redraw_ = true; return ""; } - if (n == '[') { - int m = getch(); - if (m == 'A') psel--; - else if (m == 'B') psel++; - else if (m == 'D') { need_full_redraw_ = true; return ""; } - else { need_full_redraw_ = true; return ""; } - } - break; - } + case 27: need_full_redraw_ = true; return ""; default: break; } } @@ -1126,18 +1241,12 @@ bool FileManager::read_line(std::string &out, const std::string &prompt, move(bot, static_cast(prompt.size() + pos)); refresh(); - int ch = getch(); - if (ch == 27) { // ESC - nodelay(stdscr, TRUE); - int n = getch(); - nodelay(stdscr, FALSE); - if (n == ERR) { - leaveok(stdscr, TRUE); - curs_set(0); - out.clear(); - return false; - } - continue; + int ch = get_key(); + if (ch == 27) { // bare ESC — cancel + leaveok(stdscr, TRUE); + curs_set(0); + out.clear(); + return false; } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { leaveok(stdscr, TRUE); curs_set(0); @@ -1307,20 +1416,14 @@ void FileManager::do_search() { need_full_redraw_ = true; draw(); - int ch = getch(); - if (ch == 27) { // ESC - nodelay(stdscr, TRUE); - int n = getch(); - nodelay(stdscr, FALSE); - if (n == ERR) { - // Bare ESC — clear filter and exit search - filter_.clear(); - searching_ = false; - apply_filter(); - curs_set(0); - need_full_redraw_ = true; - } - // else: arrow key or other sequence — ignore + int ch = get_key(); + if (ch == 27) { + // Bare ESC — clear filter and exit search + filter_.clear(); + searching_ = false; + apply_filter(); + curs_set(0); + need_full_redraw_ = true; } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { searching_ = false; curs_set(0); @@ -1475,13 +1578,7 @@ void FileManager::do_info() { refresh(); // Wait for any key - nodelay(stdscr, FALSE); - int ch = getch(); - if (ch == 27) { - nodelay(stdscr, TRUE); - getch(); - nodelay(stdscr, FALSE); - } + get_key(); need_full_redraw_ = true; } @@ -1598,13 +1695,7 @@ void FileManager::do_help() { refresh(); // Wait for any key - nodelay(stdscr, FALSE); - int ch = getch(); - if (ch == 27) { - nodelay(stdscr, TRUE); - getch(); - nodelay(stdscr, FALSE); - } + get_key(); need_full_redraw_ = true; } @@ -2327,6 +2418,68 @@ void FileManager::open_file(const std::string &path) { } // ─── main event loop ──────────────────────────────────────────────────────── +bool FileManager::handle_key(int key) { + switch (key) { + case 'j': case KEY_DOWN: + if (!entries_.empty() && sel_ < static_cast(entries_.size()) - 1) + sel_++; + break; + case 'k': case KEY_UP: + if (sel_ > 0) sel_--; + break; + case 'g': sel_ = 0; break; + case 'G': + if (!entries_.empty()) sel_ = static_cast(entries_.size()) - 1; + break; + case KEY_NPAGE: + sel_ = std::min(sel_ + rows_ / 2, + std::max(0, static_cast(entries_.size()) - 1)); + break; + case KEY_PPAGE: + sel_ = std::max(sel_ - rows_ / 2, 0); + break; + case KEY_DC: do_delete(); break; + case '\n': case '\r': case KEY_ENTER: case 'l': case KEY_RIGHT: + do_open(); break; + case 'h': case KEY_LEFT: + do_go_back(); break; + case 'b': do_bookmark_add(); break; + case 'B': do_bookmark_jump(); break; + case '?': do_help(); break; + case 'R': load_entries(); flash_msg("refreshed"); break; + case '/': do_search(); break; + case '.': do_toggle_hidden(); break; + case 'T': do_toggle_details(); break; + case 'P': do_toggle_preview(); break; + case 'i': do_info(); break; + case '+': do_chmod_x(true); break; + case '-': do_chmod_x(false); break; + case 'o': do_open_with(); break; + case 's': do_sort(); break; + case 'u': do_trash(); break; + case 'U': do_open_trash(); break; + case 'f': do_find(); break; + case ':': do_jump_path(); break; + case '~': do_go_home(); break; + case '`': do_jump_back(); break; + case 'c': do_copy_path(); break; + case 27: do_clear_filter(); break; // ESC key + case ' ': do_toggle_select(); break; + case 'a': do_select_all(); break; + case 'y': do_yank(); break; + case 'x': do_cut(); break; + case 'p': do_paste(); break; + case 'd': do_delete(); break; + case 'r': do_rename(); break; + case 'm': do_mkdir(); break; + case 'n': do_newfile(); break; + case 'q': case 'Q': return false; + case '!': do_shell(); break; + default: break; + } + return true; +} + void FileManager::run() { setup_term(); if (can_color()) init_colors(); @@ -2334,75 +2487,43 @@ void FileManager::run() { is_root_ = (geteuid() == 0); - while (true) { - draw(); - int key = getch(); +#ifndef NCURSES_VERSION + struct sigaction sa; + std::memset(&sa, 0, sizeof(sa)); + sa.sa_handler = on_sigwinch; + sigaction(SIGWINCH, &sa, nullptr); +#endif - // Handle terminal resize + draw(); + while (true) { +#ifndef NCURSES_VERSION + // netbsd-curses never delivers KEY_RESIZE; poll for SIGWINCH and + // re-query the terminal size ourselves. + if (g_winch) { + g_winch = 0; + endwin(); // endwin()+refresh() makes curses re-read the size + refresh(); + need_full_redraw_ = true; + draw(); + } + nodelay(stdscr, TRUE); +#endif + int key = get_key(); +#ifndef NCURSES_VERSION + nodelay(stdscr, FALSE); + if (key == ERR) { nap_ms(20); continue; } +#endif + + // Handle terminal resize (ncurses) if (key == KEY_RESIZE) { update_size(); need_full_redraw_ = true; + draw(); continue; } - switch (key) { - case 'j': case KEY_DOWN: - if (!entries_.empty() && sel_ < static_cast(entries_.size()) - 1) - sel_++; - break; - case 'k': case KEY_UP: - if (sel_ > 0) sel_--; - break; - case 'g': sel_ = 0; break; - case 'G': - if (!entries_.empty()) sel_ = static_cast(entries_.size()) - 1; - break; - case KEY_NPAGE: - sel_ = std::min(sel_ + rows_ / 2, - std::max(0, static_cast(entries_.size()) - 1)); - break; - case KEY_PPAGE: - sel_ = std::max(sel_ - rows_ / 2, 0); - break; - case KEY_DC: do_delete(); break; - case '\n': case '\r': case KEY_ENTER: case 'l': case KEY_RIGHT: - do_open(); break; - case 'h': case KEY_LEFT: - do_go_back(); break; - case 'b': do_bookmark_add(); break; - case 'B': do_bookmark_jump(); break; - case '?': do_help(); break; - case 'R': load_entries(); flash_msg("refreshed"); break; - case '/': do_search(); break; - case '.': do_toggle_hidden(); break; - case 'T': do_toggle_details(); break; - case 'P': do_toggle_preview(); break; - case 'i': do_info(); break; - case '+': do_chmod_x(true); break; - case '-': do_chmod_x(false); break; - case 'o': do_open_with(); break; - case 's': do_sort(); break; - case 'u': do_trash(); break; - case 'U': do_open_trash(); break; - case 'f': do_find(); break; - case ':': do_jump_path(); break; - case '~': do_go_home(); break; - case '`': do_jump_back(); break; - case 'c': do_copy_path(); break; - case 27: do_clear_filter(); break; // ESC key - case ' ': do_toggle_select(); break; - case 'a': do_select_all(); break; - case 'y': do_yank(); break; - case 'x': do_cut(); break; - case 'p': do_paste(); break; - case 'd': do_delete(); break; - case 'r': do_rename(); break; - case 'm': do_mkdir(); break; - case 'n': do_newfile(); break; - case 'q': case 'Q': return; - case '!': do_shell(); break; - default: break; - } + if (!handle_key(key)) return; + draw(); } }