/* * widgets.hpp * Montauk Toolkit immediate-mode widgets * Copyright (c) 2026 Daniel Hammer */ #pragma once #include #include "gui/canvas.hpp" #include "gui/clipboard.hpp" #include "gui/font.hpp" #include "gui/mtk/theme.hpp" namespace gui::mtk { enum ButtonVariant : uint8_t { BUTTON_PRIMARY = 0, BUTTON_SECONDARY, BUTTON_DANGER, BUTTON_GHOST, BUTTON_TONAL, }; struct WidgetState { bool active; bool hovered; 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}; } struct ButtonColors { Color bg; Color border; 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; } inline void draw_rounded_frame(Canvas& c, const Rect& bounds, int radius, Color fill, Color border, int border_w = 1) { if (bounds.empty()) return; if (border_w <= 0 || same_color(fill, border)) { c.fill_rounded_rect(bounds.x, bounds.y, bounds.w, bounds.h, gui_max(radius, 0), fill); return; } c.fill_rounded_rect(bounds.x, bounds.y, bounds.w, bounds.h, gui_max(radius, 0), border); int inner_w = bounds.w - border_w * 2; int inner_h = bounds.h - border_w * 2; if (inner_w <= 0 || inner_h <= 0) return; c.fill_rounded_rect(bounds.x + border_w, bounds.y + border_w, inner_w, inner_h, gui_max(radius - border_w, 0), fill); } inline ButtonColors resolve_button_colors(ButtonVariant variant, const WidgetState& state, const Theme& theme) { if (!state.enabled) { return {theme.disabled_bg, theme.disabled_bg, theme.disabled_fg}; } switch (variant) { case BUTTON_PRIMARY: return { state.hovered ? theme.accent_hover : theme.accent, state.hovered ? theme.accent_hover : theme.accent, theme.accent_fg }; case BUTTON_DANGER: return { state.hovered ? theme.danger_hover : theme.danger, state.hovered ? theme.danger_hover : theme.danger, theme.danger_fg }; case BUTTON_GHOST: if (state.active) { return {theme.accent_soft, theme.accent_soft, theme.accent}; } return { state.hovered ? theme.surface_hover : theme.window_bg, theme.border, theme.text }; case BUTTON_TONAL: { Color fill = state.hovered ? mix(theme.surface, theme.accent, 40) : mix(theme.surface, theme.accent, 24); return {fill, fill, theme.text}; } case BUTTON_SECONDARY: default: if (state.active) { return { state.hovered ? theme.accent_hover : theme.accent, state.hovered ? theme.accent_hover : theme.accent, theme.accent_fg }; } return { state.hovered ? theme.surface_hover : theme.window_bg, theme.border, theme.text }; } } inline void draw_button(Canvas& c, const Rect& bounds, const char* label, ButtonVariant variant, const WidgetState& state, const Theme& theme) { if (bounds.empty()) return; ButtonColors colors = resolve_button_colors(variant, state, theme); draw_rounded_frame(c, bounds, theme.radius_md, colors.bg, colors.border); if (label && label[0]) { int tw = text_width(label); int fh = system_font_height(); int tx = bounds.x + (bounds.w - tw) / 2; int ty = bounds.y + (bounds.h - fh) / 2; c.text(tx, ty, label, colors.fg); } } inline void draw_separator(Canvas& c, int x, int y, int w, const Theme& theme) { c.hline(x, y, w, theme.border); } static constexpr int SCROLLBAR_W = 12; static constexpr int SCROLLBAR_OUTER_PAD = 4; static constexpr int SCROLLBAR_TRACK_PAD = 8; static constexpr int SCROLLBAR_THUMB_INSET = 2; static constexpr int SCROLLBAR_MIN_THUMB = 24; inline bool scrollbar_needed(int content_extent, int view_extent) { return content_extent > view_extent && view_extent > 0; } inline int scrollbar_max_offset(int content_extent, int view_extent) { int max_offset = content_extent - view_extent; return max_offset > 0 ? max_offset : 0; } inline Rect scrollbar_track_rect(const Rect& viewport, int width = SCROLLBAR_W, int outer_pad = SCROLLBAR_OUTER_PAD, int track_pad = SCROLLBAR_TRACK_PAD) { int h = gui_max(viewport.h - track_pad * 2, 0); int usable_w = viewport.w - outer_pad; if (usable_w <= 0 || h <= 0) return {0, 0, 0, 0}; int actual_w = gui_clamp(width, 1, usable_w); return { viewport.x + viewport.w - actual_w - outer_pad, viewport.y + track_pad, actual_w, h }; } inline int scrollbar_thumb_extent(int content_extent, int view_extent, int track_extent, int min_thumb = SCROLLBAR_MIN_THUMB) { if (!scrollbar_needed(content_extent, view_extent) || track_extent <= 0) return track_extent; int thumb = (int)(((int64_t)view_extent * track_extent) / content_extent); return gui_clamp(thumb, gui_min(min_thumb, track_extent), track_extent); } inline Rect scrollbar_thumb_rect(const Rect& track, int content_extent, int view_extent, int offset, int min_thumb = SCROLLBAR_MIN_THUMB, int thumb_inset = SCROLLBAR_THUMB_INSET) { if (track.empty() || !scrollbar_needed(content_extent, view_extent)) return {0, 0, 0, 0}; int max_offset = scrollbar_max_offset(content_extent, view_extent); int thumb_h = scrollbar_thumb_extent(content_extent, view_extent, track.h, min_thumb); int travel = gui_max(track.h - thumb_h, 0); int clamped_offset = gui_clamp(offset, 0, max_offset); int thumb_y = track.y + (max_offset > 0 ? (int)(((int64_t)clamped_offset * travel) / max_offset) : 0); int thumb_w = gui_max(track.w - thumb_inset * 2, 1); return {track.x + thumb_inset, thumb_y, thumb_w, thumb_h}; } inline int scrollbar_offset_from_thumb_top(const Rect& track, int content_extent, int view_extent, int thumb_extent, int thumb_top) { int max_offset = scrollbar_max_offset(content_extent, view_extent); int travel = gui_max(track.h - thumb_extent, 0); if (track.empty() || max_offset <= 0 || travel <= 0) return 0; int rel = gui_clamp(thumb_top - track.y, 0, travel); return (int)(((int64_t)rel * max_offset + travel / 2) / travel); } inline Color scrollbar_thumb_color(const Theme& theme, bool hovered, bool dragging) { Color base = mix(theme.border, theme.text_muted, 54); if (dragging) return mix(base, theme.accent, 118); if (hovered) return mix(base, theme.accent, 42); return base; } inline void draw_scrollbar(Canvas& c, const Rect& track, const Rect& thumb, bool hovered, bool dragging, const Theme& theme) { if (track.empty() || thumb.empty()) return; if (hovered || dragging) { int rail_w = track.w > 6 ? track.w - 6 : track.w; if (rail_w < 1) rail_w = 1; Rect rail = { track.x + (track.w - rail_w) / 2, track.y, rail_w, track.h }; Color rail_fill = dragging ? mix(theme.window_bg, theme.accent, 34) : mix(theme.window_bg, theme.text_muted, 24); c.fill_rounded_rect(rail.x, rail.y, rail.w, rail.h, gui_max(theme.radius_sm, rail.w / 2), rail_fill); } Color thumb_fill = scrollbar_thumb_color(theme, hovered, dragging); int radius = gui_max(theme.radius_sm, thumb.w / 2); c.fill_rounded_rect(thumb.x, thumb.y, thumb.w, thumb.h, radius, thumb_fill); } inline Rect tab_rect(const Rect& bar, int count, int index) { if (count <= 0) return {bar.x, bar.y, 0, bar.h}; int slot_w = bar.w / count; int rx = bar.x + slot_w * index; int rw = (index == count - 1) ? (bar.w - slot_w * index) : slot_w; return {rx, bar.y, rw, bar.h}; } inline void draw_tab_bar(Canvas& c, const Rect& bar, const char* const* labels, int count, int active, const Theme& theme) { c.fill_rect(bar.x, bar.y, bar.w, bar.h, theme.surface); c.hline(bar.x, bar.y + bar.h - 1, bar.w, theme.border); int fh = system_font_height(); for (int i = 0; i < count; i++) { Rect item = tab_rect(bar, count, i); bool is_active = (i == active); if (is_active) { c.fill_rect(item.x, item.y, item.w, item.h, theme.window_bg); if (item.w > 8) { c.fill_rect(item.x + 4, item.y + item.h - 3, item.w - 8, 3, theme.selection); } } Color tc = is_active ? theme.accent : theme.text_subtle; int tw = text_width(labels[i]); c.text(item.x + (item.w - tw) / 2, item.y + (item.h - fh) / 2, labels[i], tc); } } inline int hit_tab_bar(const Rect& bar, int count, int mx, int my) { if (!bar.contains(mx, my) || count <= 0) return -1; for (int i = 0; i < count; i++) { if (tab_rect(bar, count, i).contains(mx, my)) return i; } return -1; } inline Rect radio_indicator_rect(const Rect& option) { int size = 14; return {option.x, option.y + (option.h - size) / 2, size, size}; } inline void draw_radio(Canvas& c, const Rect& option, const char* label, bool selected, const Theme& theme) { Rect bubble = radio_indicator_rect(option); int radius = bubble.w / 2; c.fill_rounded_rect(bubble.x, bubble.y, bubble.w, bubble.h, radius, theme.border); c.fill_rounded_rect(bubble.x + 1, bubble.y + 1, bubble.w - 2, bubble.h - 2, gui_max(radius - 1, 1), colors::WHITE); if (selected) { int inner = gui_max(bubble.w - 8, 2); c.fill_rounded_rect(bubble.x + 4, bubble.y + 4, inner, inner, gui_max(inner / 2, 1), theme.accent); } int fh = system_font_height(); c.text(bubble.x + bubble.w + theme.gap_sm, option.y + (option.h - fh) / 2, label, theme.text); } inline Rect swatch_rect(int row_x, int row_y, int index, int size = 24, int gap = 6) { return {row_x + index * (size + gap), row_y, size, size}; } inline int hit_swatch_row(int mx, int my, int row_x, int row_y, int count, int size = 24, int gap = 6) { for (int i = 0; i < count; i++) { if (swatch_rect(row_x, row_y, i, size, gap).contains(mx, my)) return i; } return -1; } inline void draw_swatch_row(Canvas& c, int row_x, int row_y, const Color* palette, int count, int selected_index, const Theme& theme, int size = 24, int gap = 6) { for (int i = 0; i < count; i++) { Rect swatch = swatch_rect(row_x, row_y, i, size, gap); if (i == selected_index) { c.fill_rounded_rect(swatch.x - 2, swatch.y - 2, swatch.w + 4, swatch.h + 4, 4, theme.accent); } c.fill_rounded_rect(swatch.x, swatch.y, swatch.w, swatch.h, 3, palette[i]); c.rect(swatch.x, swatch.y, swatch.w, swatch.h, Color::from_rgb(0xCC, 0xCC, 0xCC)); } } inline void draw_list_row(Canvas& c, const Rect& row, bool selected, bool alternate, const Theme& theme) { if (selected) { c.fill_rounded_rect(row.x, row.y, row.w, row.h, theme.radius_md, theme.selection); } else if (alternate) { c.fill_rounded_rect(row.x, row.y, row.w, row.h, theme.radius_md, theme.surface_alt); } } inline int badge_width(const char* label, const Theme& theme) { return text_width(label) + theme.gap_sm * 2; } inline int badge_height() { return system_font_height() + 4; } inline void draw_badge(Canvas& c, int x, int y, const char* label, Color bg, Color fg, const Theme& theme) { int bw = badge_width(label, theme); int bh = badge_height(); c.fill_rounded_rect(x, y, bw, bh, bh / 2, bg); c.text(x + theme.gap_sm, y + 2, label, fg); } inline const char* mask_text(const char* value, char* buffer, int cap) { int len = 0; while (value && value[len] && len < cap - 1) { buffer[len] = '*'; len++; } buffer[len] = '\0'; return buffer; } inline void copy_prefix(char* buffer, int cap, const char* value, int count, bool masked) { if (cap <= 0) return; int len = 0; while (value && value[len] && len < count && len < cap - 1) { buffer[len] = masked ? '*' : value[len]; len++; } buffer[len] = '\0'; } inline void draw_text_field(Canvas& c, const Rect& bounds, const char* value, int cursor, bool focused, bool masked, 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); 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, 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}; } inline int labeled_text_height(const Theme& theme, int field_h = 0) { if (field_h <= 0) field_h = theme.control_h; return system_font_height() + theme.gap_xs + field_h; } inline void draw_labeled_text_field(Canvas& c, int x, int y, int w, const char* label, const char* value, int cursor, bool focused, bool masked, const Theme& theme, 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, selection_anchor); } inline void draw_modal_actions(Canvas& c, const Rect& primary, const char* primary_label, ButtonVariant primary_variant, const WidgetState& primary_state, const Rect& secondary, const char* secondary_label, const WidgetState& secondary_state, const Theme& theme) { draw_button(c, primary, primary_label, primary_variant, primary_state, theme); draw_button(c, secondary, secondary_label, BUTTON_SECONDARY, secondary_state, theme); } } // namespace gui::mtk