366 lines
12 KiB
C++
366 lines
12 KiB
C++
/*
|
|
* widgets.hpp
|
|
* Montauk Toolkit immediate-mode widgets
|
|
* Copyright (c) 2026 Daniel Hammer
|
|
*/
|
|
|
|
#pragma once
|
|
|
|
#include "gui/canvas.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;
|
|
};
|
|
|
|
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 int text_len(const char* text) {
|
|
int len = 0;
|
|
while (text && text[len]) len++;
|
|
return len;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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) {
|
|
const char* text = value ? value : "";
|
|
char masked_text[65];
|
|
const char* shown = masked ? mask_text(text, masked_text, (int)sizeof(masked_text)) : 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;
|
|
c.text(tx, ty, shown, theme.text);
|
|
|
|
if (focused) {
|
|
int clamped = gui_clamp(cursor, 0, text_len(text));
|
|
char prefix[65];
|
|
copy_prefix(prefix, (int)sizeof(prefix), text, clamped, masked);
|
|
int cx = tx + text_width(prefix);
|
|
c.fill_rect(cx, bounds.y + 6, 2, gui_max(bounds.h - 12, 1), theme.accent);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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);
|
|
}
|
|
|
|
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
|