diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e84a9b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build/ +*.db diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..8804fe7 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,57 @@ +cmake_minimum_required(VERSION 3.14) +project(LLS VERSION 1.0 LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) +endif() + +# --- SQLite ------------------------------------------------------------------ +# Use the system SQLite if present, otherwise download the amalgamation. +find_package(SQLite3) + +if(NOT SQLite3_FOUND) + message(STATUS "System SQLite3 not found - fetching the amalgamation") + include(FetchContent) + if(POLICY CMP0135) + cmake_policy(SET CMP0135 NEW) + endif() + FetchContent_Declare(sqlite3_amalgamation + URL https://www.sqlite.org/2024/sqlite-amalgamation-3450100.zip) + FetchContent_MakeAvailable(sqlite3_amalgamation) + + find_package(Threads REQUIRED) + add_library(sqlite3_local STATIC ${sqlite3_amalgamation_SOURCE_DIR}/sqlite3.c) + target_include_directories(sqlite3_local PUBLIC ${sqlite3_amalgamation_SOURCE_DIR}) + target_link_libraries(sqlite3_local PUBLIC Threads::Threads ${CMAKE_DL_LIBS}) + add_library(SQLite::SQLite3 ALIAS sqlite3_local) +endif() + +# --- Application ------------------------------------------------------------- +add_executable(lls + src/main.cpp + src/Completer.cpp + src/Config.cpp + src/Database.cpp + src/Display.cpp + src/ExternalEditor.cpp + src/LineEditor.cpp + src/Repl.cpp + src/Status.cpp + src/StringUtil.cpp + src/Table.cpp + src/Terminal.cpp + src/Tokenizer.cpp +) + +target_include_directories(lls PRIVATE include) +target_link_libraries(lls PRIVATE SQLite::SQLite3) + +if(MSVC) + target_compile_options(lls PRIVATE /W4) +else() + target_compile_options(lls PRIVATE -Wall -Wextra -Wpedantic) +endif() diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..9b6cfd7 --- /dev/null +++ b/build.sh @@ -0,0 +1 @@ +cmake -B build && cmake --build build --parallel -j 8 diff --git a/include/CommandSpec.h b/include/CommandSpec.h new file mode 100644 index 0000000..7f62275 --- /dev/null +++ b/include/CommandSpec.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +// What kind of value an argument holds (drives tab completion). +enum class ArgKind { + None, + Task, + Account, + AccountOrDash, + AccountOrStatus, + Status, + Visibility, + TaskLevel, + AccountLevel, + File, + Editor +}; + +struct CommandSpec { + std::string key; // e.g. "tsk add" + std::string usage; // e.g. "tsk add [acc] [status]" + std::string help; + std::size_t minArgs = 0; + std::size_t maxArgs = 0; + std::vector argKinds; +}; diff --git a/include/Completer.h b/include/Completer.h new file mode 100644 index 0000000..684b603 --- /dev/null +++ b/include/Completer.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +#include "CommandSpec.h" +#include "Completion.h" +#include "Database.h" + +// Works out tab-completion candidates for the text typed so far: command +// words first, then arguments (task names, account names, statuses, ...). +class Completer { +public: + Completer(const Database& db, const std::vector& specs); + + Completion complete(const std::string& textBeforeCursor) const; + +private: + std::vector valuesFor(ArgKind kind, const std::string& current) const; + + const Database& db_; + const std::vector& specs_; +}; diff --git a/include/Completion.h b/include/Completion.h new file mode 100644 index 0000000..2950c71 --- /dev/null +++ b/include/Completion.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#include +#include + +// Result of a tab-completion request: the text from `start` up to the cursor +// can be replaced by any of `candidates`. +struct Completion { + std::size_t start = 0; + std::vector candidates; +}; diff --git a/include/Config.h b/include/Config.h new file mode 100644 index 0000000..6e6b7c4 --- /dev/null +++ b/include/Config.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +// User preferences stored outside the database, in +// $XDG_CONFIG_HOME/task_tracker (or ~/.config/task_tracker, or %APPDATA%). +class Config { +public: + Config(); + + void load(); + void save() const; + + const std::string& editor() const; // empty = use the default + void setEditor(const std::string& command); + + std::string historyPath() const; + + static std::string directory(); + +private: + std::string directory_; + std::string editor_; +}; diff --git a/include/Database.h b/include/Database.h new file mode 100644 index 0000000..ccec73d --- /dev/null +++ b/include/Database.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include + +#include "Models.h" + +struct sqlite3; + +// Works on an in-memory SQLite database that is loaded from, and saved to, a +// file on request (db save / backup / restore). +class Database { +public: + Database(); + ~Database(); + Database(const Database&) = delete; + Database& operator=(const Database&) = delete; + + // Loads `path` into memory if it exists, otherwise starts empty. + void open(const std::string& path); + void save(); + std::string backup(const std::string& file); // empty = timestamped name; returns the file used + void restore(const std::string& file); + + bool dirty() const; + const std::string& path() const; + + // Accounts + std::optional findAccount(const std::string& name) const; // canonical spelling + std::vector listAccounts() const; + void addAccount(const std::string& name); + void removeAccount(const std::string& name); + void renameAccount(const std::string& name, const std::string& newName); + + // Tasks + std::optional findTask(const std::string& name) const; // canonical spelling + std::optional getTask(const std::string& name) const; + std::vector listTasks() const; + void addTask(const std::string& name, const std::optional& account, const std::string& status); + void removeTask(const std::string& name); + void renameTask(const std::string& name, const std::string& newName); + void setTaskAccount(const std::string& name, const std::optional& account); + void setTaskStatus(const std::string& name, const std::string& status); + void setTaskNotes(const std::string& name, const std::string& notes); + +private: + using Param = std::optional; + using Row = std::vector>; + + void createSchema(); + void exec(const std::string& sql); + std::vector query(const std::string& sql, const std::vector& params = {}) const; + void run(const std::string& sql, const std::vector& params); + + sqlite3* db_; + std::string path_; + bool dirty_; +}; diff --git a/include/Display.h b/include/Display.h new file mode 100644 index 0000000..1c5a3cf --- /dev/null +++ b/include/Display.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +#include "Database.h" +#include "Status.h" + +enum class AccountLevel { Busy, Free }; + +struct DisplaySettings { + bool showTasks = true; // auto-print the task table after each change + bool showAccounts = true; // auto-print the account table after each change + std::set hiddenTaskLevels; + std::set hiddenAccountLevels; +}; + +// Builds the computed views. The status shown is derived from the stored +// status and the current time, never copied straight from the database. +class Display { +public: + explicit Display(const Database& db); + + DisplaySettings& settings(); + const DisplaySettings& settings() const; + + std::string renderTasks(std::time_t now) const; + std::string renderAccounts(std::time_t now) const; + +private: + const Database& db_; + DisplaySettings settings_; +}; diff --git a/include/ExternalEditor.h b/include/ExternalEditor.h new file mode 100644 index 0000000..82ff517 --- /dev/null +++ b/include/ExternalEditor.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +namespace ExternalEditor { + +// $VISUAL, then $EDITOR, then the first of nano/vim/vi found on PATH. +// Empty if nothing was found. +std::string defaultCommand(); + +// Is the first word of `command` an executable we can find? +bool commandExists(const std::string& command); + +// Well-known editors that are installed (used for tab completion). +std::vector installedEditors(); + +// Opens `initialText` in the editor and returns the edited text. +// Throws std::runtime_error if the editor cannot be run or exits with an error. +std::string edit(const std::string& command, const std::string& initialText); + +} // namespace ExternalEditor diff --git a/include/LineEditor.h b/include/LineEditor.h new file mode 100644 index 0000000..ec45b06 --- /dev/null +++ b/include/LineEditor.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +#include "Completion.h" + +// Reads one line with cursor editing, history (up/down) and tab completion. +// Falls back to plain std::getline when stdin/stdout are not a terminal. +class LineEditor { +public: + using CompletionFn = std::function; + + LineEditor(); + + void setCompleter(const CompletionFn& completer); + void setHistoryFile(const std::string& path); // loads it and appends new entries + void addHistory(const std::string& line); + + // Returns false on end of input (Ctrl-D on an empty line, or closed stdin). + bool readLine(const std::string& prompt, std::string& line); + +private: + bool readPlain(const std::string& prompt, std::string& line); + bool readInteractive(const std::string& prompt, std::string& line); + + CompletionFn completer_; + std::vector history_; + std::string historyFile_; +}; diff --git a/include/Models.h b/include/Models.h new file mode 100644 index 0000000..dcd162d --- /dev/null +++ b/include/Models.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +struct TaskRow { + std::string id; + std::optional account; + std::string status; // serialized, see StatusUtil::serialize + std::string notes; // free text, any length (stored as SQLite TEXT) +}; diff --git a/include/Repl.h b/include/Repl.h new file mode 100644 index 0000000..7b35b80 --- /dev/null +++ b/include/Repl.h @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include + +#include "CommandSpec.h" +#include "Completer.h" +#include "Config.h" +#include "Database.h" +#include "Display.h" +#include "LineEditor.h" +#include "Status.h" + +// Interactive prompt: commands are typed live, one per line. +class Repl { +public: + Repl(Database& db, Display& display, Config& config); + + int run(); + +private: + using Args = std::vector; + using Handler = bool (Repl::*)(const Args&); // returns true if the panels should be redrawn + + struct Command { + CommandSpec spec; + Handler handler; + }; + + void registerCommands(); + void add(const std::string& key, const std::string& usage, const std::string& help, + std::size_t minArgs, std::size_t maxArgs, const std::vector& argKinds, + Handler handler); + const Command* find(const Args& tokens, std::size_t& consumed) const; + + void execute(const std::string& line); + void refresh() const; + void say(const std::string& message); + void flushMessages(); + void endOfInput(); + bool confirm(const std::string& question) const; + + // Helpers + static void requireName(const std::string& name); + static Status parseStatusFrom(const Args& args, std::size_t from); + + // Account commands + bool cmdAccAdd(const Args& args); + bool cmdAccDel(const Args& args); + bool cmdAccRename(const Args& args); + bool cmdAccLs(const Args& args); + + // Task commands + bool cmdTskAdd(const Args& args); + bool cmdTskDel(const Args& args); + bool cmdTskLs(const Args& args); + bool cmdTskRename(const Args& args); + bool cmdTskReacc(const Args& args); + bool cmdTskRestat(const Args& args); + bool cmdTskNotes(const Args& args); + + // Display commands + bool cmdDisp(const Args& args); + bool cmdDispTsk(const Args& args); + bool cmdDispTskLevel(const Args& args); + bool cmdDispAcc(const Args& args); + bool cmdDispAccLevel(const Args& args); + + // Database commands + bool cmdDbSave(const Args& args); + bool cmdDbBackup(const Args& args); + bool cmdDbRestore(const Args& args); + + // Misc + bool cmdEditor(const Args& args); + bool cmdHelp(const Args& args); + bool cmdQuit(const Args& args); + + Database& db_; + Display& display_; + Config& config_; + std::vector commands_; + std::vector specs_; + Completer completer_; + LineEditor lineEditor_; + std::vector messages_; + bool quit_; +}; diff --git a/include/Status.h b/include/Status.h new file mode 100644 index 0000000..f2fc041 --- /dev/null +++ b/include/Status.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include + +// Prompt: free to accept prompts. +// LimitReset: computed - a Wait whose time has passed (can also be set by hand). +// The enum order is the sort order of the panels. +enum class StatusKind { Prompt, LimitReset, Testing, Wait, Uploading, Frozen, Done }; + +struct Status { + StatusKind kind = StatusKind::Prompt; + std::time_t waitUntil = 0; // only meaningful when kind == Wait +}; + +namespace StatusUtil { + +Status make(StatusKind kind); + +// User input such as "testing" or "wait 16:30". A wait time that has already +// passed today is taken to mean tomorrow. +std::optional parse(const std::string& text, std::time_t now); + +// Database representation, e.g. "testing" or "wait 2026-10-17T16:30". +std::string serialize(const Status& status); +std::optional deserialize(const std::string& text); + +// The status shown to the user: an expired wait becomes LimitReset. +Status effective(const Status& status, std::time_t now); + +std::string label(const Status& status); // "Prompt", "Limit reset", "Wait 16:30", ... +std::string kindName(StatusKind kind); // lowercase token: "prompt", "limit-reset", ... +std::vector allKinds(); +// Accepts "limit-reset", "limit_reset", "limitreset", "reset"; "available" is an +// old name for "prompt". +std::optional kindFromName(const std::string& name); +int rank(StatusKind kind); // sort order +bool isBusy(StatusKind kind); // does this status hold the account? + +} // namespace StatusUtil diff --git a/include/StringUtil.h b/include/StringUtil.h new file mode 100644 index 0000000..46ce758 --- /dev/null +++ b/include/StringUtil.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +namespace StringUtil { + +std::string lower(const std::string& text); +std::string trim(const std::string& text); +bool equalsIgnoreCase(const std::string& a, const std::string& b); +bool startsWithIgnoreCase(const std::string& text, const std::string& prefix); +std::string join(const std::vector& parts, std::size_t from, const std::string& separator); + +// Number of characters (UTF-8 code points) in the text. +std::size_t displayWidth(const std::string& text); + +// Collapses line breaks into " / ". If `maxChars` > 0 the text is cut to that +// many characters; 0 means no limit. +std::string singleLine(const std::string& text, std::size_t maxChars); + +} // namespace StringUtil diff --git a/include/Table.h b/include/Table.h new file mode 100644 index 0000000..c1c109b --- /dev/null +++ b/include/Table.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include + +class TextTable { +public: + explicit TextTable(const std::vector& headers); + + void addRow(const std::vector& cells); + std::size_t rowCount() const; + std::string render() const; + +private: + std::vector headers_; + std::vector> rows_; +}; diff --git a/include/Terminal.h b/include/Terminal.h new file mode 100644 index 0000000..d147cf9 --- /dev/null +++ b/include/Terminal.h @@ -0,0 +1,11 @@ +#pragma once + +namespace Terminal { + +bool stdinIsInteractive(); +bool stdoutIsInteractive(); + +// Clears the screen. Does nothing when stdout is not a terminal. +void clearScreen(); + +} // namespace Terminal diff --git a/include/Tokenizer.h b/include/Tokenizer.h new file mode 100644 index 0000000..679f073 --- /dev/null +++ b/include/Tokenizer.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +namespace Tokenizer { + +// Splits a command line on whitespace. Double quotes group words into a single +// token ("Breeze A"). Throws std::invalid_argument on an unterminated quote. +std::vector split(const std::string& line); + +// Lenient split for tab completion: completed words, plus the word being typed. +struct PartialLine { + std::vector words; // completed words (quotes removed) + std::string current; // word being typed (quotes removed, may be empty) + std::size_t start = 0; // index in the text where that word begins +}; + +PartialLine splitPartial(const std::string& text); + +} // namespace Tokenizer diff --git a/src/Completer.cpp b/src/Completer.cpp new file mode 100644 index 0000000..e4bb00c --- /dev/null +++ b/src/Completer.cpp @@ -0,0 +1,178 @@ +#include "Completer.h" + +#include +#include +#include +#include + +#include "ExternalEditor.h" +#include "Status.h" +#include "StringUtil.h" +#include "Tokenizer.h" + +namespace { + +std::string quoteIfNeeded(const std::string& name, bool force) { + const bool hasSpace = std::any_of(name.begin(), name.end(), + [](unsigned char c) { return std::isspace(c) != 0; }); + return (force || hasSpace) ? "\"" + name + "\"" : name; +} + +std::vector splitWords(const std::string& text) { + std::vector words; + std::istringstream in(text); + std::string word; + while (in >> word) { + words.push_back(word); + } + return words; +} + +std::vector statusNames() { + std::vector names; + for (StatusKind kind : StatusUtil::allKinds()) { + names.push_back(StatusUtil::kindName(kind)); + } + return names; +} + +std::vector fileNames(const std::string& partial) { + namespace fs = std::filesystem; + + std::string directoryPart; + std::string prefix = partial; + const std::size_t slash = partial.find_last_of("/\\"); + if (slash != std::string::npos) { + directoryPart = partial.substr(0, slash + 1); + prefix = partial.substr(slash + 1); + } + + std::vector names; + std::error_code error; + fs::directory_iterator it(directoryPart.empty() ? fs::path(".") : fs::path(directoryPart), error); + for (fs::directory_iterator end; !error && it != end; it.increment(error)) { + const std::string name = it->path().filename().string(); + if (!name.empty() && name[0] == '.' && prefix.empty()) { + continue; // hide dotfiles unless asked for + } + std::error_code statusError; + const bool isDirectory = it->is_directory(statusError); + names.push_back(directoryPart + name + (isDirectory ? "/" : "")); + } + return names; +} + +} // namespace + +Completer::Completer(const Database& db, const std::vector& specs) + : db_(db), specs_(specs) {} + +std::vector Completer::valuesFor(ArgKind kind, const std::string& current) const { + std::vector values; + switch (kind) { + case ArgKind::None: + break; + case ArgKind::Task: + for (const TaskRow& task : db_.listTasks()) { + values.push_back(task.id); + } + break; + case ArgKind::Account: + values = db_.listAccounts(); + break; + case ArgKind::AccountOrDash: + values = db_.listAccounts(); + values.push_back("-"); + break; + case ArgKind::AccountOrStatus: + values = db_.listAccounts(); + for (const std::string& name : statusNames()) { + values.push_back(name); + } + break; + case ArgKind::Status: + values = statusNames(); + break; + case ArgKind::Visibility: + values = {"show", "hide"}; + break; + case ArgKind::TaskLevel: + values = statusNames(); + values.push_back("all"); + break; + case ArgKind::AccountLevel: + values = {"busy", "free", "all"}; + break; + case ArgKind::File: + values = fileNames(current); + break; + case ArgKind::Editor: + values = ExternalEditor::installedEditors(); + values.push_back("default"); + break; + } + return values; +} + +Completion Completer::complete(const std::string& textBeforeCursor) const { + const Tokenizer::PartialLine line = Tokenizer::splitPartial(textBeforeCursor); + + std::vector words; + for (const std::string& word : line.words) { + words.push_back(StringUtil::lower(word)); + } + + std::vector raw; + + // 1. Continue a command name: "tsk" -> add/del/ls/..., "disp tsk" -> level + for (const CommandSpec& spec : specs_) { + const std::vector parts = splitWords(spec.key); + if (parts.size() <= words.size()) { + continue; + } + bool matches = true; + for (std::size_t i = 0; i < words.size(); ++i) { + matches = matches && (parts[i] == words[i]); + } + if (matches) { + raw.push_back(parts[words.size()]); + } + } + + // 2. Complete an argument of the command typed so far (longest match wins). + for (std::size_t consumed = std::min(3, words.size()); consumed >= 1; --consumed) { + const std::string key = StringUtil::join( + std::vector(words.begin(), words.begin() + static_cast(consumed)), 0, " "); + const CommandSpec* found = nullptr; + for (const CommandSpec& spec : specs_) { + if (spec.key == key) { + found = &spec; + break; + } + } + if (!found) { + continue; + } + const std::size_t argIndex = words.size() - consumed; + if (argIndex < found->argKinds.size()) { + for (const std::string& value : valuesFor(found->argKinds[argIndex], line.current)) { + raw.push_back(value); + } + } + break; + } + + const bool forceQuote = line.start < textBeforeCursor.size() && textBeforeCursor[line.start] == '"'; + + Completion completion; + completion.start = line.start; + for (const std::string& value : raw) { + if (StringUtil::startsWithIgnoreCase(value, line.current)) { + completion.candidates.push_back(quoteIfNeeded(value, forceQuote)); + } + } + std::sort(completion.candidates.begin(), completion.candidates.end()); + completion.candidates.erase(std::unique(completion.candidates.begin(), completion.candidates.end()), + completion.candidates.end()); + return completion; +} diff --git a/src/Config.cpp b/src/Config.cpp new file mode 100644 index 0000000..1abaa56 --- /dev/null +++ b/src/Config.cpp @@ -0,0 +1,69 @@ +#include "Config.h" + +#include +#include +#include +#include + +#include "StringUtil.h" + +namespace fs = std::filesystem; + +Config::Config() : directory_(directory()) {} + +std::string Config::directory() { + fs::path base; +#ifdef _WIN32 + const char* appData = std::getenv("APPDATA"); + base = (appData && *appData) ? fs::path(appData) : fs::current_path(); +#else + const char* xdg = std::getenv("XDG_CONFIG_HOME"); + const char* home = std::getenv("HOME"); + if (xdg && *xdg) { + base = xdg; + } else if (home && *home) { + base = fs::path(home) / ".config"; + } else { + base = fs::current_path(); + } +#endif + return (base / "task_tracker").string(); +} + +void Config::load() { + std::ifstream in(fs::path(directory_) / "config"); + std::string line; + while (std::getline(in, line)) { + const std::size_t equals = line.find('='); + if (equals == std::string::npos) { + continue; + } + const std::string key = StringUtil::trim(line.substr(0, equals)); + const std::string value = StringUtil::trim(line.substr(equals + 1)); + if (key == "editor") { + editor_ = value; + } + } +} + +void Config::save() const { + std::error_code error; + fs::create_directories(directory_, error); + std::ofstream out(fs::path(directory_) / "config", std::ios::trunc); + if (!out) { + throw std::runtime_error("Cannot write the config file in " + directory_); + } + out << "editor=" << editor_ << "\n"; +} + +const std::string& Config::editor() const { + return editor_; +} + +void Config::setEditor(const std::string& command) { + editor_ = command; +} + +std::string Config::historyPath() const { + return (fs::path(directory_) / "history").string(); +} diff --git a/src/Database.cpp b/src/Database.cpp new file mode 100644 index 0000000..6b9b745 --- /dev/null +++ b/src/Database.cpp @@ -0,0 +1,294 @@ +#include "Database.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +const char* const kSchema = + "CREATE TABLE IF NOT EXISTS Account (" + " id TEXT NOT NULL PRIMARY KEY COLLATE NOCASE" + ");" + "CREATE TABLE IF NOT EXISTS Task (" + " id TEXT NOT NULL PRIMARY KEY COLLATE NOCASE," + " account TEXT COLLATE NOCASE REFERENCES Account(id) ON UPDATE CASCADE ON DELETE SET NULL," + " status TEXT NOT NULL DEFAULT 'prompt'," + " notes TEXT NOT NULL DEFAULT ''" + ");" + "CREATE INDEX IF NOT EXISTS idx_task_account ON Task(account);"; + +using Connection = std::unique_ptr; + +Connection openFile(const std::string& path, int flags) { + sqlite3* raw = nullptr; + int rc = sqlite3_open_v2(path.c_str(), &raw, flags, nullptr); + Connection connection(raw, &sqlite3_close); + if (rc != SQLITE_OK) { + throw std::runtime_error("Cannot open '" + path + "': " + + (raw ? sqlite3_errmsg(raw) : "out of memory")); + } + return connection; +} + +void copyDatabase(sqlite3* from, sqlite3* to) { + sqlite3_backup* backup = sqlite3_backup_init(to, "main", from, "main"); + if (!backup) { + throw std::runtime_error(std::string("Copy failed: ") + sqlite3_errmsg(to)); + } + int rc = sqlite3_backup_step(backup, -1); + sqlite3_backup_finish(backup); + if (rc != SQLITE_DONE) { + throw std::runtime_error(std::string("Copy failed: ") + sqlite3_errmsg(to)); + } +} + +// True if the connection holds a database with our two tables. +bool hasExpectedTables(sqlite3* db) { + sqlite3_stmt* stmt = nullptr; + const char* sql = "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN ('Task', 'Account')"; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) { + return false; + } + int count = 0; + if (sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + } + sqlite3_finalize(stmt); + return count == 2; +} + +std::string timestampedName(const std::string& basePath) { + std::time_t now = std::time(nullptr); + std::tm parts{}; +#ifdef _WIN32 + localtime_s(&parts, &now); +#else + localtime_r(&now, &parts); +#endif + std::ostringstream out; + out << basePath << "." << std::put_time(&parts, "%Y%m%d-%H%M%S") << ".bak"; + return out.str(); +} + +} // namespace + +Database::Database() : db_(nullptr), dirty_(false) { + if (sqlite3_open(":memory:", &db_) != SQLITE_OK) { + std::string message = db_ ? sqlite3_errmsg(db_) : "out of memory"; + sqlite3_close(db_); + db_ = nullptr; + throw std::runtime_error("Cannot create in-memory database: " + message); + } + exec("PRAGMA foreign_keys = ON;"); + createSchema(); +} + +Database::~Database() { + sqlite3_close(db_); +} + +void Database::createSchema() { + exec(kSchema); + // "available" was split into "prompt" and "limit-reset"; old files still say "available". + exec("UPDATE Task SET status = 'prompt' WHERE status = 'available';"); +} + +void Database::exec(const std::string& sql) { + char* error = nullptr; + if (sqlite3_exec(db_, sql.c_str(), nullptr, nullptr, &error) != SQLITE_OK) { + std::string message = error ? error : "unknown error"; + sqlite3_free(error); + throw std::runtime_error(message); + } +} + +std::vector Database::query(const std::string& sql, const std::vector& params) const { + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) { + throw std::runtime_error(sqlite3_errmsg(db_)); + } + + for (std::size_t i = 0; i < params.size(); ++i) { + int index = static_cast(i) + 1; + if (params[i]) { + sqlite3_bind_text64(stmt, index, params[i]->data(), params[i]->size(), SQLITE_TRANSIENT, SQLITE_UTF8); + } else { + sqlite3_bind_null(stmt, index); + } + } + + std::vector rows; + int rc = SQLITE_OK; + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + Row row; + int columns = sqlite3_column_count(stmt); + for (int c = 0; c < columns; ++c) { + if (sqlite3_column_type(stmt, c) == SQLITE_NULL) { + row.emplace_back(std::nullopt); + } else { + const unsigned char* text = sqlite3_column_text(stmt, c); + const int bytes = sqlite3_column_bytes(stmt, c); + row.emplace_back(std::string(reinterpret_cast(text), static_cast(bytes))); + } + } + rows.push_back(row); + } + + std::string error = (rc == SQLITE_DONE) ? "" : sqlite3_errmsg(db_); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + throw std::runtime_error(error); + } + return rows; +} + +void Database::run(const std::string& sql, const std::vector& params) { + query(sql, params); + dirty_ = true; +} + +void Database::open(const std::string& path) { + path_ = path; + dirty_ = false; + + std::FILE* probe = std::fopen(path.c_str(), "rb"); + if (!probe) { + return; // new database, will be created on first save + } + std::fclose(probe); + + restore(path); + dirty_ = false; +} + +void Database::save() { + if (path_.empty()) { + throw std::runtime_error("No database file set."); + } + Connection file = openFile(path_, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE); + copyDatabase(db_, file.get()); + dirty_ = false; +} + +std::string Database::backup(const std::string& fileName) { + std::string target = fileName.empty() ? timestampedName(path_.empty() ? "tasks.db" : path_) : fileName; + Connection file = openFile(target, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE); + copyDatabase(db_, file.get()); + return target; +} + +void Database::restore(const std::string& fileName) { + Connection file = openFile(fileName, SQLITE_OPEN_READONLY); + if (!hasExpectedTables(file.get())) { + throw std::runtime_error("'" + fileName + "' is not a task database."); + } + copyDatabase(file.get(), db_); + exec("PRAGMA foreign_keys = ON;"); + createSchema(); + dirty_ = true; +} + +bool Database::dirty() const { + return dirty_; +} + +const std::string& Database::path() const { + return path_; +} + +// ---- Accounts --------------------------------------------------------------- + +std::optional Database::findAccount(const std::string& name) const { + std::vector rows = query("SELECT id FROM Account WHERE id = ?1", {name}); + if (rows.empty()) { + return std::nullopt; + } + return rows[0][0]; +} + +std::vector Database::listAccounts() const { + std::vector names; + for (const Row& row : query("SELECT id FROM Account ORDER BY id")) { + names.push_back(*row[0]); + } + return names; +} + +void Database::addAccount(const std::string& name) { + run("INSERT INTO Account (id) VALUES (?1)", {name}); +} + +void Database::removeAccount(const std::string& name) { + run("DELETE FROM Account WHERE id = ?1", {name}); +} + +void Database::renameAccount(const std::string& name, const std::string& newName) { + run("UPDATE Account SET id = ?2 WHERE id = ?1", {name, newName}); +} + +// ---- Tasks ------------------------------------------------------------------ + +std::optional Database::findTask(const std::string& name) const { + std::vector rows = query("SELECT id FROM Task WHERE id = ?1", {name}); + if (rows.empty()) { + return std::nullopt; + } + return rows[0][0]; +} + +std::optional Database::getTask(const std::string& name) const { + std::vector rows = query("SELECT id, account, status, notes FROM Task WHERE id = ?1", {name}); + if (rows.empty()) { + return std::nullopt; + } + TaskRow task; + task.id = *rows[0][0]; + task.account = rows[0][1]; + task.status = *rows[0][2]; + task.notes = *rows[0][3]; + return task; +} + +std::vector Database::listTasks() const { + std::vector tasks; + for (const Row& row : query("SELECT id, account, status, notes FROM Task ORDER BY id")) { + TaskRow task; + task.id = *row[0]; + task.account = row[1]; + task.status = *row[2]; + task.notes = *row[3]; + tasks.push_back(task); + } + return tasks; +} + +void Database::addTask(const std::string& name, const std::optional& account, + const std::string& status) { + run("INSERT INTO Task (id, account, status) VALUES (?1, ?2, ?3)", {name, account, status}); +} + +void Database::removeTask(const std::string& name) { + run("DELETE FROM Task WHERE id = ?1", {name}); +} + +void Database::renameTask(const std::string& name, const std::string& newName) { + run("UPDATE Task SET id = ?2 WHERE id = ?1", {name, newName}); +} + +void Database::setTaskAccount(const std::string& name, const std::optional& account) { + run("UPDATE Task SET account = ?2 WHERE id = ?1", {name, account}); +} + +void Database::setTaskStatus(const std::string& name, const std::string& status) { + run("UPDATE Task SET status = ?2 WHERE id = ?1", {name, status}); +} + +void Database::setTaskNotes(const std::string& name, const std::string& notes) { + run("UPDATE Task SET notes = ?2 WHERE id = ?1", {name, notes}); +} diff --git a/src/Display.cpp b/src/Display.cpp new file mode 100644 index 0000000..993d97c --- /dev/null +++ b/src/Display.cpp @@ -0,0 +1,98 @@ +#include "Display.h" + +#include +#include +#include + +#include "StringUtil.h" +#include "Table.h" + +namespace { + +struct TaskView { + std::string name; + std::string account; // empty = unassigned + std::string notes; + Status status; // effective status at render time +}; + +std::vector loadViews(const Database& db, std::time_t now) { + std::vector views; + for (const TaskRow& row : db.listTasks()) { + TaskView view; + view.name = row.id; + view.account = row.account.value_or(""); + view.notes = row.notes; + std::optional stored = StatusUtil::deserialize(row.status); + view.status = StatusUtil::effective(stored.value_or(StatusUtil::make(StatusKind::Prompt)), now); + views.push_back(view); + } + return views; +} + +bool taskLess(const TaskView& a, const TaskView& b) { + int rankA = StatusUtil::rank(a.status.kind); + int rankB = StatusUtil::rank(b.status.kind); + if (rankA != rankB) { + return rankA < rankB; + } + if (a.status.kind == StatusKind::Wait && a.status.waitUntil != b.status.waitUntil) { + return a.status.waitUntil < b.status.waitUntil; + } + return StringUtil::lower(a.name) < StringUtil::lower(b.name); +} + +} // namespace + +Display::Display(const Database& db) : db_(db) {} + +DisplaySettings& Display::settings() { + return settings_; +} + +const DisplaySettings& Display::settings() const { + return settings_; +} + +std::string Display::renderTasks(std::time_t now) const { + std::vector views = loadViews(db_, now); + std::sort(views.begin(), views.end(), taskLess); + + TextTable table({"TASK", "ACCOUNT", "STATUS", "NOTES"}); + for (const TaskView& view : views) { + if (settings_.hiddenTaskLevels.count(view.status.kind) > 0) { + continue; + } + table.addRow({view.name, + view.account.empty() ? "-" : view.account, + StatusUtil::label(view.status), + view.notes.empty() ? "-" : StringUtil::singleLine(view.notes, 0)}); + } + return table.render(); +} + +std::string Display::renderAccounts(std::time_t now) const { + std::vector views = loadViews(db_, now); + std::sort(views.begin(), views.end(), taskLess); + + // One row per account. It is Busy while any of its tasks is Testing or + // Wait (those tasks are listed); otherwise it is Free. + TextTable table({"ACCOUNT", "STATUS", "TASKS"}); + for (const std::string& account : db_.listAccounts()) { + std::vector busyTasks; + for (const TaskView& view : views) { + if (view.account == account && StatusUtil::isBusy(view.status.kind)) { + busyTasks.push_back(view.name); + } + } + + const AccountLevel level = busyTasks.empty() ? AccountLevel::Free : AccountLevel::Busy; + if (settings_.hiddenAccountLevels.count(level) > 0) { + continue; + } + table.addRow({account, + level == AccountLevel::Busy ? "Busy" : "Free", + busyTasks.empty() ? "-" : StringUtil::join(busyTasks, 0, ", ")}); + } + return table.render(); +} diff --git a/src/ExternalEditor.cpp b/src/ExternalEditor.cpp new file mode 100644 index 0000000..68574f0 --- /dev/null +++ b/src/ExternalEditor.cpp @@ -0,0 +1,170 @@ +#include "ExternalEditor.h" + +#include +#include +#include +#include +#include +#include + +#if defined(__unix__) || defined(__APPLE__) +#include +#endif + +namespace fs = std::filesystem; + +namespace { + +#ifdef _WIN32 +const char kPathSeparator = ';'; +#else +const char kPathSeparator = ':'; +#endif + +bool isExecutableFile(const fs::path& path) { + std::error_code error; + if (!fs::is_regular_file(path, error)) { + return false; + } +#if defined(__unix__) || defined(__APPLE__) + return access(path.c_str(), X_OK) == 0; +#else + return true; +#endif +} + +bool findInPath(const std::string& program) { + if (program.find('/') != std::string::npos || program.find('\\') != std::string::npos) { + return isExecutableFile(program); + } + + const char* pathVariable = std::getenv("PATH"); + if (!pathVariable) { + return false; + } + + std::stringstream paths(pathVariable); + std::string directory; + while (std::getline(paths, directory, kPathSeparator)) { + if (directory.empty()) { + continue; + } + if (isExecutableFile(fs::path(directory) / program)) { + return true; + } +#ifdef _WIN32 + if (isExecutableFile(fs::path(directory) / (program + ".exe"))) { + return true; + } +#endif + } + return false; +} + +std::string firstWord(const std::string& command) { + std::istringstream in(command); + std::string word; + in >> word; + return word; +} + +std::string quotePath(const std::string& path) { +#ifdef _WIN32 + return "\"" + path + "\""; +#else + std::string quoted = "'"; + for (char c : path) { + if (c == '\'') { + quoted += "'\\''"; + } else { + quoted.push_back(c); + } + } + return quoted + "'"; +#endif +} + +std::string makeTempFile(const std::string& content) { +#if defined(__unix__) || defined(__APPLE__) + std::string pattern = (fs::temp_directory_path() / "task-tracker-XXXXXX.txt").string(); + std::vector buffer(pattern.begin(), pattern.end()); + buffer.push_back('\0'); + + int fd = mkstemps(buffer.data(), 4); + if (fd < 0) { + throw std::runtime_error("Cannot create a temporary file for the editor."); + } + std::size_t written = 0; + while (written < content.size()) { + ssize_t n = ::write(fd, content.data() + written, content.size() - written); + if (n <= 0) { + ::close(fd); + throw std::runtime_error("Cannot write the temporary file for the editor."); + } + written += static_cast(n); + } + ::close(fd); + return std::string(buffer.data()); +#else + fs::path path = fs::temp_directory_path() / ("task-tracker-" + std::to_string(std::rand()) + ".txt"); + std::ofstream out(path, std::ios::binary); + out << content; + if (!out) { + throw std::runtime_error("Cannot write the temporary file for the editor."); + } + return path.string(); +#endif +} + +} // namespace + +namespace ExternalEditor { + +std::string defaultCommand() { + for (const char* variable : {"VISUAL", "EDITOR"}) { + const char* value = std::getenv(variable); + if (value && *value) { + return value; + } + } + for (const char* candidate : {"nano", "vim", "vi"}) { + if (findInPath(candidate)) { + return candidate; + } + } + return ""; +} + +bool commandExists(const std::string& command) { + const std::string program = firstWord(command); + return !program.empty() && findInPath(program); +} + +std::vector installedEditors() { + std::vector found; + for (const char* candidate : {"nano", "vim", "nvim", "vi", "emacs", "micro", "helix", "hx", "code", "kate", "gedit"}) { + if (findInPath(candidate)) { + found.push_back(candidate); + } + } + return found; +} + +std::string edit(const std::string& command, const std::string& initialText) { + const std::string path = makeTempFile(initialText); + const std::string commandLine = command + " " + quotePath(path); + const int status = std::system(commandLine.c_str()); + + std::ifstream in(path, std::ios::binary); + std::string content((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + in.close(); + std::error_code error; + fs::remove(path, error); + + if (status != 0) { + throw std::runtime_error("The editor exited with an error; nothing was changed."); + } + return content; +} + +} // namespace ExternalEditor diff --git a/src/LineEditor.cpp b/src/LineEditor.cpp new file mode 100644 index 0000000..04fb0be --- /dev/null +++ b/src/LineEditor.cpp @@ -0,0 +1,367 @@ +#include "LineEditor.h" + +#include +#include +#include + +#include "StringUtil.h" +#include "Terminal.h" + +#if defined(__unix__) || defined(__APPLE__) +#define TT_HAVE_TERMIOS 1 +#include +#include +#include + +#include +#endif + +namespace { + +const std::size_t kMaxHistory = 500; + +#ifdef TT_HAVE_TERMIOS + +// Puts the terminal into raw-ish mode and restores it on destruction. +class RawMode { +public: + RawMode(); + ~RawMode(); + bool active() const; + +private: + termios saved_; + bool active_; +}; + +RawMode::RawMode() : saved_(), active_(false) { + if (tcgetattr(STDIN_FILENO, &saved_) != 0) { + return; + } + termios raw = saved_; + raw.c_lflag &= static_cast(~(ICANON | ECHO | ISIG | IEXTEN)); + raw.c_cc[VMIN] = 1; + raw.c_cc[VTIME] = 0; + active_ = (tcsetattr(STDIN_FILENO, TCSADRAIN, &raw) == 0); +} + +RawMode::~RawMode() { + if (active_) { + tcsetattr(STDIN_FILENO, TCSADRAIN, &saved_); + } +} + +bool RawMode::active() const { + return active_; +} + +int readByte() { + unsigned char c = 0; + for (;;) { + ssize_t n = ::read(STDIN_FILENO, &c, 1); + if (n == 1) { + return c; + } + if (n == 0 || errno != EINTR) { + return -1; + } + } +} + +bool byteAvailable(int milliseconds) { + pollfd descriptor; + descriptor.fd = STDIN_FILENO; + descriptor.events = POLLIN; + descriptor.revents = 0; + return poll(&descriptor, 1, milliseconds) > 0; +} + +std::size_t previousChar(const std::string& text, std::size_t position) { + if (position == 0) { + return 0; + } + --position; + while (position > 0 && (static_cast(text[position]) & 0xC0) == 0x80) { + --position; + } + return position; +} + +std::size_t nextChar(const std::string& text, std::size_t position) { + if (position >= text.size()) { + return text.size(); + } + ++position; + while (position < text.size() && (static_cast(text[position]) & 0xC0) == 0x80) { + ++position; + } + return position; +} + +std::size_t previousWord(const std::string& text, std::size_t position) { + while (position > 0 && std::isspace(static_cast(text[position - 1]))) { + --position; + } + while (position > 0 && !std::isspace(static_cast(text[position - 1]))) { + --position; + } + return position; +} + +std::size_t nextWord(const std::string& text, std::size_t position) { + while (position < text.size() && std::isspace(static_cast(text[position]))) { + ++position; + } + while (position < text.size() && !std::isspace(static_cast(text[position]))) { + ++position; + } + return position; +} + +void redraw(const std::string& prompt, const std::string& buffer, std::size_t cursor) { + std::string out = "\r" + prompt + buffer + "\x1b[K"; + const std::size_t back = StringUtil::displayWidth(buffer.substr(cursor)); + if (back > 0) { + out += "\x1b[" + std::to_string(back) + "D"; + } + std::cout << out << std::flush; +} + +std::string commonPrefix(const std::vector& values) { + std::string prefix = values.front(); + for (const std::string& value : values) { + std::size_t n = 0; + while (n < prefix.size() && n < value.size() && prefix[n] == value[n]) { + ++n; + } + prefix.resize(n); + } + // never cut a UTF-8 character in half + while (!prefix.empty() && prefix.size() < values.front().size() && + (static_cast(values.front()[prefix.size()]) & 0xC0) == 0x80) { + prefix.pop_back(); + } + return prefix; +} + +void printCandidates(const std::vector& candidates) { + const std::size_t lineWidth = 78; + std::string line; + std::cout << "\n"; + for (const std::string& candidate : candidates) { + if (!line.empty() && StringUtil::displayWidth(line) + 2 + StringUtil::displayWidth(candidate) > lineWidth) { + std::cout << line << "\n"; + line.clear(); + } + line += line.empty() ? candidate : " " + candidate; + } + std::cout << line << "\n"; +} + +#endif // TT_HAVE_TERMIOS + +} // namespace + +LineEditor::LineEditor() = default; + +void LineEditor::setCompleter(const CompletionFn& completer) { + completer_ = completer; +} + +void LineEditor::setHistoryFile(const std::string& path) { + historyFile_ = path; + history_.clear(); + + std::ifstream in(path); + std::string line; + while (std::getline(in, line)) { + if (!line.empty()) { + history_.push_back(line); + } + } + in.close(); + + if (history_.size() > kMaxHistory) { + history_.erase(history_.begin(), history_.end() - static_cast(kMaxHistory)); + std::ofstream out(path, std::ios::trunc); + for (const std::string& entry : history_) { + out << entry << "\n"; + } + } +} + +void LineEditor::addHistory(const std::string& line) { + if (StringUtil::trim(line).empty() || (!history_.empty() && history_.back() == line)) { + return; + } + history_.push_back(line); + if (history_.size() > kMaxHistory) { + history_.erase(history_.begin()); + } + if (!historyFile_.empty()) { + std::ofstream out(historyFile_, std::ios::app); + out << line << "\n"; + } +} + +bool LineEditor::readLine(const std::string& prompt, std::string& line) { +#ifdef TT_HAVE_TERMIOS + if (Terminal::stdinIsInteractive() && Terminal::stdoutIsInteractive()) { + return readInteractive(prompt, line); + } +#endif + return readPlain(prompt, line); +} + +bool LineEditor::readPlain(const std::string& prompt, std::string& line) { + std::cout << prompt << std::flush; + return static_cast(std::getline(std::cin, line)); +} + +#ifdef TT_HAVE_TERMIOS + +bool LineEditor::readInteractive(const std::string& prompt, std::string& line) { + RawMode raw; + if (!raw.active()) { + return readPlain(prompt, line); + } + + std::string buffer; + std::size_t cursor = 0; + std::size_t historyIndex = history_.size(); + std::string pending; // the line being typed while browsing history + + redraw(prompt, buffer, cursor); + + for (;;) { + const int c = readByte(); + if (c < 0) { + std::cout << "\n"; + return false; + } + + if (c == '\n' || c == '\r') { + std::cout << "\n" << std::flush; + line = buffer; + addHistory(buffer); + return true; + } + + if (c == 4) { // Ctrl-D + if (buffer.empty()) { + std::cout << "\n"; + return false; + } + buffer.erase(cursor, nextChar(buffer, cursor) - cursor); + } else if (c == 3) { // Ctrl-C + std::cout << "^C\n" << std::flush; + line.clear(); + return true; + } else if (c == 1) { // Ctrl-A + cursor = 0; + } else if (c == 5) { // Ctrl-E + cursor = buffer.size(); + } else if (c == 2) { // Ctrl-B + cursor = previousChar(buffer, cursor); + } else if (c == 6) { // Ctrl-F + cursor = nextChar(buffer, cursor); + } else if (c == 11) { // Ctrl-K + buffer.erase(cursor); + } else if (c == 21) { // Ctrl-U + buffer.erase(0, cursor); + cursor = 0; + } else if (c == 23) { // Ctrl-W + const std::size_t start = previousWord(buffer, cursor); + buffer.erase(start, cursor - start); + cursor = start; + } else if (c == 12) { // Ctrl-L + Terminal::clearScreen(); + } else if (c == 127 || c == 8) { // Backspace + const std::size_t start = previousChar(buffer, cursor); + buffer.erase(start, cursor - start); + cursor = start; + } else if (c == 9) { // Tab + if (!completer_) { + continue; + } + const Completion completion = completer_(buffer.substr(0, cursor)); + if (completion.candidates.empty()) { + std::cout << "\a" << std::flush; + continue; + } + + const std::string word = buffer.substr(completion.start, cursor - completion.start); + std::string replacement = commonPrefix(completion.candidates); + bool showList = false; + if (completion.candidates.size() == 1) { + if (replacement.empty() || replacement.back() != '/') { + replacement += ' '; + } + } else if (replacement.size() <= word.size()) { + showList = true; + } + + buffer.replace(completion.start, cursor - completion.start, replacement); + cursor = completion.start + replacement.size(); + if (showList) { + printCandidates(completion.candidates); + } + } else if (c == 27) { // Escape sequences (arrows, home, end, delete...) + if (!byteAvailable(30)) { + continue; + } + const int introducer = readByte(); + if (introducer != '[' && introducer != 'O') { + continue; + } + + std::string parameters; + int lastByte = readByte(); + while (lastByte >= 0 && (std::isdigit(lastByte) || lastByte == ';')) { + parameters.push_back(static_cast(lastByte)); + lastByte = readByte(); + } + + const bool withModifier = parameters.find(';') != std::string::npos; + if (lastByte == '~') { + if (parameters == "3") { + buffer.erase(cursor, nextChar(buffer, cursor) - cursor); + } else if (parameters == "1" || parameters == "7") { + cursor = 0; + } else if (parameters == "4" || parameters == "8") { + cursor = buffer.size(); + } + } else if (lastByte == 'A') { // Up + if (historyIndex > 0) { + if (historyIndex == history_.size()) { + pending = buffer; + } + --historyIndex; + buffer = history_[historyIndex]; + cursor = buffer.size(); + } + } else if (lastByte == 'B') { // Down + if (historyIndex < history_.size()) { + ++historyIndex; + buffer = (historyIndex == history_.size()) ? pending : history_[historyIndex]; + cursor = buffer.size(); + } + } else if (lastByte == 'C') { // Right + cursor = withModifier ? nextWord(buffer, cursor) : nextChar(buffer, cursor); + } else if (lastByte == 'D') { // Left + cursor = withModifier ? previousWord(buffer, cursor) : previousChar(buffer, cursor); + } else if (lastByte == 'H') { + cursor = 0; + } else if (lastByte == 'F') { + cursor = buffer.size(); + } + } else if (c >= 32) { // Printable (including UTF-8 bytes) + buffer.insert(cursor, 1, static_cast(c)); + ++cursor; + } + + redraw(prompt, buffer, cursor); + } +} + +#endif // TT_HAVE_TERMIOS diff --git a/src/Repl.cpp b/src/Repl.cpp new file mode 100644 index 0000000..7f3c365 --- /dev/null +++ b/src/Repl.cpp @@ -0,0 +1,570 @@ +#include "Repl.h" + +#include +#include +#include +#include +#include +#include + +#include "ExternalEditor.h" +#include "StringUtil.h" +#include "Terminal.h" +#include "Tokenizer.h" + +namespace { + +const std::size_t kUnlimited = std::numeric_limits::max(); + +const char* const kStatusHelp = + "prompt, limit-reset, testing, wait