commit e480561fb4c44a2728f851ba830ccd02b8c19567 Author: Maxwell Jeffress Date: Mon Apr 27 21:38:06 2026 +1000 Initial commit diff --git a/.clangd b/.clangd new file mode 100644 index 0000000..aff1a4f --- /dev/null +++ b/.clangd @@ -0,0 +1,2 @@ +CompileFlags: + Add: [-std=c++23] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1bc5169 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dive diff --git a/README.md b/README.md new file mode 100644 index 0000000..9719ecc --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Dive + +Dive is a Vim-like text editor using ncurses. + +## Usage + +dive file.txt + +## Compiling + +fish build.fish + +## Supported actions + +Ctrl+C exits the editor anywhere. + +### Normal Mode + +* Move around with arrow keys or hjkl +* Use i to enter insert mode +* Use a to enter insert mode, moving the cursor forward +* Use o to enter insert mode, with a blank line below the currently selected one +* Use : or ; to enter command mode + +### Insert Mode + +* Use ESC to get back to Normal Mode +* Use arrow keys to move around +* Type to type things in +* Backspace to delete the character before the cursor, or to merge 2 lines when cursor is at the start of a line +* Enter to split the current line in half + +### Command Mode + +* Use ESC to get back to Normal Mode +* Type to type a command into the minibuffer +* Enter to run the command + +Commands: + +* q, quit: Quits Dive, forgetting any changes. +* w, write: Writes changes to the file provided + + +Enjoy! diff --git a/build.fish b/build.fish new file mode 100644 index 0000000..941e899 --- /dev/null +++ b/build.fish @@ -0,0 +1,3 @@ +#!/usr/bin/env fish + +g++ src/*.cpp -lncurses -o dive -ggdb -std=c++23 diff --git a/src/buffer.cpp b/src/buffer.cpp new file mode 100644 index 0000000..bd15f57 --- /dev/null +++ b/src/buffer.cpp @@ -0,0 +1,17 @@ +#include "buffer.hpp" +#include + +namespace Dive { + void Buffer::save() { + std::ofstream file(fileName); + if (!file.is_open()) { + throw std::runtime_error("Cannot open file " + fileName + " for writing"); + } + + for (const auto& line : lines) { + file << line << "\n"; + } + + file.close(); + } +} diff --git a/src/buffer.hpp b/src/buffer.hpp new file mode 100644 index 0000000..91123ca --- /dev/null +++ b/src/buffer.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace Dive { + enum class BufferType { + File + }; + + struct Buffer { + BufferType type = BufferType::File; + std::string fileName = ""; + std::vector lines = {}; + + struct { + size_t x = 0; + size_t y = 0; + } pos; + + Buffer(std::string name) : fileName(name) { + if (std::filesystem::exists(name)) { + std::ifstream file(name); + if (!file.is_open()) { + throw std::runtime_error("Cannot open file " + name); + } + std::string buf; + while (std::getline(file, buf)) { + lines.push_back(buf); + } + } + } + Buffer() = default; + + void save(); + }; +} diff --git a/src/editor.cpp b/src/editor.cpp new file mode 100644 index 0000000..0e33a0a --- /dev/null +++ b/src/editor.cpp @@ -0,0 +1,252 @@ +#include "editor.hpp" +#include "renderer.hpp" +#include +#include +#include +#include + +namespace Dive { + void Editor::handleKey(Keycode key) { + switch (state) { + case EditorState::Normal: { + handleKeyNormal(key); + break; + } + case EditorState::Insert: { + handleKeyInsert(key); + break; + } + case EditorState::Command: { + handleKeyCommand(key); + break; + } + } + } + + void Editor::handleKeyNormal(Keycode key) { + minibuf.clear(); + switch (key) { + case 'i': + { + state = EditorState::Insert; + break; + } + case 'a': + { + state = EditorState::Insert; + if (pos.x < buffer.lines[pos.y].size()) { + pos.x++; + currentLineHangover = pos.x; + } + break; + } + case 'o': + { + state = EditorState::Insert; + buffer.lines.insert(buffer.lines.begin() + pos.y + 1, ""); + pos.y++; + pos.x = 0; + currentLineHangover = 0; + break; + } + case ':': + case ';': + { + state = EditorState::Command; + break; + } + + case KEY_UP: + case 'k': + { + if (pos.y > 0) { + pos.y--; + if (pos.x > buffer.lines[pos.y].size()) { + pos.x = buffer.lines[pos.y].size(); + } else if (buffer.lines[pos.y].size() < currentLineHangover) { + pos.x = buffer.lines[pos.y].size(); + } else { + pos.x = currentLineHangover; + } + } + break; + } + + case 10: + case 13: + case KEY_ENTER: + case KEY_DOWN: + case 'j': + { + if (pos.y < buffer.lines.size() - 1) { + pos.y++; + if (pos.x > buffer.lines[pos.y].size()) { + pos.x = buffer.lines[pos.y].size(); + } else if (buffer.lines[pos.y].size() < currentLineHangover) { + pos.x = buffer.lines[pos.y].size(); + } else { + pos.x = currentLineHangover; + } + } + break; + } + + case KEY_LEFT: + case KEY_BACKSPACE: + case 'h': + { + if (pos.x > 0) { + pos.x--; + currentLineHangover = pos.x; + } + break; + } + + case KEY_RIGHT: + case 'l': + { + if (pos.x < buffer.lines[pos.y].size()) { + pos.x++; + currentLineHangover = pos.x; + } + break; + } + } + } + void Editor::handleKeyInsert(Keycode key) { + switch (key) { + case 27: // ESC + { + state = EditorState::Normal; + break; + } + case KEY_UP: + { + if (pos.y > 0) { + pos.y--; + if (pos.x > buffer.lines[pos.y].size()) { + pos.x = buffer.lines[pos.y].size(); + } else if (buffer.lines[pos.y].size() < currentLineHangover) { + pos.x = buffer.lines[pos.y].size(); + } else { + pos.x = currentLineHangover; + } + } + break; + } + + case KEY_DOWN: + { + if (pos.y < buffer.lines.size() - 1) { + pos.y++; + if (pos.x > buffer.lines[pos.y].size()) { + pos.x = buffer.lines[pos.y].size(); + } else if (buffer.lines[pos.y].size() < currentLineHangover) { + pos.x = buffer.lines[pos.y].size(); + } else { + pos.x = currentLineHangover; + } + } + break; + } + + case KEY_LEFT: + { + if (pos.x > 0) { + pos.x--; + currentLineHangover = pos.x; + } + break; + } + + case KEY_RIGHT: + { + if (pos.x < buffer.lines[pos.y].size()) { + pos.x++; + currentLineHangover = pos.x; + } + break; + } + + case KEY_BACKSPACE: + { + if (pos.x > 0) { + buffer.lines[pos.y].erase(pos.x - 1, 1); + pos.x--; + currentLineHangover = pos.x; + } else { + if (pos.y == 0) break; + + // Merge with previous line + pos.x = buffer.lines[pos.y - 1].size(); + currentLineHangover = pos.x; + buffer.lines[pos.y - 1] += buffer.lines[pos.y]; + buffer.lines.erase(buffer.lines.begin() + pos.y); + pos.y--; + } + break; + } + + case 10: + case 13: + case KEY_ENTER: + { + std::string tail = buffer.lines[pos.y].substr(pos.x); + buffer.lines[pos.y] = buffer.lines[pos.y].substr(0, pos.x); + buffer.lines.insert(buffer.lines.begin() + pos.y + 1, tail); + pos.y++; + pos.x = 0; + currentLineHangover = 0; + break; + } + + default: + { + if (key >= 32 && key < 127) { + char ch[2] = { (char)key, '\0' }; + buffer.lines[pos.y].insert(pos.x, ch); + pos.x++; + currentLineHangover = pos.x; + } + break; + } + } + } + + void Editor::handleKeyCommand(Keycode key) { + switch (key) { + case 27: // ESC + { + minibuf.clear(); + state = EditorState::Normal; + break; + } + case 10: + case 13: + case KEY_ENTER: + { + if (minibuf == "q" || minibuf == "quit") { + // do this cleaner at some point + raise(SIGINT); + } else if (minibuf == "w" || minibuf == "write") { + try { + buffer.save(); + minibuf = "Wrote to file " + buffer.fileName; + } catch (std::exception e) { + minibuf = e.what(); + } + } else { + minibuf = "Unknown command \"" + minibuf + "\""; + } + + state = EditorState::Normal; + break; + } + default: + { + minibuf += keyname(key); + break; + } + } + } +} diff --git a/src/editor.hpp b/src/editor.hpp new file mode 100644 index 0000000..875e2dd --- /dev/null +++ b/src/editor.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "buffer.hpp" +#include "renderer.hpp" + +extern std::string diveHome; + +namespace Dive { + enum class EditorState { + Normal, Insert, Command + }; + + struct Editor { + EditorState state = EditorState::Normal; + Buffer buffer; + + struct { + int x = 0; + int y = 0; + } pos; + + int currentLineHangover = 0; + std::string minibuf = ""; + + // Used to store the old position before interacting with the minibuf + struct { + int x = 0; + int y = 0; + } oldPos; + + Editor(std::string file) : buffer(Buffer(file)) {} + Editor() : buffer(Buffer(diveHome)) {} + + void handleKey(Keycode key); + + private: + void handleKeyNormal(Keycode key); + void handleKeyInsert(Keycode key); + void handleKeyCommand(Keycode key); + }; +} + diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..c16617f --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,80 @@ +#include "editor.hpp" +#include "window.hpp" + +#include +#include +#include +#include + +static Dive::Window* _global_window = nullptr; +static bool hasCaught = false; + +void exitHandle(int signum) { + + if (hasCaught == true) { + exit(1); + } + + hasCaught = true; + + if (_global_window == nullptr) { + return; + } + _global_window->exit(); + + switch (signum) { + case SIGABRT: + std::cout << "Dive has unexpectedly aborted! (abort signal)\nPlease report this issue on Chookspace (https://chookspace.com/max/dive)" << std::endl; + exit(1); + + case SIGSEGV: + std::cout << "Dive has unexpectedly aborted! (segfault signal)\nPlease report this issue on Chookspace (https://chookspace.com/max/dive)" << std::endl; + exit(1); + + case SIGINT: + exit(0); + + default: + std::cout << "Dive has unexpectedly aborted! (signal number " << signum << ")\nPlease report this issue on Chookspace (https://chookspace.com/max/dive)" << std::endl; + exit(1); + + } +} + +std::string diveHome = "/.divehome"; + +int main(int argc, char** argv) { + + signal(SIGINT, exitHandle); + signal(SIGABRT, exitHandle); + signal(SIGSEGV, exitHandle); + + const char* home = getenv("HOME"); + if (home == NULL) { + home = "/"; + } + + diveHome = home + std::string("/.divehome"); + + std::string fileName = diveHome; + + if (argc > 1) { + fileName = argv[1]; + } + + Dive::Editor editor(fileName); + if (editor.buffer.lines.empty()) { + editor.buffer.lines.push_back(""); + } + Dive::Window window(editor); + _global_window = &window; + + try { + window.show(); + } catch (std::exception e) { + window.exit(); + std::cout << "An exception occured in Dive:\n" << e.what() << "\nPlease report this issue on Chookspace (https://chookspace.com/max/dive)" << std::endl; + exit(1); + } +} + diff --git a/src/renderer.cpp b/src/renderer.cpp new file mode 100644 index 0000000..8eebded --- /dev/null +++ b/src/renderer.cpp @@ -0,0 +1,47 @@ +#include "renderer.hpp" +#include +#include + +namespace Dive { + void Renderer::init() { + if (!hasInit) { + initscr(); + cbreak(); + noecho(); + keypad(stdscr, TRUE); + set_escdelay(10); + + hasInit = true; + } + } + + void Renderer::close() { + if (hasInit) endwin(); + hasInit = false; + } + + void Renderer::display() { + if (hasInit) refresh(); + } + + void Renderer::clear() { + if (hasInit) erase(); + } + + Keycode Renderer::wait() { + if (hasInit) return getch(); + else return 0; + } + + void Renderer::putString(int x, int y, std::string str) { + if (hasInit) mvprintw(y, x, "%s", str.c_str()); + } + + void Renderer::appendString(std::string str) { + if (hasInit) printw("%s", str.c_str()); + } + + void Renderer::setPos(int x, int y) { + if (hasInit) move(y, x); + } +} diff --git a/src/renderer.hpp b/src/renderer.hpp new file mode 100644 index 0000000..c45c278 --- /dev/null +++ b/src/renderer.hpp @@ -0,0 +1,85 @@ +#pragma once + +#include +#include + +namespace Dive { + + using Keycode = int; + + /* + * Renderer struct + * Dive's abstraction over ncurses, which allows easy screen manipulation. + * + * Call Renderer().init() before using any other methods, this will set up the ncurses + * screen for you. All other methods will do nothing without the renderer being init'd. + * + * You don't have to do Renderer().close(), as the destructor will do this for you, + * unless you want to exit the window early. The Renderer will keep track of whether + * it is init'd, and can be reused after being closed by using init again. + */ + struct Renderer { + + /* + * Initialises ncurses in the terminal. When called, will wipe the terminal and + * ensure it is ready for all other functions. + * + * This method sets up ncurses in the way which Dive expects it to function, + * please do not mess with ncurses while the renderer is init'ed. + */ + void init(); + + /* + * Closes ncurses in the terminal. Use when you're done. Automatically called by + * the destructor. + */ + void close(); + + /* + * Displays anything currently in the buffer, waiting to be displayed. + */ + void display(); + + /* + * Clears the buffer, ready for new contents. + */ + void clear(); + + /* + * Waits for the user to press a key on their keyboard. + * This blocks the thread until a key is pressed. + */ + Keycode wait(); + + + /* + * Puts a string into a buffer, at the specified x and y positioning. Moves the + * cursor to the needed position. + */ + void putString(int x, int y, std::string str); + + /* + * Puts a string into the buffer at the current position of the cursor. Useful + * for status bars. + */ + void appendString(std::string str); + + /* + * Sets the position of the cursor on screen. + */ + void setPos(int x, int y); + + Renderer() = default; + ~Renderer() { + if (hasInit) close(); + } + + private: + + /* + * Ensures that the renderer always knows if ncurses is ready or not. + */ + bool hasInit = false; + + }; +} diff --git a/src/window.cpp b/src/window.cpp new file mode 100644 index 0000000..b98fa98 --- /dev/null +++ b/src/window.cpp @@ -0,0 +1,71 @@ +#include "window.hpp" +#include "editor.hpp" +#include "renderer.hpp" +#include +#include +#include + +namespace Dive { + void Window::show() { + renderer.init(); + + getmaxyx(stdscr, size.h, size.w); + + int offset = 0; + + for (;;) { + + renderer.clear(); + + if (editor.pos.y < offset) { + offset = editor.pos.y; + } else if (editor.pos.y >= offset + (size.h - 2)) { + offset = editor.pos.y - (size.h - 2) + 1; + } + + int viewLines = size.h - 2; + for (int i = 0; i < viewLines; i++) { + int lineIdx = offset + i; + if (lineIdx < (int)editor.buffer.lines.size()) { + renderer.putString(0, i, editor.buffer.lines[lineIdx]); + } else { + renderer.putString(0, i, "~"); + } + } + + // Create status bar + switch (editor.state) { + case EditorState::Normal: + renderer.putString(0, size.h - 2, "normal"); + break; + case EditorState::Insert: + renderer.putString(0, size.h - 2, "insert"); + break; + case EditorState::Command: + renderer.putString(0, size.h - 2, "command"); + break; + } + + // Add minibuf to bottom of screen + renderer.putString(0, size.h - 1, editor.minibuf); + + renderer.setPos(editor.pos.x, editor.pos.y - offset); + renderer.display(); + + Keycode key = renderer.wait(); + switch (key) { + case KEY_RESIZE: + getmaxyx(stdscr, size.h, size.w); + break; + + default: + editor.handleKey(key); + break; + } + } + } + + void Window::exit() { + renderer.close(); + } +} diff --git a/src/window.hpp b/src/window.hpp new file mode 100644 index 0000000..4194bca --- /dev/null +++ b/src/window.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "editor.hpp" +#include "renderer.hpp" + +extern std::string diveHome; + +namespace Dive { + + /* + * Window class + * Holds information about the current window. + * + * This is the main class that needs to be interacted with by the main() function. + * It handles creating a renderer, keeping track of screen size, and key presses. + */ + class Window { + + private: + Renderer renderer; + + public: + Editor editor; + + struct { + int w, h; + } size; + + int currentBottomLine; + + Window(Editor editor) : editor(editor) {} + Window() : editor(Editor(diveHome)) {} + + void show(); + void exit(); + }; +}