diff --git a/CMakeLists.txt b/CMakeLists.txt index 83061e36..2a2b2c74 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,3 +53,4 @@ add_subdirectory(tests) add_subdirectory(shell) add_subdirectory(wind) add_subdirectory(terminal) +add_subdirectory(editor) diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt new file mode 100644 index 00000000..161931d6 --- /dev/null +++ b/editor/CMakeLists.txt @@ -0,0 +1,12 @@ +set(SOURCES + main.cpp + EditorWidget.h + EditorWidget.cpp +) + +add_executable(editor ${SOURCES}) +target_compile_options(editor PRIVATE -Os ${COMMON_FLAGS} -Wno-write-strings) +add_dependencies(editor libc) +target_include_directories(editor PRIVATE ${LUNA_BASE}/usr/include ${CMAKE_CURRENT_LIST_DIR}) +target_link_libraries(editor PRIVATE os ui) +install(TARGETS editor DESTINATION ${LUNA_BASE}/usr/bin) diff --git a/editor/EditorWidget.cpp b/editor/EditorWidget.cpp new file mode 100644 index 00000000..901c3058 --- /dev/null +++ b/editor/EditorWidget.cpp @@ -0,0 +1,251 @@ +/** + * @file EditorWidget.cpp + * @author apio (cloudapio.eu) + * @brief Multiline text editing widget. + * + * @copyright Copyright (c) 2024, the Luna authors. + * + */ + +#include "EditorWidget.h" +#include +#include +#include +#include +#include +#include + +EditorWidget::EditorWidget(SharedPtr font) : ui::TextInput(), m_font(font) +{ + recalculate_lines(); +} + +Result EditorWidget::load_file(const os::Path& path) +{ + struct stat st; + auto rc = os::FileSystem::stat(path, st, true); + + if (!rc.has_error() && !S_ISREG(st.st_mode)) + { + os::eprintln("editor: not loading %s as it is not a regular file", path.name().chars()); + return {}; + } + + os::eprintln("Loading file: %s", path.name().chars()); + + auto file = TRY(os::File::open_or_create(path, os::File::ReadOnly)); + + m_data = TRY(file->read_all()); + + os::eprintln("Read %zu bytes.", m_data.size()); + + m_cursor = m_data.size(); + + m_path = path; + + auto basename = TRY(PathParser::basename(m_path.name())); + + String title = TRY(String::format("Text Editor - %s"_sv, basename.chars())); + window()->set_title(title.view()); + + TRY(recalculate_lines()); + + return {}; +} + +Result EditorWidget::handle_key_event(const ui::KeyEventRequest& request) +{ + // Avoid handling "key released" events + if (!request.pressed) return ui::EventResult::DidNotHandle; + + if (request.code == moon::K_UpArrow) + { + if (m_cursor_position.y > 0) m_cursor_position.y--; + else + return ui::EventResult::DidNotHandle; + recalculate_cursor_index(); + update_cursor(); + return ui::EventResult::DidHandle; + } + + if (request.code == moon::K_DownArrow) + { + if (m_cursor_position.y + 1 < (int)m_lines.size()) m_cursor_position.y++; + else + return ui::EventResult::DidNotHandle; + recalculate_cursor_index(); + update_cursor(); + return ui::EventResult::DidHandle; + } + + if (request.code == moon::K_LeftArrow) + { + if (m_cursor > 0) m_cursor--; + else + return ui::EventResult::DidNotHandle; + recalculate_cursor_position(); + update_cursor(); + return ui::EventResult::DidHandle; + } + + if (request.code == moon::K_RightArrow) + { + if (m_cursor < m_data.size()) m_cursor++; + else + return ui::EventResult::DidNotHandle; + recalculate_cursor_position(); + update_cursor(); + return ui::EventResult::DidHandle; + } + + if (request.modifiers & ui::Mod_Ctrl) + { + switch (request.key) + { + case 's': { + auto result = save_file(); + if (result.has_error()) + { + os::eprintln("editor: failed to save file: %s", result.error_string()); + return ui::EventResult::DidNotHandle; + } + os::println("editor: buffer saved to %s successfully", m_path.name().chars()); + return ui::EventResult::DidNotHandle; + } + default: return ui::EventResult::DidNotHandle; + } + } + + if (request.code == moon::K_Backspace) + { + if (m_cursor == 0) return ui::EventResult::DidNotHandle; + m_cursor--; + + delete_current_character(); + + TRY(recalculate_lines()); + + update_cursor(); + + return ui::EventResult::DidHandle; + } + + if (request.letter != '\n' && iscntrl(request.letter)) return ui::EventResult::DidNotHandle; + + if (m_cursor == m_data.size()) TRY(m_data.append_data((const u8*)&request.letter, 1)); + else + TRY(insert_character(request.letter)); + + m_cursor++; + TRY(recalculate_lines()); + + update_cursor(); + + return ui::EventResult::DidHandle; +} + +Result EditorWidget::save_file() +{ + if (m_path.is_empty_path()) + { + os::eprintln("editor: no file to save buffer to!"); + return err(ENOENT); + } + + auto file = TRY(os::File::open(m_path, os::File::WriteOnly)); + return file->write(m_data); +} + +Result EditorWidget::draw(ui::Canvas& canvas) +{ + int visible_lines = canvas.height / m_font->height(); + int visible_columns = canvas.width / m_font->width(); + + if ((usize)visible_lines > m_lines.size()) visible_lines = static_cast(m_lines.size()); + + for (int i = 0; i < visible_lines; i++) + { + auto line = m_lines[i]; + if (line.begin == line.end) continue; + + auto slice = TRY(m_data.slice(line.begin, line.end - line.begin)); + auto string = TRY( + String::from_string_view(StringView::from_fixed_size_cstring((const char*)slice, line.end - line.begin))); + + Utf8StringDecoder decoder(string.chars()); + wchar_t buf[4096]; + decoder.decode(buf, sizeof(buf)).release_value(); + + int characters_to_render = (int)wcslen(buf); + + for (int j = 0; j < visible_columns && j < characters_to_render; j++) + { + auto subcanvas = + canvas.subcanvas({ j * m_font->width(), i * m_font->height(), m_font->width(), m_font->height() }); + m_font->render(buf[j], ui::WHITE, subcanvas); + } + } + + // Draw the cursor + if (m_cursor_position.x < visible_columns && m_cursor_position.y < visible_lines && m_cursor_activated) + { + canvas + .subcanvas( + { m_cursor_position.x * m_font->width(), m_cursor_position.y * m_font->height(), 1, m_font->height() }) + .fill(ui::WHITE); + } + + return {}; +} + +Result EditorWidget::recalculate_lines() +{ + m_lines.clear(); + + Line l; + l.begin = 0; + for (usize i = 0; i < m_data.size(); i++) + { + if (m_data.data()[i] == '\n') + { + l.end = i; + TRY(m_lines.try_append(l)); + l.begin = i + 1; + } + } + + l.end = m_data.size(); + TRY(m_lines.try_append(l)); + + recalculate_cursor_position(); + + return {}; +} + +void EditorWidget::recalculate_cursor_position() +{ + if (m_cursor == 0) m_cursor_position = { 0, 0 }; + + for (int i = 0; i < (int)m_lines.size(); i++) + { + auto line = m_lines[i]; + if (m_cursor >= line.begin && m_cursor <= line.end) + { + m_cursor_position.x = (int)(m_cursor - line.begin); + m_cursor_position.y = i; + return; + } + } + + unreachable(); +} + +void EditorWidget::recalculate_cursor_index() +{ + m_cursor = m_lines[m_cursor_position.y].begin + m_cursor_position.x; + if (m_cursor > m_lines[m_cursor_position.y].end) + { + m_cursor = m_lines[m_cursor_position.y].end; + recalculate_cursor_position(); + } +} diff --git a/editor/EditorWidget.h b/editor/EditorWidget.h new file mode 100644 index 00000000..3a543d92 --- /dev/null +++ b/editor/EditorWidget.h @@ -0,0 +1,44 @@ +/** + * @file EditorWidget.h + * @author apio (cloudapio.eu) + * @brief Multiline text editing widget. + * + * @copyright Copyright (c) 2024, the Luna authors. + * + */ + +#include +#include +#include +#include +#include + +class EditorWidget : public ui::TextInput +{ + public: + EditorWidget(SharedPtr font); + + Result load_file(const os::Path& path); + + Result save_file(); + + Result handle_key_event(const ui::KeyEventRequest& request) override; + + Result draw(ui::Canvas& canvas) override; + + private: + SharedPtr m_font; + + struct Line + { + usize begin; + usize end; + }; + Vector m_lines; + + os::Path m_path { AT_FDCWD }; + + Result recalculate_lines(); + void recalculate_cursor_position(); + void recalculate_cursor_index(); +}; diff --git a/editor/main.cpp b/editor/main.cpp new file mode 100644 index 00000000..cca1edcf --- /dev/null +++ b/editor/main.cpp @@ -0,0 +1,39 @@ +/** + * @file main.cpp + * @author apio (cloudapio.eu) + * @brief Graphical text editor. + * + * @copyright Copyright (c) 2024, the Luna authors. + * + */ + +#include "EditorWidget.h" +#include +#include + +Result luna_main(int argc, char** argv) +{ + StringView path; + + os::ArgumentParser parser; + parser.add_description("A graphical text editor"_sv); + parser.add_system_program_info("editor"_sv); + parser.add_positional_argument(path, "path", false); + parser.parse(argc, argv); + + ui::App app; + TRY(app.init()); + + auto* window = TRY(ui::Window::create(ui::Rect { 200, 300, 600, 600 })); + window->set_background(ui::Color::from_rgb(40, 40, 40)); + window->set_title("Text Editor"); + app.set_main_window(window); + + auto* editor = TRY(make(ui::Font::default_font())); + window->set_main_widget(*editor); + if (!path.is_empty()) TRY(editor->load_file(path)); + + window->draw(); + + return app.run(); +} diff --git a/libui/CMakeLists.txt b/libui/CMakeLists.txt index 04dcfcdc..fb325b54 100644 --- a/libui/CMakeLists.txt +++ b/libui/CMakeLists.txt @@ -17,6 +17,8 @@ set(SOURCES src/Container.cpp src/Button.cpp src/Label.cpp + src/InputField.cpp + src/TextInput.cpp ) add_library(ui ${SOURCES}) diff --git a/libui/include/ui/InputField.h b/libui/include/ui/InputField.h new file mode 100644 index 00000000..bd3bab56 --- /dev/null +++ b/libui/include/ui/InputField.h @@ -0,0 +1,40 @@ +/** + * @file InputField.h + * @author apio (cloudapio.eu) + * @brief Single line text input widget. + * + * @copyright Copyright (c) 2024, the Luna authors. + * + */ + +#pragma once +#include +#include +#include + +namespace ui +{ + class InputField : public ui::TextInput + { + public: + InputField(SharedPtr font); + + Result handle_key_event(const ui::KeyEventRequest& request) override; + + Result draw(ui::Canvas& canvas) override; + + StringView data(); + + void on_submit(os::Function&& action) + { + m_on_submit_action = move(action); + m_has_on_submit_action = true; + } + + private: + SharedPtr m_font; + + os::Function m_on_submit_action; + bool m_has_on_submit_action { false }; + }; +} diff --git a/libui/include/ui/TextInput.h b/libui/include/ui/TextInput.h new file mode 100644 index 00000000..6d38b3a7 --- /dev/null +++ b/libui/include/ui/TextInput.h @@ -0,0 +1,41 @@ +/** + * @file TextInput.h + * @author apio (cloudapio.eu) + * @brief Base class for text inputs. + * + * @copyright Copyright (c) 2024, the Luna authors. + * + */ + +#pragma once +#include +#include +#include + +namespace ui +{ + class TextInput : public Widget + { + public: + TextInput(); + + virtual Result handle_key_event(const ui::KeyEventRequest& request) = 0; + + virtual Result draw(ui::Canvas& canvas) = 0; + + protected: + Buffer m_data; + + usize m_cursor { 0 }; + ui::Point m_cursor_position { 0, 0 }; + + OwnedPtr m_cursor_timer; + bool m_cursor_activated = true; + + void tick_cursor(); + void update_cursor(); + + Result delete_current_character(); + Result insert_character(char c); + }; +} diff --git a/libui/src/InputField.cpp b/libui/src/InputField.cpp new file mode 100644 index 00000000..a1e00eeb --- /dev/null +++ b/libui/src/InputField.cpp @@ -0,0 +1,112 @@ +/** + * @file InputField.cpp + * @author apio (cloudapio.eu) + * @brief Single line text input widget. + * + * @copyright Copyright (c) 2024, the Luna authors. + * + */ + +#include +#include +#include +#include +#include + +namespace ui +{ + InputField::InputField(SharedPtr font) : ui::TextInput(), m_font(font) + { + } + + Result InputField::handle_key_event(const ui::KeyEventRequest& request) + { + // Avoid handling "key released" events + if (!request.pressed) return ui::EventResult::DidNotHandle; + + if (request.code == moon::K_LeftArrow) + { + if (m_cursor > 0) m_cursor--; + else + return ui::EventResult::DidNotHandle; + update_cursor(); + return ui::EventResult::DidHandle; + } + + if (request.code == moon::K_RightArrow) + { + if (m_cursor < m_data.size()) m_cursor++; + else + return ui::EventResult::DidNotHandle; + update_cursor(); + return ui::EventResult::DidHandle; + } + + if (request.code == moon::K_Backspace) + { + if (m_cursor == 0) return ui::EventResult::DidNotHandle; + m_cursor--; + + delete_current_character(); + + update_cursor(); + + return ui::EventResult::DidHandle; + } + + if (request.letter == '\n') + { + if (m_has_on_submit_action) + { + m_on_submit_action(data()); + return ui::EventResult::DidHandle; + } + + return ui::EventResult::DidNotHandle; + } + + if (iscntrl(request.letter)) return ui::EventResult::DidNotHandle; + + if (m_cursor == m_data.size()) TRY(m_data.append_data((const u8*)&request.letter, 1)); + else + TRY(insert_character(request.letter)); + + m_cursor++; + + update_cursor(); + + return ui::EventResult::DidHandle; + } + + Result InputField::draw(ui::Canvas& canvas) + { + int visible_characters = canvas.width / m_font->width(); + + auto string = data(); + + Utf8StringDecoder decoder(string.chars()); + wchar_t buf[4096]; + decoder.decode(buf, sizeof(buf)).release_value(); + + int characters_to_render = (int)wcslen(buf); + + for (int j = 0; j < visible_characters && j < characters_to_render; j++) + { + auto subcanvas = canvas.subcanvas({ j * m_font->width(), 0, m_font->width(), m_font->height() }); + m_font->render(buf[j], ui::WHITE, subcanvas); + } + + // Draw the cursor + if ((int)m_cursor < visible_characters && m_cursor_activated) + { + canvas.subcanvas({ (int)m_cursor * m_font->width(), 0, 1, m_font->height() }).fill(ui::WHITE); + } + + return {}; + } + + StringView InputField::data() + { + return StringView { (const char*)m_data.data(), m_data.size() }; + } +} diff --git a/libui/src/TextInput.cpp b/libui/src/TextInput.cpp new file mode 100644 index 00000000..0e47a84a --- /dev/null +++ b/libui/src/TextInput.cpp @@ -0,0 +1,52 @@ +/** + * @file TextInput.cpp + * @author apio (cloudapio.eu) + * @brief Base class for text inputs. + * + * @copyright Copyright (c) 2024, the Luna authors. + * + */ + +#include +#include + +namespace ui +{ + TextInput::TextInput() : Widget() + { + m_cursor_timer = os::Timer::create_repeating(500, [this]() { this->tick_cursor(); }).release_value(); + } + + void TextInput::update_cursor() + { + m_cursor_timer->restart(); + m_cursor_activated = true; + } + + Result TextInput::delete_current_character() + { + usize size = m_data.size() - m_cursor; + u8* slice = TRY(m_data.slice(m_cursor, size)); + memmove(slice, slice + 1, size - 1); + TRY(m_data.try_resize(m_data.size() - 1)); + + return {}; + } + + Result TextInput::insert_character(char c) + { + usize size = m_data.size() - m_cursor; + u8* slice = TRY(m_data.slice(m_cursor, size + 1)); + memmove(slice + 1, slice, size); + *slice = (u8)c; + + return {}; + } + + void TextInput::tick_cursor() + { + m_cursor_activated = !m_cursor_activated; + + window()->draw(); + } +} diff --git a/terminal/TerminalWidget.cpp b/terminal/TerminalWidget.cpp index d26d9b7a..11db8b85 100644 --- a/terminal/TerminalWidget.cpp +++ b/terminal/TerminalWidget.cpp @@ -40,7 +40,7 @@ Result TerminalWidget::init(char* const* args) m_font = ui::Font::default_font(); m_bold_font = ui::Font::default_bold_font(); - m_terminal_canvas = ui::App::the().main_window()->canvas(); + m_terminal_canvas = window()->canvas(); m_terminal_canvas.fill(ui::BLACK); m_cursor_timer = TRY(os::Timer::create_repeating(500, [this]() { this->tick_cursor(); })); @@ -102,7 +102,7 @@ Result TerminalWidget::process() if (did_draw) drawn++; } - if (drawn > 0) ui::App::the().main_window()->draw(); + if (drawn > 0) window()->draw(); return {}; } @@ -117,7 +117,7 @@ void TerminalWidget::tick_cursor() else erase_current_char(); - ui::App::the().main_window()->draw(); + window()->draw(); } void TerminalWidget::draw_glyph(wchar_t c, int x, int y)