1123 lines
41 KiB
C++
1123 lines
41 KiB
C++
/*
|
|
* widgets.hpp
|
|
* Montauk Toolkit immediate-mode widgets
|
|
* Copyright (c) 2026 Daniel Hammer
|
|
*/
|
|
|
|
#pragma once
|
|
|
|
#include <Api/Syscall.hpp>
|
|
|
|
#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, accent_hover_tint(theme.accent));
|
|
|
|
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::abi::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 = 0;
|
|
static constexpr int SCROLLBAR_TRACK_PAD = 0;
|
|
static constexpr int SCROLLBAR_THUMB_INSET = 1;
|
|
static constexpr int SCROLLBAR_MIN_THUMB = 20;
|
|
|
|
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) {
|
|
if (hovered || dragging) return Color::from_rgb(0xA0, 0xA0, 0xA0);
|
|
return colors::SCROLLBAR_FG;
|
|
}
|
|
|
|
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;
|
|
|
|
c.fill_rect(track.x, track.y, track.w, track.h, colors::SCROLLBAR_BG);
|
|
Color thumb_fill = scrollbar_thumb_color(theme, hovered, dragging);
|
|
c.fill_rect(thumb.x, thumb.y, thumb.w, thumb.h, 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
|