From 89971642032853edff5b8741464346a98276a76d Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Wed, 6 May 2026 08:03:14 +0200 Subject: [PATCH] feat: improve MTK input boxes, right-click context menus --- programs/include/gui/mtk/theme.hpp | 2 + programs/include/gui/mtk/widgets.hpp | 674 +++++++++++++++++- .../src/desktop/apps/filemanager/actions.cpp | 1 + .../src/desktop/apps/filemanager/events.cpp | 61 +- .../apps/filemanager/filemanager_internal.hpp | 27 +- .../src/desktop/apps/filemanager/render.cpp | 33 +- .../src/desktop/apps/usermanager/dialogs.cpp | 238 +++++-- programs/src/network/main.cpp | 194 +++-- programs/src/printers/main.cpp | 60 +- programs/src/texteditor/main.cpp | 116 +++ programs/src/wikipedia/main.cpp | 53 +- programs/src/wordprocessor/document.cpp | 1 + programs/src/wordprocessor/input.cpp | 99 +++ programs/src/wordprocessor/render.cpp | 17 + programs/src/wordprocessor/wordprocessor.hpp | 17 + 15 files changed, 1337 insertions(+), 256 deletions(-) diff --git a/programs/include/gui/mtk/theme.hpp b/programs/include/gui/mtk/theme.hpp index a868511..ea7a90e 100644 --- a/programs/include/gui/mtk/theme.hpp +++ b/programs/include/gui/mtk/theme.hpp @@ -44,6 +44,7 @@ struct Theme { Color accent_soft; Color accent_fg; Color selection; + Color text_selection; Color danger; Color danger_hover; @@ -85,6 +86,7 @@ inline Theme make_theme(Color accent = colors::ACCENT) { theme.accent_soft = lighten(accent, 214); theme.accent_fg = colors::WHITE; theme.selection = accent; + theme.text_selection = Color::from_rgb(0xB0, 0xD0, 0xF0); theme.danger = Color::from_rgb(0xD0, 0x3E, 0x3E); theme.danger_hover = Color::from_rgb(0xDD, 0x44, 0x44); diff --git a/programs/include/gui/mtk/widgets.hpp b/programs/include/gui/mtk/widgets.hpp index 2c9f282..2b127e0 100644 --- a/programs/include/gui/mtk/widgets.hpp +++ b/programs/include/gui/mtk/widgets.hpp @@ -6,7 +6,10 @@ #pragma once +#include + #include "gui/canvas.hpp" +#include "gui/clipboard.hpp" #include "gui/font.hpp" #include "gui/mtk/theme.hpp" @@ -26,6 +29,52 @@ struct WidgetState { bool enabled; }; +struct ContextMenuState { + bool open; + int x; + int y; + int hover; +}; + +struct ContextMenuItem { + const char* label; + int id; + bool enabled; +}; + +enum ContextMenuResult : int { + CONTEXT_MENU_NONE = -1, + CONTEXT_MENU_CONSUMED = -2, + CONTEXT_MENU_DISMISSED = -3, +}; + +static constexpr int CONTEXT_MENU_DEFAULT_W = 140; +static constexpr int CONTEXT_MENU_ITEM_H = 24; +static constexpr int CONTEXT_MENU_PAD_Y = 4; + +struct TextInputState { + int cursor; + int selection_anchor; + bool dragging; + ContextMenuState context; +}; + +using TextInputCharFilter = bool (*)(char ch, void* userdata); + +enum TextInputResult : uint8_t { + TEXT_INPUT_NONE = 0, + TEXT_INPUT_CONSUMED = 1, + TEXT_INPUT_CHANGED = 2, +}; + +enum TextInputContextItem : int { + TEXT_INPUT_CTX_CUT = 0, + TEXT_INPUT_CTX_COPY, + TEXT_INPUT_CTX_PASTE, + TEXT_INPUT_CTX_DELETE, + TEXT_INPUT_CTX_COUNT, +}; + inline WidgetState widget_state(bool active = false, bool hovered = false, bool enabled = true) { return {active, hovered, enabled}; } @@ -36,12 +85,595 @@ struct ButtonColors { Color fg; }; +inline void context_menu_reset(ContextMenuState& state) { + state.open = false; + state.x = 0; + state.y = 0; + state.hover = -1; +} + +inline void context_menu_open(ContextMenuState& state, int x, int y) { + state.open = true; + state.x = x; + state.y = y; + state.hover = -1; +} + +inline void context_menu_close(ContextMenuState& state) { + state.open = false; + state.hover = -1; +} + +inline int context_menu_height(int item_count, + int item_h = CONTEXT_MENU_ITEM_H) { + return gui_max(item_count, 0) * item_h + CONTEXT_MENU_PAD_Y * 2; +} + +inline Rect context_menu_rect(const ContextMenuState& state, + int item_count, + int view_w, + int view_h, + int menu_w = CONTEXT_MENU_DEFAULT_W, + int item_h = CONTEXT_MENU_ITEM_H) { + int menu_h = context_menu_height(item_count, item_h); + int x = state.x; + int y = state.y; + if (x + menu_w > view_w) x = view_w - menu_w; + if (y + menu_h > view_h) y = view_h - menu_h; + if (x < 0) x = 0; + if (y < 0) y = 0; + return {x, y, menu_w, menu_h}; +} + +inline int context_menu_hit_item(const ContextMenuState& state, + int item_count, + int mx, + int my, + int view_w, + int view_h, + int menu_w = CONTEXT_MENU_DEFAULT_W, + int item_h = CONTEXT_MENU_ITEM_H) { + if (!state.open || item_count <= 0) return -1; + Rect menu = context_menu_rect(state, item_count, view_w, view_h, + menu_w, item_h); + Rect items = {menu.x + 2, menu.y + CONTEXT_MENU_PAD_Y, + menu.w - 4, item_count * item_h}; + if (!items.contains(mx, my)) return -1; + return (my - items.y) / item_h; +} + +inline int context_menu_handle_mouse(ContextMenuState& state, + const ContextMenuItem* items, + int item_count, + int mx, + int my, + uint8_t buttons, + uint8_t prev_buttons, + int view_w, + int view_h, + int menu_w = CONTEXT_MENU_DEFAULT_W, + int item_h = CONTEXT_MENU_ITEM_H) { + if (!state.open) return CONTEXT_MENU_NONE; + + bool left_pressed = (buttons & 1) && !(prev_buttons & 1); + bool right_pressed = (buttons & 2) && !(prev_buttons & 2); + state.hover = context_menu_hit_item(state, item_count, mx, my, + view_w, view_h, menu_w, item_h); + + if (left_pressed) { + int hover = state.hover; + context_menu_close(state); + if (hover >= 0 && hover < item_count && items && items[hover].enabled) + return items[hover].id; + return CONTEXT_MENU_DISMISSED; + } + + if (right_pressed) { + context_menu_close(state); + return CONTEXT_MENU_DISMISSED; + } + + return CONTEXT_MENU_CONSUMED; +} + +inline int context_menu_label_height(TrueTypeFont* font = nullptr, + int font_size = 0) { + if (font && font->valid && font_size > 0) + return font->get_line_height(font_size); + return system_font_height(); +} + +inline void context_menu_draw_label(Canvas& c, + int x, + int y, + const char* label, + Color color, + TrueTypeFont* font = nullptr, + int font_size = 0) { + if (font && font->valid && font_size > 0) { + font->draw_to_buffer(c.pixels, c.w, c.h, x, y, label, color, font_size); + return; + } + c.text(x, y, label, color); +} + +inline void draw_context_menu(Canvas& c, + const ContextMenuState& state, + const ContextMenuItem* items, + int item_count, + const Theme& theme, + int menu_w = CONTEXT_MENU_DEFAULT_W, + int item_h = CONTEXT_MENU_ITEM_H, + TrueTypeFont* font = nullptr, + int font_size = 0) { + if (!state.open || !items || item_count <= 0) return; + + Rect menu = context_menu_rect(state, item_count, c.w, c.h, + menu_w, item_h); + c.fill_rect(menu.x + 2, menu.y + 2, menu.w, menu.h, + Color::from_rgb(0x80, 0x80, 0x80)); + c.fill_rounded_rect(menu.x, menu.y, menu.w, menu.h, 4, + colors::MENU_BG); + c.rect(menu.x, menu.y, menu.w, menu.h, theme.border); + + int fh = context_menu_label_height(font, font_size); + for (int i = 0; i < item_count; i++) { + Rect item = {menu.x + 2, menu.y + CONTEXT_MENU_PAD_Y + i * item_h, + menu.w - 4, item_h}; + if (state.hover == i) + c.fill_rect(item.x, item.y, item.w, item.h, colors::MENU_HOVER); + + Color label_color = items[i].enabled ? theme.text : theme.text_muted; + context_menu_draw_label(c, menu.x + 12, item.y + (item.h - fh) / 2, + items[i].label, label_color, font, font_size); + } +} + inline int text_len(const char* text) { int len = 0; while (text && text[len]) len++; return len; } +inline int text_input_len(const char* text, int cap = 0) { + int len = 0; + int limit = cap > 0 ? cap - 1 : 0x7FFFFFFF; + while (text && text[len] && len < limit) len++; + return len; +} + +inline int text_input_clamp_cursor(int cursor, int len) { + return gui_clamp(cursor, 0, gui_max(len, 0)); +} + +inline void text_input_reset(TextInputState& state, int cursor = 0) { + state.cursor = gui_max(cursor, 0); + state.selection_anchor = state.cursor; + state.dragging = false; + context_menu_reset(state.context); +} + +inline void text_input_set_cursor(TextInputState& state, int len, int cursor, bool selecting = false) { + state.cursor = text_input_clamp_cursor(cursor, len); + state.selection_anchor = selecting + ? text_input_clamp_cursor(state.selection_anchor, len) + : state.cursor; +} + +inline bool text_input_has_selection(const TextInputState& state) { + return state.cursor != state.selection_anchor; +} + +inline int text_input_selection_start(const TextInputState& state, int len) { + int cursor = text_input_clamp_cursor(state.cursor, len); + int anchor = text_input_clamp_cursor(state.selection_anchor, len); + return cursor < anchor ? cursor : anchor; +} + +inline int text_input_selection_end(const TextInputState& state, int len) { + int cursor = text_input_clamp_cursor(state.cursor, len); + int anchor = text_input_clamp_cursor(state.selection_anchor, len); + return cursor > anchor ? cursor : anchor; +} + +inline bool text_input_has_selection(const TextInputState& state, const char* text, int cap = 0) { + int len = text_input_len(text, cap); + return text_input_selection_start(state, len) != text_input_selection_end(state, len); +} + +inline int text_input_prefix_width(const char* text, int count, bool masked) { + char prefix[512]; + int len = 0; + while (text && text[len] && len < count && len < (int)sizeof(prefix) - 1) { + prefix[len] = masked ? '*' : text[len]; + len++; + } + prefix[len] = '\0'; + return text_width(prefix); +} + +inline int text_input_index_from_x(const Rect& bounds, const char* value, + int mx, bool masked = false) { + int rel_x = mx - (bounds.x + 8); + if (rel_x <= 0) return 0; + + int len = text_input_len(value); + int prev_w = 0; + for (int i = 0; i < len; i++) { + int cur_w = text_input_prefix_width(value, i + 1, masked); + int midpoint = prev_w + (cur_w - prev_w) / 2; + if (rel_x < midpoint) return i; + prev_w = cur_w; + } + return len; +} + +inline int text_input_mouse(TextInputState& state, + const Rect& bounds, + const char* value, + int mx, int my, + uint8_t buttons, + uint8_t prev_buttons, + bool focused, + bool masked = false) { + bool left_down = (buttons & 1) != 0; + bool left_pressed = (buttons & 1) && !(prev_buttons & 1); + bool left_released = !(buttons & 1) && (prev_buttons & 1); + + if (left_pressed && bounds.contains(mx, my)) { + int cursor = text_input_index_from_x(bounds, value, mx, masked); + state.cursor = cursor; + state.selection_anchor = cursor; + state.dragging = true; + return TEXT_INPUT_CONSUMED; + } + + if (focused && state.dragging && left_down) { + int cursor = text_input_index_from_x(bounds, value, mx, masked); + if (state.cursor != cursor) { + state.cursor = cursor; + return TEXT_INPUT_CONSUMED; + } + return TEXT_INPUT_CONSUMED; + } + + if (state.dragging && left_released) { + state.dragging = false; + return TEXT_INPUT_CONSUMED; + } + + return TEXT_INPUT_NONE; +} + +inline bool text_input_word_char(char ch) { + return (ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || + ch == '_'; +} + +inline int text_input_prev_word_boundary(const char* text, int cursor) { + int pos = cursor; + if (pos <= 0) return 0; + pos--; + while (pos > 0 && !text_input_word_char(text[pos])) pos--; + while (pos > 0 && text_input_word_char(text[pos - 1])) pos--; + return pos; +} + +inline int text_input_next_word_boundary(const char* text, int cursor) { + int len = text_input_len(text); + int pos = text_input_clamp_cursor(cursor, len); + if (pos >= len) return len; + while (pos < len && text_input_word_char(text[pos])) pos++; + while (pos < len && !text_input_word_char(text[pos])) pos++; + return pos; +} + +inline void text_input_delete_range(char* text, int* len, int start, int end) { + if (!text || !len || start >= end) return; + for (int i = start; i <= *len - (end - start); i++) + text[i] = text[i + (end - start)]; + *len -= (end - start); +} + +inline bool text_input_char_allowed(char ch, TextInputCharFilter filter = nullptr, + void* userdata = nullptr) { + unsigned char u = (unsigned char)ch; + if (u < 0x20 || u >= 0x7F) return false; + return filter == nullptr || filter(ch, userdata); +} + +inline bool text_input_delete_selection(TextInputState& state, char* text, int cap) { + int len = text_input_len(text, cap); + int sel_start = text_input_selection_start(state, len); + int sel_end = text_input_selection_end(state, len); + if (sel_start == sel_end) return false; + + text_input_delete_range(text, &len, sel_start, sel_end); + state.cursor = sel_start; + state.selection_anchor = sel_start; + return true; +} + +inline bool text_input_copy_selection(const TextInputState& state, const char* text, + int cap, bool masked = false) { + if (masked) return false; + int len = text_input_len(text, cap); + int sel_start = text_input_selection_start(state, len); + int sel_end = text_input_selection_end(state, len); + if (sel_start == sel_end) return false; + return gui::clipboard_set_text(text + sel_start, sel_end - sel_start); +} + +inline bool text_input_replace_selection_with_text(TextInputState& state, + char* text, + int cap, + const char* insert, + int insert_len, + TextInputCharFilter filter = nullptr, + void* userdata = nullptr) { + if (!text || !insert || cap <= 0 || insert_len < 0) return false; + + for (int i = 0; i < insert_len; i++) { + if (!text_input_char_allowed(insert[i], filter, userdata)) + return false; + } + + int len = text_input_len(text, cap); + state.cursor = text_input_clamp_cursor(state.cursor, len); + state.selection_anchor = text_input_clamp_cursor(state.selection_anchor, len); + + int sel_start = text_input_selection_start(state, len); + int sel_end = text_input_selection_end(state, len); + int replaced = sel_end - sel_start; + if (len - replaced + insert_len >= cap) return false; + + if (replaced > 0) + text_input_delete_range(text, &len, sel_start, sel_end); + + for (int i = len; i >= sel_start; i--) + text[i + insert_len] = text[i]; + for (int i = 0; i < insert_len; i++) + text[sel_start + i] = insert[i]; + + state.cursor = sel_start + insert_len; + state.selection_anchor = state.cursor; + return replaced > 0 || insert_len > 0; +} + +inline bool text_input_paste_clipboard(TextInputState& state, + char* text, + int cap, + TextInputCharFilter filter = nullptr, + void* userdata = nullptr) { + int paste_len = 0; + char* paste = gui::clipboard_get_text_alloc(&paste_len); + if (paste == nullptr || paste_len <= 0) { + if (paste) montauk::mfree(paste); + return false; + } + + bool changed = text_input_replace_selection_with_text(state, text, cap, + paste, paste_len, + filter, userdata); + montauk::mfree(paste); + return changed; +} + +inline bool text_input_cut_selection(TextInputState& state, char* text, + int cap, bool masked = false) { + if (!text_input_copy_selection(state, text, cap, masked)) + return false; + return text_input_delete_selection(state, text, cap); +} + +inline Rect text_input_context_menu_rect(const TextInputState& state, int view_w, int view_h) { + return context_menu_rect(state.context, TEXT_INPUT_CTX_COUNT, view_w, view_h); +} + +inline void text_input_context_items(ContextMenuItem items[TEXT_INPUT_CTX_COUNT], + bool has_selection, + bool masked) { + bool paste_enabled = gui::clipboard_has_text(); + items[TEXT_INPUT_CTX_CUT] = {"Cut", TEXT_INPUT_CTX_CUT, + has_selection && !masked}; + items[TEXT_INPUT_CTX_COPY] = {"Copy", TEXT_INPUT_CTX_COPY, + has_selection && !masked}; + items[TEXT_INPUT_CTX_PASTE] = {"Paste", TEXT_INPUT_CTX_PASTE, + paste_enabled}; + items[TEXT_INPUT_CTX_DELETE] = {"Delete", TEXT_INPUT_CTX_DELETE, + has_selection}; +} + +inline int text_input_handle_mouse(TextInputState& state, + const Rect& bounds, + char* text, + int cap, + int mx, + int my, + uint8_t buttons, + uint8_t prev_buttons, + int view_w, + int view_h, + bool focused, + bool masked = false, + TextInputCharFilter filter = nullptr, + void* userdata = nullptr) { + bool right_pressed = (buttons & 2) && !(prev_buttons & 2); + + if (state.context.open) { + bool has_selection = text_input_has_selection(state, text, cap); + ContextMenuItem items[TEXT_INPUT_CTX_COUNT]; + text_input_context_items(items, has_selection, masked); + + int action = context_menu_handle_mouse(state.context, items, + TEXT_INPUT_CTX_COUNT, + mx, my, buttons, prev_buttons, + view_w, view_h); + if (action == CONTEXT_MENU_NONE) return TEXT_INPUT_NONE; + int result = TEXT_INPUT_CONSUMED; + switch (action) { + case TEXT_INPUT_CTX_CUT: + if (text_input_cut_selection(state, text, cap, masked)) + result |= TEXT_INPUT_CHANGED; + break; + case TEXT_INPUT_CTX_COPY: + text_input_copy_selection(state, text, cap, masked); + break; + case TEXT_INPUT_CTX_PASTE: + if (text_input_paste_clipboard(state, text, cap, filter, userdata)) + result |= TEXT_INPUT_CHANGED; + break; + case TEXT_INPUT_CTX_DELETE: + if (text_input_delete_selection(state, text, cap)) + result |= TEXT_INPUT_CHANGED; + break; + } + return result; + } + + if (right_pressed && bounds.contains(mx, my)) { + int len = text_input_len(text, cap); + int cursor = text_input_index_from_x(bounds, text, mx, masked); + int sel_start = text_input_selection_start(state, len); + int sel_end = text_input_selection_end(state, len); + if (sel_start == sel_end || cursor < sel_start || cursor > sel_end) + text_input_set_cursor(state, len, cursor, false); + state.dragging = false; + context_menu_open(state.context, mx, my); + return TEXT_INPUT_CONSUMED; + } + + return text_input_mouse(state, bounds, text, mx, my, buttons, prev_buttons, + focused, masked); +} + +inline int text_input_key(TextInputState& state, + char* text, + int cap, + const Montauk::KeyEvent& key, + TextInputCharFilter filter = nullptr, + void* userdata = nullptr, + bool masked = false) { + if (!key.pressed || !text || cap <= 0) return TEXT_INPUT_NONE; + + int len = text_input_len(text, cap); + state.cursor = text_input_clamp_cursor(state.cursor, len); + state.selection_anchor = text_input_clamp_cursor(state.selection_anchor, len); + + if (state.context.open) { + context_menu_close(state.context); + if (key.scancode == 0x01) return TEXT_INPUT_CONSUMED; + } + + if (key.ctrl && (key.scancode == 0x1E || key.ascii == 'a' || key.ascii == 'A')) { + state.selection_anchor = 0; + state.cursor = len; + return TEXT_INPUT_CONSUMED; + } + + int sel_start = text_input_selection_start(state, len); + int sel_end = text_input_selection_end(state, len); + + if (key.ctrl && !key.alt && (key.ascii == 'c' || key.ascii == 'C')) { + text_input_copy_selection(state, text, cap, masked); + return TEXT_INPUT_CONSUMED; + } + + if (key.ctrl && !key.alt && (key.ascii == 'x' || key.ascii == 'X')) { + bool changed = text_input_cut_selection(state, text, cap, masked); + return TEXT_INPUT_CONSUMED | (changed ? TEXT_INPUT_CHANGED : 0); + } + + if (key.ctrl && !key.alt && (key.ascii == 'v' || key.ascii == 'V')) { + bool changed = text_input_paste_clipboard(state, text, cap, filter, userdata); + return TEXT_INPUT_CONSUMED | (changed ? TEXT_INPUT_CHANGED : 0); + } + + if (key.scancode == 0x4B || key.scancode == 0x4D || + key.scancode == 0x47 || key.scancode == 0x4F) { + int next = state.cursor; + if (!key.shift && sel_start != sel_end && key.scancode == 0x4B) + next = sel_start; + else if (!key.shift && sel_start != sel_end && key.scancode == 0x4D) + next = sel_end; + else if (key.scancode == 0x4B) + next = key.ctrl ? text_input_prev_word_boundary(text, state.cursor) : state.cursor - 1; + else if (key.scancode == 0x4D) + next = key.ctrl ? text_input_next_word_boundary(text, state.cursor) : state.cursor + 1; + else if (key.scancode == 0x47) + next = 0; + else if (key.scancode == 0x4F) + next = len; + + text_input_set_cursor(state, len, next, key.shift); + return TEXT_INPUT_CONSUMED; + } + + bool changed = false; + + if (key.ascii == '\b' || key.scancode == 0x0E) { + if (sel_start != sel_end) { + text_input_delete_range(text, &len, sel_start, sel_end); + state.cursor = sel_start; + changed = true; + } else if (key.ctrl) { + int start = text_input_prev_word_boundary(text, state.cursor); + if (start != state.cursor) { + text_input_delete_range(text, &len, start, state.cursor); + state.cursor = start; + changed = true; + } + } else if (state.cursor > 0) { + text_input_delete_range(text, &len, state.cursor - 1, state.cursor); + state.cursor--; + changed = true; + } + state.selection_anchor = state.cursor; + return TEXT_INPUT_CONSUMED | (changed ? TEXT_INPUT_CHANGED : 0); + } + + if (key.scancode == 0x53) { + if (sel_start != sel_end) { + text_input_delete_range(text, &len, sel_start, sel_end); + state.cursor = sel_start; + changed = true; + } else if (key.ctrl) { + int end = text_input_next_word_boundary(text, state.cursor); + if (end != state.cursor) { + text_input_delete_range(text, &len, state.cursor, end); + changed = true; + } + } else if (state.cursor < len) { + text_input_delete_range(text, &len, state.cursor, state.cursor + 1); + changed = true; + } + state.selection_anchor = state.cursor; + return TEXT_INPUT_CONSUMED | (changed ? TEXT_INPUT_CHANGED : 0); + } + + if (key.ctrl || key.alt || !text_input_char_allowed(key.ascii, filter, userdata)) + return TEXT_INPUT_NONE; + + if (sel_start != sel_end) { + text_input_delete_range(text, &len, sel_start, sel_end); + state.cursor = sel_start; + changed = true; + } + + if (len < cap - 1) { + for (int i = len; i >= state.cursor; i--) + text[i + 1] = text[i]; + text[state.cursor++] = key.ascii; + len++; + changed = true; + } + + state.selection_anchor = state.cursor; + return TEXT_INPUT_CONSUMED | (changed ? TEXT_INPUT_CHANGED : 0); +} + inline bool same_color(Color a, Color b) { return a.r == b.r && a.g == b.g && a.b == b.b && a.a == b.a; } @@ -302,10 +934,12 @@ inline void draw_text_field(Canvas& c, int cursor, bool focused, bool masked, - const Theme& theme) { + const Theme& theme, + int selection_anchor = -1) { const char* text = value ? value : ""; char masked_text[65]; const char* shown = masked ? mask_text(text, masked_text, (int)sizeof(masked_text)) : text; + int len = text_len(text); draw_rounded_frame(c, bounds, theme.radius_sm, theme.surface, focused ? theme.accent : theme.border); @@ -313,17 +947,42 @@ inline void draw_text_field(Canvas& c, int fh = system_font_height(); int tx = bounds.x + 8; int ty = bounds.y + (bounds.h - fh) / 2; + + if (focused && selection_anchor >= 0) { + int cur = text_input_clamp_cursor(cursor, len); + int anchor = text_input_clamp_cursor(selection_anchor, len); + if (cur != anchor) { + int start = cur < anchor ? cur : anchor; + int end = cur > anchor ? cur : anchor; + int sx = tx + text_input_prefix_width(text, start, masked); + int ex = tx + text_input_prefix_width(text, end, masked); + int clip_x = gui_max(sx, bounds.x + 3); + int clip_r = gui_min(ex, bounds.x + bounds.w - 3); + if (clip_r > clip_x) + c.fill_rect(clip_x, bounds.y + 4, clip_r - clip_x, + gui_max(bounds.h - 8, 1), theme.text_selection); + } + } + c.text(tx, ty, shown, theme.text); if (focused) { - int clamped = gui_clamp(cursor, 0, text_len(text)); - char prefix[65]; - copy_prefix(prefix, (int)sizeof(prefix), text, clamped, masked); - int cx = tx + text_width(prefix); + int clamped = gui_clamp(cursor, 0, len); + int cx = tx + text_input_prefix_width(text, clamped, masked); c.fill_rect(cx, bounds.y + 6, 2, gui_max(bounds.h - 12, 1), theme.accent); } } +inline void draw_text_input_context_menu(Canvas& c, + const TextInputState& state, + const Theme& theme, + bool has_selection = true, + bool masked = false) { + ContextMenuItem items[TEXT_INPUT_CTX_COUNT]; + text_input_context_items(items, has_selection, masked); + draw_context_menu(c, state.context, items, TEXT_INPUT_CTX_COUNT, theme); +} + inline Rect labeled_text_input_rect(int x, int y, int w, const Theme& theme, int field_h = 0) { if (field_h <= 0) field_h = theme.control_h; return {x, y + system_font_height() + theme.gap_xs, w, field_h}; @@ -342,11 +1001,12 @@ inline void draw_labeled_text_field(Canvas& c, bool focused, bool masked, const Theme& theme, - int field_h = 0) { + int field_h = 0, + int selection_anchor = -1) { if (field_h <= 0) field_h = theme.control_h; c.text(x, y, label, theme.text_muted); draw_text_field(c, labeled_text_input_rect(x, y, w, theme, field_h), - value, cursor, focused, masked, theme); + value, cursor, focused, masked, theme, selection_anchor); } inline void draw_modal_actions(Canvas& c, diff --git a/programs/src/desktop/apps/filemanager/actions.cpp b/programs/src/desktop/apps/filemanager/actions.cpp index 59ccd8f..f10f5a4 100644 --- a/programs/src/desktop/apps/filemanager/actions.cpp +++ b/programs/src/desktop/apps/filemanager/actions.cpp @@ -183,6 +183,7 @@ void filemanager_open_ctx_menu(FileManagerState* fm, int local_x, int local_y, i void filemanager_close_ctx_menu(FileManagerState* fm) { fm->ctx_open = false; + fm->ctx_hover = -1; } } // namespace filemanager diff --git a/programs/src/desktop/apps/filemanager/events.cpp b/programs/src/desktop/apps/filemanager/events.cpp index a5d725f..451a2e6 100644 --- a/programs/src/desktop/apps/filemanager/events.cpp +++ b/programs/src/desktop/apps/filemanager/events.cpp @@ -25,50 +25,35 @@ void filemanager_on_mouse(Window* win, MouseEvent& ev) { // ---- Context menu interaction ---- if (fm->ctx_open) { - int cmx = fm->ctx_x; - int cmy = fm->ctx_y; - int cmh = fm->ctx_item_count * CTX_ITEM_H + 8; - if (cmx + CTX_MENU_W > cw) cmx = cw - CTX_MENU_W; - if (cmy + cmh > win->content_h) cmy = win->content_h - cmh; - if (cmx < 0) cmx = 0; - if (cmy < 0) cmy = 0; + mtk::ContextMenuState state = filemanager_context_state(fm); + mtk::ContextMenuItem items[8]; + filemanager_build_context_items(fm, items, 8); + int action = mtk::context_menu_handle_mouse(state, items, + fm->ctx_item_count, + local_x, local_y, + ev.buttons, ev.prev_buttons, + cw, win->content_h); + filemanager_store_context_state(fm, state); - // Update hover - if (local_x >= cmx && local_x < cmx + CTX_MENU_W && - local_y >= cmy + 4 && local_y < cmy + 4 + fm->ctx_item_count * CTX_ITEM_H) { - fm->ctx_hover = (local_y - cmy - 4) / CTX_ITEM_H; - } else { - fm->ctx_hover = -1; - } + if (action >= 0) { + int target = fm->ctx_target_idx; - if (ev.left_pressed()) { - if (fm->ctx_hover >= 0 && fm->ctx_hover < fm->ctx_item_count) { - int action = fm->ctx_items[fm->ctx_hover]; - int target = fm->ctx_target_idx; - filemanager_close_ctx_menu(fm); + // Select target if needed + if (target >= 0 && target < fm->entry_count) + fm->selected = target; - // Select target if needed - if (target >= 0 && target < fm->entry_count) - fm->selected = target; - - switch (action) { - case CTX_OPEN: filemanager_open_entry(fm, target); break; - case CTX_COPY: filemanager_do_copy(fm); break; - case CTX_CUT: filemanager_do_cut(fm); break; - case CTX_PASTE: filemanager_do_paste(fm); break; - case CTX_RENAME: filemanager_start_rename(fm); break; - case CTX_DELETE: filemanager_delete_selected(fm); break; - case CTX_NEW_FOLDER: filemanager_new_folder(fm); break; - } - } else { - filemanager_close_ctx_menu(fm); + switch (action) { + case CTX_OPEN: filemanager_open_entry(fm, target); break; + case CTX_COPY: filemanager_do_copy(fm); break; + case CTX_CUT: filemanager_do_cut(fm); break; + case CTX_PASTE: filemanager_do_paste(fm); break; + case CTX_RENAME: filemanager_start_rename(fm); break; + case CTX_DELETE: filemanager_delete_selected(fm); break; + case CTX_NEW_FOLDER: filemanager_new_folder(fm); break; } return; } - if (ev.right_pressed()) { - filemanager_close_ctx_menu(fm); - return; - } + if (action == mtk::CONTEXT_MENU_DISMISSED) return; return; } diff --git a/programs/src/desktop/apps/filemanager/filemanager_internal.hpp b/programs/src/desktop/apps/filemanager/filemanager_internal.hpp index d6e6e1b..b2d6aa4 100644 --- a/programs/src/desktop/apps/filemanager/filemanager_internal.hpp +++ b/programs/src/desktop/apps/filemanager/filemanager_internal.hpp @@ -7,6 +7,7 @@ #pragma once #include "../apps_common.hpp" +#include namespace filemanager { @@ -131,9 +132,6 @@ inline constexpr int CTX_RENAME = 4; inline constexpr int CTX_DELETE = 5; inline constexpr int CTX_NEW_FOLDER = 6; -inline constexpr int CTX_MENU_W = 140; -inline constexpr int CTX_ITEM_H = 24; - inline bool filemanager_is_virtual_view(const FileManagerState* fm) { return fm && fm->virtual_view != FM_VIRTUAL_VIEW_NONE; } @@ -153,6 +151,29 @@ inline const char* filemanager_virtual_view_label(FileManagerVirtualViewKind vie int special_folder_index(const char* name); const char* ctx_label(int action); +inline mtk::ContextMenuState filemanager_context_state(const FileManagerState* fm) { + if (!fm) return {}; + return {fm->ctx_open, fm->ctx_x, fm->ctx_y, fm->ctx_hover}; +} + +inline void filemanager_store_context_state(FileManagerState* fm, + const mtk::ContextMenuState& state) { + if (!fm) return; + fm->ctx_open = state.open; + fm->ctx_x = state.x; + fm->ctx_y = state.y; + fm->ctx_hover = state.hover; +} + +inline void filemanager_build_context_items(const FileManagerState* fm, + mtk::ContextMenuItem* items, + int max_items) { + if (!fm || !items || max_items <= 0) return; + int count = fm->ctx_item_count < max_items ? fm->ctx_item_count : max_items; + for (int i = 0; i < count; i++) + items[i] = {ctx_label(fm->ctx_items[i]), fm->ctx_items[i], true}; +} + bool str_ends_with(const char* s, const char* suffix); bool is_image_file(const char* name); bool is_font_file(const char* name); diff --git a/programs/src/desktop/apps/filemanager/render.cpp b/programs/src/desktop/apps/filemanager/render.cpp index 37d400b..a77e43d 100644 --- a/programs/src/desktop/apps/filemanager/render.cpp +++ b/programs/src/desktop/apps/filemanager/render.cpp @@ -576,33 +576,14 @@ void filemanager_on_draw(Window* win, Framebuffer& fb) { // ---- Context menu overlay ---- if (fm->ctx_open && fm->ctx_item_count > 0) { - int cmx = fm->ctx_x; - int cmy = fm->ctx_y; - int cmh = fm->ctx_item_count * CTX_ITEM_H + 8; + mtk::Theme theme = mtk::make_theme(colors::ACCENT); + theme.border = colors::BORDER; + theme.text = colors::TEXT_COLOR; - // Clamp to window bounds - if (cmx + CTX_MENU_W > c.w) cmx = c.w - CTX_MENU_W; - if (cmy + cmh > c.h) cmy = c.h - cmh; - if (cmx < 0) cmx = 0; - if (cmy < 0) cmy = 0; - - // Shadow - c.fill_rect(cmx + 2, cmy + 2, CTX_MENU_W, cmh, Color::from_rgb(0x80, 0x80, 0x80)); - // Background - c.fill_rounded_rect(cmx, cmy, CTX_MENU_W, cmh, 4, colors::WHITE); - c.rect(cmx, cmy, CTX_MENU_W, cmh, colors::BORDER); - - for (int i = 0; i < fm->ctx_item_count; i++) { - int iy = cmy + 4 + i * CTX_ITEM_H; - - // Hover highlight - if (i == fm->ctx_hover) - c.fill_rect(cmx + 2, iy, CTX_MENU_W - 4, CTX_ITEM_H, colors::MENU_HOVER); - - int fh = system_font_height(); - int ty = iy + (CTX_ITEM_H - fh) / 2; - c.text(cmx + 12, ty, ctx_label(fm->ctx_items[i]), colors::TEXT_COLOR); - } + mtk::ContextMenuItem items[8]; + filemanager_build_context_items(fm, items, 8); + mtk::draw_context_menu(c, filemanager_context_state(fm), items, + fm->ctx_item_count, theme); } } diff --git a/programs/src/desktop/apps/usermanager/dialogs.cpp b/programs/src/desktop/apps/usermanager/dialogs.cpp index 4c66c36..67d76a9 100644 --- a/programs/src/desktop/apps/usermanager/dialogs.cpp +++ b/programs/src/desktop/apps/usermanager/dialogs.cpp @@ -15,21 +15,6 @@ static mtk::WidgetState dialog_button_state(int mouse_x, int mouse_y, return mtk::widget_state(active, rect.contains(mouse_x, mouse_y), enabled); } -static void dialog_append(char* buf, int* len, int max, char ch) { - if (*len < max - 1) { - buf[*len] = ch; - (*len)++; - buf[*len] = '\0'; - } -} - -static void dialog_backspace(char* buf, int* len) { - if (*len > 0) { - (*len)--; - buf[*len] = '\0'; - } -} - static void dialog_close_self(DesktopState* ds, void* app_data) { for (int i = 0; i < ds->window_count; i++) { if (ds->windows[i].app_data == app_data) { @@ -55,8 +40,11 @@ struct AddUserDialogState { int mouse_y; int field; char username[32]; int username_len; + mtk::TextInputState username_input; char display[64]; int display_len; + mtk::TextInputState display_input; char password[64]; int password_len; + mtk::TextInputState password_input; bool role_admin; char error[64]; }; @@ -76,15 +64,18 @@ static void adduser_on_draw(Window* win, Framebuffer& fb) { int sfh = system_font_height(); mtk::draw_labeled_text_field(c, pad, y, fw, "Username", st->username, - st->username_len, st->field == 0, false, theme, USER_FIELD_H); + st->username_input.cursor, st->field == 0, false, + theme, USER_FIELD_H, st->username_input.selection_anchor); y += mtk::labeled_text_height(theme, USER_FIELD_H) + 10; mtk::draw_labeled_text_field(c, pad, y, fw, "Display Name", st->display, - st->display_len, st->field == 1, false, theme, USER_FIELD_H); + st->display_input.cursor, st->field == 1, false, + theme, USER_FIELD_H, st->display_input.selection_anchor); y += mtk::labeled_text_height(theme, USER_FIELD_H) + 10; mtk::draw_labeled_text_field(c, pad, y, fw, "Password", st->password, - st->password_len, st->field == 2, true, theme, USER_FIELD_H); + st->password_input.cursor, st->field == 2, true, + theme, USER_FIELD_H, st->password_input.selection_anchor); y += mtk::labeled_text_height(theme, USER_FIELD_H) + 12; c.text(pad, y + 4, "Role:", theme.text_muted); @@ -112,6 +103,20 @@ static void adduser_on_draw(Window* win, Framebuffer& fb) { cancel_btn, "Cancel", dialog_button_state(st->mouse_x, st->mouse_y, cancel_btn), theme); + + mtk::draw_text_input_context_menu( + c, st->username_input, theme, + mtk::text_input_has_selection(st->username_input, + st->username, (int)sizeof(st->username))); + mtk::draw_text_input_context_menu( + c, st->display_input, theme, + mtk::text_input_has_selection(st->display_input, + st->display, (int)sizeof(st->display))); + mtk::draw_text_input_context_menu( + c, st->password_input, theme, + mtk::text_input_has_selection(st->password_input, + st->password, (int)sizeof(st->password)), + true); } static void adduser_submit(AddUserDialogState* st) { @@ -145,25 +150,90 @@ static void adduser_on_mouse(Window* win, MouseEvent& ev) { if (!host.map_mouse(ev, &mx, &my)) return; st->mouse_x = mx; st->mouse_y = my; - if (!ev.left_pressed()) return; mtk::Theme theme = mtk::make_theme(st->accent); int pad = 16; int fw = win->content_w - 2 * pad; int y = pad; + bool right_pressed = ev.right_pressed(); Rect username_rect = mtk::labeled_text_input_rect(pad, y, fw, theme, USER_FIELD_H); - if (username_rect.contains(mx, my)) { st->field = 0; return; } + if (st->username_input.context.open || st->username_input.dragging) { + int result = mtk::text_input_handle_mouse( + st->username_input, username_rect, st->username, (int)sizeof(st->username), + mx, my, ev.buttons, ev.prev_buttons, win->content_w, win->content_h, + st->field == 0); + if (result & mtk::TEXT_INPUT_CHANGED) + st->username_len = mtk::text_input_len(st->username, (int)sizeof(st->username)); + return; + } y += mtk::labeled_text_height(theme, USER_FIELD_H) + 10; Rect display_rect = mtk::labeled_text_input_rect(pad, y, fw, theme, USER_FIELD_H); - if (display_rect.contains(mx, my)) { st->field = 1; return; } + if (st->display_input.context.open || st->display_input.dragging) { + int result = mtk::text_input_handle_mouse( + st->display_input, display_rect, st->display, (int)sizeof(st->display), + mx, my, ev.buttons, ev.prev_buttons, win->content_w, win->content_h, + st->field == 1); + if (result & mtk::TEXT_INPUT_CHANGED) + st->display_len = mtk::text_input_len(st->display, (int)sizeof(st->display)); + return; + } y += mtk::labeled_text_height(theme, USER_FIELD_H) + 10; Rect password_rect = mtk::labeled_text_input_rect(pad, y, fw, theme, USER_FIELD_H); - if (password_rect.contains(mx, my)) { st->field = 2; return; } + if (st->password_input.context.open || st->password_input.dragging) { + int result = mtk::text_input_handle_mouse( + st->password_input, password_rect, st->password, (int)sizeof(st->password), + mx, my, ev.buttons, ev.prev_buttons, win->content_w, win->content_h, + st->field == 2, true); + if (result & mtk::TEXT_INPUT_CHANGED) + st->password_len = mtk::text_input_len(st->password, (int)sizeof(st->password)); + return; + } y += mtk::labeled_text_height(theme, USER_FIELD_H) + 12; + if (!ev.left_pressed() && !right_pressed) return; + + if (username_rect.contains(mx, my)) { + st->field = 0; + mtk::context_menu_close(st->display_input.context); + mtk::context_menu_close(st->password_input.context); + int result = mtk::text_input_handle_mouse( + st->username_input, username_rect, st->username, (int)sizeof(st->username), + mx, my, ev.buttons, ev.prev_buttons, win->content_w, win->content_h, + true); + if (result & mtk::TEXT_INPUT_CHANGED) + st->username_len = mtk::text_input_len(st->username, (int)sizeof(st->username)); + return; + } + if (display_rect.contains(mx, my)) { + st->field = 1; + mtk::context_menu_close(st->username_input.context); + mtk::context_menu_close(st->password_input.context); + int result = mtk::text_input_handle_mouse( + st->display_input, display_rect, st->display, (int)sizeof(st->display), + mx, my, ev.buttons, ev.prev_buttons, win->content_w, win->content_h, + true); + if (result & mtk::TEXT_INPUT_CHANGED) + st->display_len = mtk::text_input_len(st->display, (int)sizeof(st->display)); + return; + } + if (password_rect.contains(mx, my)) { + st->field = 2; + mtk::context_menu_close(st->username_input.context); + mtk::context_menu_close(st->display_input.context); + int result = mtk::text_input_handle_mouse( + st->password_input, password_rect, st->password, (int)sizeof(st->password), + mx, my, ev.buttons, ev.prev_buttons, win->content_w, win->content_h, + true, true); + if (result & mtk::TEXT_INPUT_CHANGED) + st->password_len = mtk::text_input_len(st->password, (int)sizeof(st->password)); + return; + } + + if (!ev.left_pressed()) return; + Rect user_btn = {pad + 60, y, 60, USER_BTN_H}; Rect admin_btn = {pad + 128, y, 60, USER_BTN_H}; if (user_btn.contains(mx, my)) { @@ -203,20 +273,28 @@ static void adduser_on_key(Window* win, const Montauk::KeyEvent& key) { st->field = (st->field + 1) % 3; return; } - if (key.ascii == '\b' || key.scancode == 0x0E) { - switch (st->field) { - case 0: dialog_backspace(st->username, &st->username_len); break; - case 1: dialog_backspace(st->display, &st->display_len); break; - case 2: dialog_backspace(st->password, &st->password_len); break; - } - return; - } - if (key.ascii >= 0x20 && key.ascii < 0x7F) { - switch (st->field) { - case 0: dialog_append(st->username, &st->username_len, 31, key.ascii); break; - case 1: dialog_append(st->display, &st->display_len, 63, key.ascii); break; - case 2: dialog_append(st->password, &st->password_len, 63, key.ascii); break; - } + + int result = mtk::TEXT_INPUT_NONE; + switch (st->field) { + case 0: + result = mtk::text_input_key(st->username_input, st->username, + (int)sizeof(st->username), key); + if (result & mtk::TEXT_INPUT_CHANGED) + st->username_len = mtk::text_input_len(st->username, (int)sizeof(st->username)); + break; + case 1: + result = mtk::text_input_key(st->display_input, st->display, + (int)sizeof(st->display), key); + if (result & mtk::TEXT_INPUT_CHANGED) + st->display_len = mtk::text_input_len(st->display, (int)sizeof(st->display)); + break; + case 2: + result = mtk::text_input_key(st->password_input, st->password, + (int)sizeof(st->password), key, + nullptr, nullptr, true); + if (result & mtk::TEXT_INPUT_CHANGED) + st->password_len = mtk::text_input_len(st->password, (int)sizeof(st->password)); + break; } } @@ -262,7 +340,9 @@ struct ChPwdDialogState { char display_name[64]; int field; char new_pwd[64]; int new_len; + mtk::TextInputState new_input; char confirm[64]; int confirm_len; + mtk::TextInputState confirm_input; char error[64]; }; @@ -286,11 +366,13 @@ static void chpwd_on_draw(Window* win, Framebuffer& fb) { y += sfh + 12; mtk::draw_labeled_text_field(c, pad, y, fw, "New Password", st->new_pwd, - st->new_len, st->field == 0, true, theme, USER_FIELD_H); + st->new_input.cursor, st->field == 0, true, + theme, USER_FIELD_H, st->new_input.selection_anchor); y += mtk::labeled_text_height(theme, USER_FIELD_H) + 10; mtk::draw_labeled_text_field(c, pad, y, fw, "Confirm Password", st->confirm, - st->confirm_len, st->field == 1, true, theme, USER_FIELD_H); + st->confirm_input.cursor, st->field == 1, true, + theme, USER_FIELD_H, st->confirm_input.selection_anchor); y += mtk::labeled_text_height(theme, USER_FIELD_H) + 12; if (st->error[0]) { @@ -306,6 +388,17 @@ static void chpwd_on_draw(Window* win, Framebuffer& fb) { cancel_btn, "Cancel", dialog_button_state(st->mouse_x, st->mouse_y, cancel_btn), theme); + + mtk::draw_text_input_context_menu( + c, st->new_input, theme, + mtk::text_input_has_selection(st->new_input, + st->new_pwd, (int)sizeof(st->new_pwd)), + true); + mtk::draw_text_input_context_menu( + c, st->confirm_input, theme, + mtk::text_input_has_selection(st->confirm_input, + st->confirm, (int)sizeof(st->confirm)), + true); } static void chpwd_submit(ChPwdDialogState* st) { @@ -333,19 +426,62 @@ static void chpwd_on_mouse(Window* win, MouseEvent& ev) { if (!host.map_mouse(ev, &mx, &my)) return; st->mouse_x = mx; st->mouse_y = my; - if (!ev.left_pressed()) return; mtk::Theme theme = mtk::make_theme(st->accent); int pad = 16; int fw = win->content_w - 2 * pad; int y = pad + system_font_height() + 12; + bool right_pressed = ev.right_pressed(); Rect new_rect = mtk::labeled_text_input_rect(pad, y, fw, theme, USER_FIELD_H); - if (new_rect.contains(mx, my)) { st->field = 0; return; } + if (st->new_input.context.open || st->new_input.dragging) { + int result = mtk::text_input_handle_mouse( + st->new_input, new_rect, st->new_pwd, (int)sizeof(st->new_pwd), + mx, my, ev.buttons, ev.prev_buttons, win->content_w, win->content_h, + st->field == 0, true); + if (result & mtk::TEXT_INPUT_CHANGED) + st->new_len = mtk::text_input_len(st->new_pwd, (int)sizeof(st->new_pwd)); + return; + } y += mtk::labeled_text_height(theme, USER_FIELD_H) + 10; Rect confirm_rect = mtk::labeled_text_input_rect(pad, y, fw, theme, USER_FIELD_H); - if (confirm_rect.contains(mx, my)) { st->field = 1; return; } + if (st->confirm_input.context.open || st->confirm_input.dragging) { + int result = mtk::text_input_handle_mouse( + st->confirm_input, confirm_rect, st->confirm, (int)sizeof(st->confirm), + mx, my, ev.buttons, ev.prev_buttons, win->content_w, win->content_h, + st->field == 1, true); + if (result & mtk::TEXT_INPUT_CHANGED) + st->confirm_len = mtk::text_input_len(st->confirm, (int)sizeof(st->confirm)); + return; + } + + if (!ev.left_pressed() && !right_pressed) return; + + if (new_rect.contains(mx, my)) { + st->field = 0; + mtk::context_menu_close(st->confirm_input.context); + int result = mtk::text_input_handle_mouse( + st->new_input, new_rect, st->new_pwd, (int)sizeof(st->new_pwd), + mx, my, ev.buttons, ev.prev_buttons, win->content_w, win->content_h, + true, true); + if (result & mtk::TEXT_INPUT_CHANGED) + st->new_len = mtk::text_input_len(st->new_pwd, (int)sizeof(st->new_pwd)); + return; + } + if (confirm_rect.contains(mx, my)) { + st->field = 1; + mtk::context_menu_close(st->new_input.context); + int result = mtk::text_input_handle_mouse( + st->confirm_input, confirm_rect, st->confirm, (int)sizeof(st->confirm), + mx, my, ev.buttons, ev.prev_buttons, win->content_w, win->content_h, + true, true); + if (result & mtk::TEXT_INPUT_CHANGED) + st->confirm_len = mtk::text_input_len(st->confirm, (int)sizeof(st->confirm)); + return; + } + + if (!ev.left_pressed()) return; Rect save_btn = dialog_primary_button_rect(pad, win->content_h); Rect cancel_btn = dialog_secondary_button_rect(pad, win->content_h); @@ -375,14 +511,18 @@ static void chpwd_on_key(Window* win, const Montauk::KeyEvent& key) { st->field = (st->field + 1) % 2; return; } - if (key.ascii == '\b' || key.scancode == 0x0E) { - if (st->field == 0) dialog_backspace(st->new_pwd, &st->new_len); - else dialog_backspace(st->confirm, &st->confirm_len); - return; - } - if (key.ascii >= 0x20 && key.ascii < 0x7F) { - if (st->field == 0) dialog_append(st->new_pwd, &st->new_len, 63, key.ascii); - else dialog_append(st->confirm, &st->confirm_len, 63, key.ascii); + if (st->field == 0) { + int result = mtk::text_input_key(st->new_input, st->new_pwd, + (int)sizeof(st->new_pwd), key, + nullptr, nullptr, true); + if (result & mtk::TEXT_INPUT_CHANGED) + st->new_len = mtk::text_input_len(st->new_pwd, (int)sizeof(st->new_pwd)); + } else { + int result = mtk::text_input_key(st->confirm_input, st->confirm, + (int)sizeof(st->confirm), key, + nullptr, nullptr, true); + if (result & mtk::TEXT_INPUT_CHANGED) + st->confirm_len = mtk::text_input_len(st->confirm, (int)sizeof(st->confirm)); } } diff --git a/programs/src/network/main.cpp b/programs/src/network/main.cpp index 98a0173..64d24e9 100644 --- a/programs/src/network/main.cpp +++ b/programs/src/network/main.cpp @@ -38,7 +38,7 @@ enum Tab { struct Field { const char* label; char value[FIELD_TEXT_CAP]; - int cursor; + mtk::TextInputState input; }; static const char* const kTabLabels[TAB_COUNT] = { @@ -54,10 +54,10 @@ static int g_focus_field = -1; static Montauk::NetCfg g_cfg = {}; static Montauk::NetStatus g_net = {}; static Field g_fields[FIELD_COUNT] = { - {"IP Address", {}, 0}, - {"Subnet Mask", {}, 0}, - {"Gateway", {}, 0}, - {"DNS Server", {}, 0}, + {"IP Address", {}, {}}, + {"Subnet Mask", {}, {}}, + {"Gateway", {}, {}}, + {"DNS Server", {}, {}}, }; static bool g_dirty = false; static char g_status[128] = {}; @@ -108,6 +108,11 @@ static bool is_ipv4_input_char(char ch) { return (ch >= '0' && ch <= '9') || ch == '.'; } +static bool accept_ipv4_input_char(char ch, void* userdata) { + (void)userdata; + return is_ipv4_input_char(ch); +} + static void focus_field(int index, int cursor = -1) { if (index < 0) index = 0; if (index >= FIELD_COUNT) index = FIELD_COUNT - 1; @@ -118,7 +123,10 @@ static void focus_field(int index, int cursor = -1) { if (cursor > len) cursor = len; g_focus_field = index; - field.cursor = cursor; + for (int i = 0; i < FIELD_COUNT; i++) { + if (i != index) mtk::context_menu_close(g_fields[i].input.context); + } + mtk::text_input_reset(field.input, cursor); } static bool mac_is_zero(const uint8_t* mac) { @@ -233,7 +241,7 @@ static void copy_cfg_to_fields() { format_ip(g_fields[2].value, sizeof(g_fields[2].value), g_cfg.gateway); format_ip(g_fields[3].value, sizeof(g_fields[3].value), g_cfg.dnsServer); for (int i = 0; i < FIELD_COUNT; i++) { - g_fields[i].cursor = str_len(g_fields[i].value); + mtk::text_input_reset(g_fields[i].input, str_len(g_fields[i].value)); } g_dirty = false; } @@ -324,30 +332,6 @@ static int field_w(int index) { return config_two_columns() ? (available - GAP) / 2 : available; } -static int field_cursor_from_x(int index, int mx) { - if (index < 0 || index >= FIELD_COUNT) return 0; - - Rect input = field_input_rect(index); - int rel_x = mx - (input.x + 8); - if (rel_x <= 0) return 0; - - Field& field = g_fields[index]; - int len = str_len(field.value); - char prefix[FIELD_TEXT_CAP] = {}; - int prev_w = 0; - - for (int i = 0; i < len; i++) { - prefix[i] = field.value[i]; - prefix[i + 1] = '\0'; - int cur_w = text_width(prefix); - int midpoint = prev_w + (cur_w - prev_w) / 2; - if (rel_x < midpoint) return i; - prev_w = cur_w; - } - - return len; -} - static void draw_kv(Canvas& c, int* y, const char* key, const char* value, const mtk::Theme& theme) { int fh = system_font_height(); @@ -410,8 +394,19 @@ static void draw_config_tab(Canvas& c, const mtk::Theme& theme) { for (int i = 0; i < FIELD_COUNT; i++) { mtk::draw_labeled_text_field(c, field_x(i), field_label_y(i), field_w(i), g_fields[i].label, g_fields[i].value, - g_fields[i].cursor, g_focus_field == i, - false, theme, FIELD_H); + g_fields[i].input.cursor, g_focus_field == i, + false, theme, FIELD_H, + g_fields[i].input.selection_anchor); + } +} + +static void draw_config_context_menus(Canvas& c, const mtk::Theme& theme) { + for (int i = 0; i < FIELD_COUNT; i++) { + mtk::draw_text_input_context_menu( + c, g_fields[i].input, theme, + mtk::text_input_has_selection(g_fields[i].input, + g_fields[i].value, + (int)sizeof(g_fields[i].value))); } } @@ -464,6 +459,8 @@ static void render() { draw_config_tab(c, theme); } draw_footer(c, theme); + if (g_tab == TAB_CONFIG) + draw_config_context_menus(c, theme); host.present(); } @@ -530,17 +527,27 @@ static void set_tab(Tab tab) { focus_field(0); } else { g_focus_field = -1; + for (int i = 0; i < FIELD_COUNT; i++) { + g_fields[i].input.dragging = false; + mtk::context_menu_close(g_fields[i].input.context); + } } } -static bool handle_click(int mx, int my) { - int tab = mtk::hit_tab_bar(tab_bar_rect(), TAB_COUNT, mx, my); - if (tab >= 0) { - set_tab((Tab)tab); - return true; +static bool handle_mouse(int mx, int my, uint8_t buttons, uint8_t prev_buttons) { + bool left_pressed = (buttons & 1) && !(prev_buttons & 1); + bool right_pressed = (buttons & 2) && !(prev_buttons & 2); + + if (left_pressed) { + int tab = mtk::hit_tab_bar(tab_bar_rect(), TAB_COUNT, mx, my); + if (tab >= 0) { + set_tab((Tab)tab); + return true; + } } if (g_tab == TAB_STATUS) { + if (!left_pressed) return false; if (status_refresh_button().contains(mx, my)) { refresh_state(!g_dirty); set_status("Network status refreshed"); @@ -558,12 +565,44 @@ static bool handle_click(int mx, int my) { } for (int i = 0; i < FIELD_COUNT; i++) { - if (field_input_rect(i).contains(mx, my)) { - focus_field(i, field_cursor_from_x(i, mx)); + if (!g_fields[i].input.context.open && !g_fields[i].input.dragging) continue; + int result = mtk::text_input_handle_mouse(g_fields[i].input, + field_input_rect(i), + g_fields[i].value, + (int)sizeof(g_fields[i].value), + mx, my, buttons, prev_buttons, + g_win.width, g_win.height, + g_focus_field == i, + false, accept_ipv4_input_char); + if (result & mtk::TEXT_INPUT_CHANGED) + g_dirty = true; + return result != mtk::TEXT_INPUT_NONE; + } + + if (!left_pressed && !right_pressed) return false; + + for (int i = 0; i < FIELD_COUNT; i++) { + Rect input = field_input_rect(i); + if (input.contains(mx, my)) { + g_focus_field = i; + for (int j = 0; j < FIELD_COUNT; j++) { + if (j != i) mtk::context_menu_close(g_fields[j].input.context); + } + int result = mtk::text_input_handle_mouse(g_fields[i].input, input, + g_fields[i].value, + (int)sizeof(g_fields[i].value), + mx, my, buttons, prev_buttons, + g_win.width, g_win.height, + true, false, + accept_ipv4_input_char); + if (result & mtk::TEXT_INPUT_CHANGED) + g_dirty = true; return true; } } + if (!left_pressed) return false; + if (config_dhcp_button().contains(mx, my)) { launch_dhcp(); return true; @@ -584,38 +623,6 @@ static bool handle_click(int mx, int my) { return true; } -static void delete_before_cursor(Field* field) { - if (!field || field->cursor <= 0) return; - int len = str_len(field->value); - for (int i = field->cursor - 1; i < len; i++) { - field->value[i] = field->value[i + 1]; - } - field->cursor--; - g_dirty = true; -} - -static void delete_at_cursor(Field* field) { - if (!field) return; - int len = str_len(field->value); - if (field->cursor >= len) return; - for (int i = field->cursor; i < len; i++) { - field->value[i] = field->value[i + 1]; - } - g_dirty = true; -} - -static void insert_char(Field* field, char ch) { - if (!field) return; - if (!((ch >= '0' && ch <= '9') || ch == '.')) return; - int len = str_len(field->value); - if (len >= FIELD_TEXT_CAP - 1) return; - for (int i = len; i >= field->cursor; i--) { - field->value[i + 1] = field->value[i]; - } - field->value[field->cursor++] = ch; - g_dirty = true; -} - static bool handle_key(const Montauk::KeyEvent& key) { if (!key.pressed) return false; @@ -664,38 +671,12 @@ static bool handle_key(const Montauk::KeyEvent& key) { } Field* field = &g_fields[g_focus_field]; - int len = str_len(field->value); - - if (key.ascii == '\b' || key.scancode == 0x0E) { - delete_before_cursor(field); - return true; - } - if (key.scancode == 0x53) { - delete_at_cursor(field); - return true; - } - if (key.scancode == 0x4B) { - if (field->cursor > 0) field->cursor--; - return true; - } - if (key.scancode == 0x4D) { - if (field->cursor < len) field->cursor++; - return true; - } - if (key.scancode == 0x47) { - field->cursor = 0; - return true; - } - if (key.scancode == 0x4F) { - field->cursor = len; - return true; - } - if (is_ipv4_input_char(key.ascii)) { - insert_char(field, key.ascii); - return true; - } - - return false; + int result = mtk::text_input_key(field->input, field->value, + (int)sizeof(field->value), key, + accept_ipv4_input_char); + if (result & mtk::TEXT_INPUT_CHANGED) + g_dirty = true; + return (result & mtk::TEXT_INPUT_CONSUMED) != 0; } extern "C" void _start() { @@ -737,9 +718,8 @@ extern "C" void _start() { g_mouse_x = ev.mouse.x; g_mouse_y = ev.mouse.y; redraw = true; - - bool clicked = (ev.mouse.buttons & 1) && !(ev.mouse.prev_buttons & 1); - if (clicked && handle_click(ev.mouse.x, ev.mouse.y)) { + if (handle_mouse(ev.mouse.x, ev.mouse.y, + ev.mouse.buttons, ev.mouse.prev_buttons)) { redraw = true; } } else if (ev.type == 0) { diff --git a/programs/src/printers/main.cpp b/programs/src/printers/main.cpp index 1169782..77ea938 100644 --- a/programs/src/printers/main.cpp +++ b/programs/src/printers/main.cpp @@ -79,6 +79,7 @@ struct AppState { int config_button_down; char edit_uri[MAX_PATH_LEN]; int edit_len; + mtk::TextInputState edit_input; char edit_error[128]; int mouse_x; int mouse_y; @@ -473,6 +474,7 @@ static void close_config_popup() { static void open_config_popup() { safe_copy(g_app.edit_uri, sizeof(g_app.edit_uri), g_app.printer_uri); g_app.edit_len = montauk::slen(g_app.edit_uri); + mtk::text_input_reset(g_app.edit_input, g_app.edit_len); g_app.edit_error[0] = '\0'; g_app.config_button_down = 0; g_app.config_mouse_x = -1; @@ -535,8 +537,10 @@ static void render_config_popup() { c.text(x, y, "Enter an IPP or IPPS destination URI.", theme.text_muted); mtk::draw_labeled_text_field(c, lo.input.x, lo.input_label_y, lo.input.w, - "Printer URI", g_app.edit_uri, g_app.edit_len, - true, false, theme, lo.input.h); + "Printer URI", g_app.edit_uri, + g_app.edit_input.cursor, + true, false, theme, lo.input.h, + g_app.edit_input.selection_anchor); y = lo.input.y + lo.input.h + 12; c.text(x, y, "Example: ipp://printer.local/ipp/print", theme.text_muted); @@ -552,6 +556,10 @@ static void render_config_popup() { lo.cancel_btn, "Cancel", config_button_state(lo.cancel_btn, true, g_app.config_button_down == 2), theme); + mtk::draw_text_input_context_menu( + c, g_app.edit_input, theme, + mtk::text_input_has_selection(g_app.edit_input, + g_app.edit_uri, MAX_PATH_LEN)); montauk::win_present(g_config.win_id); } @@ -563,8 +571,38 @@ static bool handle_config_mouse(int mx, int my, uint8_t buttons, uint8_t prev_bu bool left_pressed = (buttons & 1) && !(prev_buttons & 1); bool left_released = !(buttons & 1) && (prev_buttons & 1); + bool right_pressed = (buttons & 2) && !(prev_buttons & 2); + + if (g_app.edit_input.context.open || g_app.edit_input.dragging) { + int result = mtk::text_input_handle_mouse(g_app.edit_input, lo.input, + g_app.edit_uri, MAX_PATH_LEN, + mx, my, buttons, prev_buttons, + g_config.width, g_config.height, + true); + if (result & mtk::TEXT_INPUT_CHANGED) { + g_app.edit_len = mtk::text_input_len(g_app.edit_uri, MAX_PATH_LEN); + g_app.edit_error[0] = '\0'; + } + return false; + } + + if (left_pressed || right_pressed) { + if (lo.input.contains(mx, my)) { + g_app.config_button_down = 0; + int result = mtk::text_input_handle_mouse(g_app.edit_input, lo.input, + g_app.edit_uri, MAX_PATH_LEN, + mx, my, buttons, prev_buttons, + g_config.width, g_config.height, + true); + if (result & mtk::TEXT_INPUT_CHANGED) { + g_app.edit_len = mtk::text_input_len(g_app.edit_uri, MAX_PATH_LEN); + g_app.edit_error[0] = '\0'; + } + return false; + } + + if (!left_pressed) return false; - if (left_pressed) { int button_down = 0; if (lo.save_btn.contains(mx, my)) button_down = 1; @@ -601,17 +639,11 @@ static bool handle_config_key(const Montauk::KeyEvent& key) { save_configured_printer(); return !g_app.config_open; } - if (key.ascii == '\b' || key.scancode == 0x0E) { - if (g_app.edit_len > 0) { - g_app.edit_len--; - g_app.edit_uri[g_app.edit_len] = '\0'; - g_app.edit_error[0] = '\0'; - } - return false; - } - if (key.ascii >= 0x20 && key.ascii < 0x7F && g_app.edit_len < MAX_PATH_LEN - 1) { - g_app.edit_uri[g_app.edit_len++] = key.ascii; - g_app.edit_uri[g_app.edit_len] = '\0'; + + int result = mtk::text_input_key(g_app.edit_input, g_app.edit_uri, + MAX_PATH_LEN, key); + if (result & mtk::TEXT_INPUT_CHANGED) { + g_app.edit_len = mtk::text_input_len(g_app.edit_uri, MAX_PATH_LEN); g_app.edit_error[0] = '\0'; } return false; diff --git a/programs/src/texteditor/main.cpp b/programs/src/texteditor/main.cpp index 918e955..7d84ee4 100644 --- a/programs/src/texteditor/main.cpp +++ b/programs/src/texteditor/main.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include extern "C" { @@ -59,6 +60,14 @@ static constexpr Color STATUS_TEXT = Color::from_rgb(0xE0, 0xE0, 0xE0); static constexpr Color PATHBAR_BG = Color::from_rgb(0xF0, 0xF0, 0xF0); static constexpr Color WHITE = Color::from_rgb(0xFF, 0xFF, 0xFF); +enum ContextItem { + CTX_CUT = 0, + CTX_COPY, + CTX_PASTE, + CTX_DELETE, + CTX_COUNT, +}; + // ============================================================================ // Global state // ============================================================================ @@ -101,6 +110,9 @@ int g_sel_end = 0; bool g_has_selection = false; bool g_mouse_selecting = false; +// Context menu +mtk::ContextMenuState g_ctx_menu = {}; + // Pathbar bool g_pathbar_open = false; bool g_pathbar_save = false; @@ -501,6 +513,57 @@ static bool paste_from_clipboard() { return true; } +static bool context_item_enabled(int item) { + int selected = selection_length(); + switch (item) { + case CTX_CUT: + case CTX_COPY: + case CTX_DELETE: + return selected > 0; + case CTX_PASTE: + return gui::clipboard_has_text(); + default: + return false; + } +} + +static void build_context_items(mtk::ContextMenuItem items[CTX_COUNT]) { + items[CTX_CUT] = {"Cut", CTX_CUT, context_item_enabled(CTX_CUT)}; + items[CTX_COPY] = {"Copy", CTX_COPY, context_item_enabled(CTX_COPY)}; + items[CTX_PASTE] = {"Paste", CTX_PASTE, context_item_enabled(CTX_PASTE)}; + items[CTX_DELETE] = {"Delete", CTX_DELETE, context_item_enabled(CTX_DELETE)}; +} + +static bool selection_contains_pos(int pos) { + if (!g_has_selection) return false; + int ss, se; + sel_range(&ss, &se); + return pos >= ss && pos < se; +} + +static void close_context_menu() { + mtk::context_menu_close(g_ctx_menu); +} + +static bool perform_context_action(int item) { + if (!context_item_enabled(item)) return false; + + switch (item) { + case CTX_CUT: + return cut_selection_to_clipboard(); + case CTX_COPY: + return copy_selection_to_clipboard(); + case CTX_PASTE: + return paste_from_clipboard(); + case CTX_DELETE: + delete_selection(); + set_status("Deleted selection"); + return true; + default: + return false; + } +} + static bool looks_binary(const char* data, int len) { if (!data || len <= 0) return false; @@ -625,6 +688,22 @@ static int hit_test(int mx, int my, int editor_y) { return g_line_offsets[clicked_line] + clicked_col; } +static void draw_context_menu(uint32_t* pixels, int W, int H) { + if (!g_ctx_menu.open) return; + + Canvas c(pixels, W, H); + mtk::Theme theme = mtk::make_theme(ACCENT); + theme.border = BORDER_COLOR; + theme.text = TEXT_COLOR; + + mtk::ContextMenuItem items[CTX_COUNT]; + build_context_items(items); + mtk::draw_context_menu(c, g_ctx_menu, items, CTX_COUNT, theme, + mtk::CONTEXT_MENU_DEFAULT_W, + mtk::CONTEXT_MENU_ITEM_H, + g_font, FONT_SIZE); +} + // ============================================================================ // Rendering // ============================================================================ @@ -880,6 +959,8 @@ static void render(uint32_t* pixels) { int rw = g_font->measure_text(status_right, FONT_SIZE); g_font->draw_to_buffer(pixels, W, H, W - rw - 4, sty, status_right, STATUS_TEXT, FONT_SIZE); } + + draw_context_menu(pixels, W, H); } // ============================================================================ @@ -948,6 +1029,11 @@ static void open_pathbar_save() { static bool handle_key(Montauk::KeyEvent& key, int win_id) { if (!key.pressed) return false; + if (g_ctx_menu.open) { + close_context_menu(); + if (key.scancode == 0x01) return true; + } + // ---- Pathbar mode ---- if (g_pathbar_open) { if (key.ascii == '\n' || key.ascii == '\r') { @@ -1140,6 +1226,7 @@ static bool handle_mouse(Montauk::WinEvent& ev, int win_id) { uint8_t btns = ev.mouse.buttons; uint8_t prev = ev.mouse.prev_buttons; bool clicked = (btns & 1) && !(prev & 1); + bool right_clicked = (btns & 2) && !(prev & 2); bool held = (btns & 1) != 0; bool released = !(btns & 1) && (prev & 1); int scroll = ev.mouse.scroll; @@ -1148,6 +1235,22 @@ static bool handle_mouse(Montauk::WinEvent& ev, int win_id) { int editor_y = TOOLBAR_H + (g_pathbar_open ? PATHBAR_H : 0); int text_area_h = g_win_h - editor_y - STATUS_H; + if (g_ctx_menu.open) { + if (scroll != 0) { + close_context_menu(); + return true; + } + + mtk::ContextMenuItem items[CTX_COUNT]; + build_context_items(items); + int action = mtk::context_menu_handle_mouse(g_ctx_menu, items, CTX_COUNT, + mx, my, btns, prev, + g_win_w, g_win_h); + if (action >= 0) + perform_context_action(action); + return action != mtk::CONTEXT_MENU_NONE; + } + // ---- Toolbar clicks ---- if (clicked && my < TOOLBAR_H) { // Open button @@ -1180,6 +1283,19 @@ static bool handle_mouse(Montauk::WinEvent& ev, int win_id) { } // ---- Editor click ---- + if (right_clicked && my >= editor_y && my < editor_y + text_area_h + && mx > LINE_NUM_W && mx < g_win_w - SCROLLBAR_W) { + int pos = hit_test(mx, my, editor_y); + if (!selection_contains_pos(pos)) { + g_cursor_pos = pos; + update_cursor_pos(); + clear_selection(); + } + g_mouse_selecting = false; + mtk::context_menu_open(g_ctx_menu, mx, my); + return true; + } + if (clicked && my >= editor_y && my < editor_y + text_area_h && mx > LINE_NUM_W && mx < g_win_w - SCROLLBAR_W) { int pos = hit_test(mx, my, editor_y); diff --git a/programs/src/wikipedia/main.cpp b/programs/src/wikipedia/main.cpp index 3274e56..f623a21 100644 --- a/programs/src/wikipedia/main.cpp +++ b/programs/src/wikipedia/main.cpp @@ -83,6 +83,7 @@ static constexpr int READER_FONT_FACE_COUNT = (int)ReaderFontFace::COUNT; static AppPhase g_phase = AppPhase::IDLE; static char g_query[256] = {}; +static mtk::TextInputState g_query_input = {}; static char g_status[256] = {}; static int g_scroll_y = 0; static int g_line_count = 0; @@ -959,8 +960,10 @@ static void render(Canvas& canvas) { // ---- Toolbar ---- canvas.fill_rect(toolbar.x, toolbar.y, toolbar.w, toolbar.h, theme.surface_alt); - mtk::draw_text_field(canvas, lo.search_field, g_query, (int)strlen(g_query), - g_search_focused, false, theme); + mtk::draw_text_field(canvas, lo.search_field, g_query, + g_query_input.cursor, + g_search_focused, false, theme, + g_query_input.selection_anchor); draw_display_toggle_button(canvas, lo.display_button, mtk::widget_state(g_display_controls_open, mouse_in_rect(lo.display_button), true), @@ -1029,6 +1032,11 @@ static void render(Canvas& canvas) { theme.radius_sm, thumb_color); } } + + mtk::draw_text_input_context_menu( + canvas, g_query_input, theme, + mtk::text_input_has_selection(g_query_input, + g_query, (int)sizeof(g_query))); } // ============================================================================ @@ -1125,12 +1133,9 @@ extern "C" void _start() { if (g_search_focused) { if (ascii == '\n' || ascii == '\r') { search_pending = true; - } else if (ascii == '\b' || scan == 0x0E) { - int len = (int)strlen(g_query); - if (len > 0) g_query[len - 1] = '\0'; - } else if (ascii >= 32 && ascii < 127) { - int len = (int)strlen(g_query); - if (len < 254) { g_query[len] = ascii; g_query[len + 1] = '\0'; } + } else { + mtk::text_input_key(g_query_input, g_query, + (int)sizeof(g_query), ev.key); } } else if (g_phase == AppPhase::DONE) { // Navigation keys @@ -1151,12 +1156,26 @@ extern "C" void _start() { g_mouse_y = my; bool left_down = (ev.mouse.buttons & 1) != 0; bool just_clicked = (ev.mouse.buttons & 1) && !(ev.mouse.prev_buttons & 1); + bool just_right_clicked = (ev.mouse.buttons & 2) && !(ev.mouse.prev_buttons & 2); bool just_released = !(ev.mouse.buttons & 1) && (ev.mouse.prev_buttons & 1); ReaderToolbarLayout lo = toolbar_layout(); Rect scrollbar_track = scrollbar_track_rect(); Rect scrollbar_thumb = scrollbar_thumb_rect(); + if (g_query_input.context.open || g_query_input.dragging) { + int result = mtk::text_input_handle_mouse(g_query_input, lo.search_field, + g_query, (int)sizeof(g_query), + mx, my, ev.mouse.buttons, + ev.mouse.prev_buttons, + g_win_w, g_win_h, + g_search_focused); + if (result & mtk::TEXT_INPUT_CONSUMED) { + just_clicked = false; + just_right_clicked = false; + } + } + if (just_released) { g_scroll_dragging = false; } @@ -1165,23 +1184,33 @@ extern "C" void _start() { set_scroll_from_thumb_top(my - g_scroll_drag_offset); } - if (just_clicked) { - g_search_focused = lo.search_field.contains(mx, my); - - if (scrollbar_thumb.contains(mx, my)) { + if (just_clicked || just_right_clicked) { + if (lo.search_field.contains(mx, my)) { + g_search_focused = true; + mtk::text_input_handle_mouse(g_query_input, lo.search_field, + g_query, (int)sizeof(g_query), + mx, my, ev.mouse.buttons, ev.mouse.prev_buttons, + g_win_w, g_win_h, true); + } else if (scrollbar_thumb.contains(mx, my)) { + g_search_focused = false; g_scroll_dragging = true; g_scroll_drag_offset = my - scrollbar_thumb.y; } else if (scrollbar_track.contains(mx, my)) { + g_search_focused = false; g_scroll_dragging = true; g_scroll_drag_offset = scrollbar_thumb.h / 2; set_scroll_from_thumb_top(my - g_scroll_drag_offset); } else if (lo.display_button.contains(mx, my)) { + g_search_focused = false; g_display_controls_open = !g_display_controls_open; update_toolbar_height(); rebuild_article(true); clamp_scroll(); } else if (handle_reader_option_click(mx, my)) { + g_search_focused = false; // Reader controls handled. + } else { + g_search_focused = false; } } diff --git a/programs/src/wordprocessor/document.cpp b/programs/src/wordprocessor/document.cpp index 3ebdb0e..6dc84c7 100644 --- a/programs/src/wordprocessor/document.cpp +++ b/programs/src/wordprocessor/document.cpp @@ -542,6 +542,7 @@ void wp_init_empty_document(WordProcessorState* wp) { wp->line_spacing_dropdown_open = false; wp->divider_flyout_open = false; wp->special_char_flyout_open = false; + mtk::context_menu_reset(wp->context_menu); wp->page_view_mode = false; wp->pending_page_view_toggle = false; diff --git a/programs/src/wordprocessor/input.cpp b/programs/src/wordprocessor/input.cpp index 099a38a..9c9e023 100644 --- a/programs/src/wordprocessor/input.cpp +++ b/programs/src/wordprocessor/input.cpp @@ -65,6 +65,59 @@ static bool wp_paste_from_clipboard(WordProcessorState* wp) { return true; } +bool wp_context_item_enabled(WordProcessorState* wp, int item) { + switch (item) { + case WP_CTX_CUT: + case WP_CTX_COPY: + case WP_CTX_DELETE: + return wp && wp->has_selection; + case WP_CTX_PASTE: + return gui::clipboard_has_text(); + default: + return false; + } +} + +void wp_build_context_items(WordProcessorState* wp, + mtk::ContextMenuItem items[WP_CTX_COUNT]) { + items[WP_CTX_CUT] = {"Cut", WP_CTX_CUT, + wp_context_item_enabled(wp, WP_CTX_CUT)}; + items[WP_CTX_COPY] = {"Copy", WP_CTX_COPY, + wp_context_item_enabled(wp, WP_CTX_COPY)}; + items[WP_CTX_PASTE] = {"Paste", WP_CTX_PASTE, + wp_context_item_enabled(wp, WP_CTX_PASTE)}; + items[WP_CTX_DELETE] = {"Delete", WP_CTX_DELETE, + wp_context_item_enabled(wp, WP_CTX_DELETE)}; +} + +static bool wp_selection_contains_abs(WordProcessorState* wp, int abs) { + if (!wp || !wp->has_selection) return false; + int sel_s = 0; + int sel_e = 0; + wp_sel_range(wp, &sel_s, &sel_e); + return abs >= sel_s && abs < sel_e; +} + +static bool wp_perform_context_action(WordProcessorState* wp, int item) { + if (!wp_context_item_enabled(wp, item)) return false; + + switch (item) { + case WP_CTX_CUT: + return wp_cut_selection_to_clipboard(wp); + case WP_CTX_COPY: + return wp_copy_selection_to_clipboard(wp); + case WP_CTX_PASTE: + return wp_paste_from_clipboard(wp); + case WP_CTX_DELETE: + wp_delete_selection(wp); + wp_history_checkpoint(wp); + wp_set_status(wp, "Deleted selection"); + return true; + default: + return false; + } +} + static void wp_open_pathbar_for_open(WordProcessorState* wp) { char path[256] = {}; char msg[160] = {}; @@ -255,6 +308,26 @@ void wp_handle_mouse(const Montauk::WinEvent& ev) { recompute_w = WP_A4_PAGE_W; } + if (wp->context_menu.open) { + if (ev.mouse.scroll != 0) { + mtk::context_menu_close(wp->context_menu); + return; + } + + mtk::ContextMenuItem items[WP_CTX_COUNT]; + wp_build_context_items(wp, items); + int action = mtk::context_menu_handle_mouse(wp->context_menu, + items, WP_CTX_COUNT, + local_x, local_y, + ev.mouse.buttons, + ev.mouse.prev_buttons, + g_win_w, g_win_h); + if (action >= 0) + wp_perform_context_action(wp, action); + if (action != mtk::CONTEXT_MENU_NONE) + return; + } + wp->scrollbar.handle_mouse(local_x, local_y, ev.mouse.buttons, ev.mouse.prev_buttons, ev.mouse.scroll); if (wp->font_dropdown_open && wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons)) { @@ -464,6 +537,27 @@ void wp_handle_mouse(const Montauk::WinEvent& ev) { wp_recompute_wrap(wp, recompute_w, WP_SCREEN_DPI); + bool right_pressed = wp_right_pressed(ev.mouse.buttons, ev.mouse.prev_buttons); + bool in_text_area = local_y >= edit_y && local_y < edit_y + text_area_h && + local_x >= 0 && local_x < g_win_w - WP_SCROLLBAR_W; + if (in_text_area && wp->page_view_mode) { + in_text_area = local_x >= page_off_x && local_x < page_off_x + WP_A4_PAGE_W && + local_y >= page_off_y && local_y < page_off_y + WP_A4_PAGE_H; + } + + if (right_pressed && in_text_area) { + wp_close_dropdowns(wp); + + int abs = wp_hit_test_text(wp, local_x, local_y, edit_y, page_off_x, page_off_y); + if (!wp_selection_contains_abs(wp, abs)) { + wp_pos_to_run(wp, abs, &wp->cursor_run, &wp->cursor_offset); + wp_clear_selection(wp); + } + wp->mouse_selecting = false; + mtk::context_menu_open(wp->context_menu, local_x, local_y); + return; + } + if (wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons) && local_y >= edit_y && local_y < edit_y + text_area_h) { wp_close_dropdowns(wp); @@ -502,6 +596,11 @@ void wp_handle_key(const Montauk::KeyEvent& key) { WordProcessorState* wp = &g_wp; if (!key.pressed) return; + if (wp->context_menu.open) { + mtk::context_menu_close(wp->context_menu); + if (key.scancode == 0x01) return; + } + if (wp->show_pathbar) { if (key.ascii == '\n' || key.ascii == '\r') { wp_commit_pathbar(wp); diff --git a/programs/src/wordprocessor/render.cpp b/programs/src/wordprocessor/render.cpp index abc51b2..4062feb 100644 --- a/programs/src/wordprocessor/render.cpp +++ b/programs/src/wordprocessor/render.cpp @@ -35,6 +35,21 @@ static void wp_draw_ui_icon_button(Canvas& c, int x, int y, int w, int h, wp_draw_ui_button(c, x, y, w, h, fallback, bg, fg, radius); } +static void wp_draw_context_menu(Canvas& c, WordProcessorState* wp) { + if (!wp || !wp->context_menu.open) return; + + mtk::Theme theme = mtk::make_theme(colors::ACCENT); + theme.border = colors::BORDER; + theme.text = colors::TEXT_COLOR; + + mtk::ContextMenuItem items[WP_CTX_COUNT]; + wp_build_context_items(wp, items); + mtk::draw_context_menu(c, wp->context_menu, items, WP_CTX_COUNT, theme, + mtk::CONTEXT_MENU_DEFAULT_W, + mtk::CONTEXT_MENU_ITEM_H, + g_ui_font, fonts::UI_SIZE); +} + static int wp_special_char_flyout_x() { int dx = WP_BTN_SECTION_X + 24 - WP_SPECIAL_CHAR_FLYOUT_W; if (dx + WP_SPECIAL_CHAR_FLYOUT_W > g_win_w) dx = g_win_w - WP_SPECIAL_CHAR_FLYOUT_W; @@ -666,4 +681,6 @@ void wp_render() { WP_SPECIAL_CHAR_OPTIONS[i].label, colors::TEXT_COLOR); } } + + wp_draw_context_menu(c, wp); } diff --git a/programs/src/wordprocessor/wordprocessor.hpp b/programs/src/wordprocessor/wordprocessor.hpp index 32313a9..5782866 100644 --- a/programs/src/wordprocessor/wordprocessor.hpp +++ b/programs/src/wordprocessor/wordprocessor.hpp @@ -13,6 +13,7 @@ #include #include #include +#include extern "C" { #include @@ -239,6 +240,18 @@ inline bool wp_left_released(uint8_t buttons, uint8_t prev_buttons) { return !(buttons & 0x01) && (prev_buttons & 0x01); } +inline bool wp_right_pressed(uint8_t buttons, uint8_t prev_buttons) { + return (buttons & 0x02) && !(prev_buttons & 0x02); +} + +enum WpContextItem : int { + WP_CTX_CUT = 0, + WP_CTX_COPY, + WP_CTX_PASTE, + WP_CTX_DELETE, + WP_CTX_COUNT, +}; + struct WpScrollbar { Rect bounds; int content_height; @@ -370,6 +383,7 @@ struct WordProcessorState { bool line_spacing_dropdown_open; bool divider_flyout_open; bool special_char_flyout_open; + mtk::ContextMenuState context_menu; bool page_view_mode; bool pending_page_view_toggle; @@ -462,6 +476,9 @@ bool wp_redo(WordProcessorState* wp); bool wp_print_document(WordProcessorState* wp, char* out_status, int out_status_len); bool wp_export_pdf_document(WordProcessorState* wp, const char* path, char* out_status, int out_status_len); +bool wp_context_item_enabled(WordProcessorState* wp, int item); +void wp_build_context_items(WordProcessorState* wp, + mtk::ContextMenuItem items[WP_CTX_COUNT]); void wp_render(); void wp_handle_mouse(const Montauk::WinEvent& ev);