Files
MontaukOS/template/sysroot/include/gui/mtk/widgets.hpp
T

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::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