Files
waylight/src/ImGui.cpp
2025-10-17 00:20:39 +03:00

793 lines
22 KiB
C++

#include "ImGui.hpp"
#include <algorithm>
#include <cassert>
#include <cctype>
#include <cmath>
#include <format>
#include <string>
#include <string_view>
#include <vector>
#include <raylib.h>
namespace Waylight {
namespace {
struct CodepointSpan {
u32 codepoint {};
usize start {};
usize end {};
};
constexpr inline float px_pos(float x) { return std::floor(x + 0.5f); }
constexpr inline float px_w(float w) { return std::ceil(w); }
constexpr auto utf8_rune_from_first(char const *s) -> u32
{
u8 b0 = static_cast<u8>(s[0]);
if (b0 < 0x80)
return b0;
if ((b0 & 0xE0) == 0xC0)
return ((b0 & 0x1F) << 6) | (static_cast<u8>(s[1]) & 0x3F);
if ((b0 & 0xF0) == 0xE0)
return ((b0 & 0x0F) << 12) | ((static_cast<u8>(s[1]) & 0x3F) << 6)
| (static_cast<u8>(s[2]) & 0x3F);
if ((b0 & 0xF8) == 0xF0)
return ((b0 & 0x07) << 18) | ((static_cast<u8>(s[1]) & 0x3F) << 12)
| ((static_cast<u8>(s[2]) & 0x3F) << 6)
| (static_cast<u8>(s[3]) & 0x3F);
return 0xFFFD;
}
constexpr auto decode_utf8(std::string_view text) -> std::vector<CodepointSpan>
{
std::vector<CodepointSpan> spans;
usize i = 0;
spans.reserve(text.size());
while (i < text.size()) {
u8 b = static_cast<u8>(text[i]);
usize len = 1;
if (b < 0x80)
len = 1;
else if ((b & 0xE0) == 0xC0)
len = 2;
else if ((b & 0xF0) == 0xE0)
len = 3;
else if ((b & 0xF8) == 0xF0)
len = 4;
if (i + len > text.size())
len = 1;
u32 cp = utf8_rune_from_first(text.data() + i);
spans.push_back({ cp, i, i + len });
i += len;
}
return spans;
}
auto encode_utf8(u32 cp) -> std::string
{
char buf[5] = { 0, 0, 0, 0, 0 };
int len = 0;
if (cp <= 0x7F) {
buf[len++] = static_cast<char>(cp);
} else if (cp <= 0x7FF) {
buf[len++] = static_cast<char>(0xC0 | (cp >> 6));
buf[len++] = static_cast<char>(0x80 | (cp & 0x3F));
} else if (cp <= 0xFFFF) {
if (cp >= 0xD800 && cp <= 0xDFFF)
return {};
buf[len++] = static_cast<char>(0xE0 | (cp >> 12));
buf[len++] = static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
buf[len++] = static_cast<char>(0x80 | (cp & 0x3F));
} else if (cp <= 0x10FFFF) {
buf[len++] = static_cast<char>(0xF0 | (cp >> 18));
buf[len++] = static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
buf[len++] = static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
buf[len++] = static_cast<char>(0x80 | (cp & 0x3F));
} else {
return {};
}
return std::string(buf, len);
}
auto rune_index_for_byte(std::string_view text, usize byte_offset) -> int
{
auto spans { decode_utf8(text) };
int idx = 0;
for (auto const &span : spans) {
if (span.start >= byte_offset)
break;
idx++;
}
if (byte_offset >= text.size())
idx = static_cast<int>(spans.size());
return idx;
}
auto clamp_preedit_index(int value, usize text_size) -> usize
{
if (value < 0)
return 0;
auto const as_size { static_cast<usize>(value) };
return std::min(as_size, text_size);
}
constexpr float HORIZONTAL_PADDING = 6.0f;
constexpr float VERTICAL_PADDING = 4.0f;
constexpr double CARET_BLINK_INTERVAL = 0.5;
constexpr float CARET_DESCENT_FRACTION = 0.25f;
} // namespace
ImGui::ImGui(std::shared_ptr<TextRenderer> text_renderer)
: m_text_renderer(text_renderer)
{
}
void ImGui::begin(u32 const rune, bool ctrl, bool shift,
std::string_view const clipboard,
std::function<void(std::string_view const &)> clipboard_set)
{
m_rune = rune;
m_ctrl = ctrl;
m_shift = shift;
m_clipboard = clipboard;
m_clipboard_set = clipboard_set;
}
void ImGui::end()
{
m_rune = false;
m_ctrl = false;
m_shift = false;
m_clipboard = {};
m_clipboard_set = nullptr;
}
void ImGui::set_font(FontHandle font) { m_font = font; }
auto ImGui::focused_text_input() const -> std::optional<usize>
{
if (m_focused_id == 0)
return std::nullopt;
return m_focused_id;
}
auto ImGui::text_input_surrounding(usize id, std::pmr::string const &str) const
-> std::optional<TextInputSurrounding>
{
auto it { m_ti_states.find(id) };
if (it == m_ti_states.end())
return std::nullopt;
TextInputSurrounding info;
info.text.assign(str.data(), str.size());
info.caret_byte = std::min(it->second.caret_byte, str.size());
info.cursor = static_cast<int>(info.caret_byte);
info.anchor = static_cast<int>(info.caret_byte);
return info;
}
auto ImGui::text_input_cursor(usize id) const -> std::optional<TextInputCursor>
{
auto it { m_ti_states.find(id) };
if (it == m_ti_states.end())
return std::nullopt;
TextInputCursor cursor;
cursor.rect = it->second.caret_rect;
cursor.visible
= it->second.caret_visible && !it->second.preedit_cursor_hidden;
return cursor;
}
void ImGui::ime_commit_text(std::pmr::string &str, std::string_view text)
{
if (m_focused_id == 0)
return;
auto it { m_ti_states.find(m_focused_id) };
if (it == m_ti_states.end())
return;
auto &state { it->second };
usize insert_pos = std::min(state.caret_byte, str.size());
if (!text.empty())
str.insert(insert_pos, text);
state.caret_byte = insert_pos + text.size();
std::string_view const view(str.data(), str.size());
state.current_rune_idx = rune_index_for_byte(view, state.caret_byte);
state.caret_timer = 0.0;
state.caret_visible = true;
state.external_change = true;
}
void ImGui::ime_delete_surrounding(
std::pmr::string &str, usize before, usize after)
{
if (m_focused_id == 0)
return;
auto it { m_ti_states.find(m_focused_id) };
if (it == m_ti_states.end())
return;
auto &state { it->second };
usize caret_byte { std::min(state.caret_byte, str.size()) };
usize start { before > caret_byte ? 0 : caret_byte - before };
usize end { std::min(caret_byte + after, str.size()) };
if (end > start) {
str.erase(start, end - start);
state.caret_byte = start;
std::string_view const view(str.data(), str.size());
state.current_rune_idx = rune_index_for_byte(view, state.caret_byte);
state.caret_timer = 0.0;
state.caret_visible = true;
state.external_change = true;
}
}
void ImGui::ime_set_preedit(std::string text, int cursor_begin, int cursor_end)
{
if (m_focused_id == 0)
return;
auto it { m_ti_states.find(m_focused_id) };
if (it == m_ti_states.end())
return;
auto &state { it->second };
state.preedit_text = std::move(text);
state.preedit_cursor_hidden = (cursor_begin == -1 && cursor_end == -1);
usize const size = state.preedit_text.size();
if (state.preedit_cursor_hidden) {
state.preedit_cursor_begin = 0;
state.preedit_cursor_end = 0;
} else {
auto begin_clamped { clamp_preedit_index(cursor_begin, size) };
auto end_clamped { clamp_preedit_index(cursor_end, size) };
state.preedit_cursor_begin = static_cast<int>(begin_clamped);
state.preedit_cursor_end = static_cast<int>(end_clamped);
}
state.preedit_active
= !state.preedit_text.empty() || !state.preedit_cursor_hidden;
if (state.preedit_active) {
state.caret_timer = 0.0;
state.caret_visible = !state.preedit_cursor_hidden;
}
}
void ImGui::ime_clear_preedit()
{
if (m_focused_id == 0)
return;
auto it { m_ti_states.find(m_focused_id) };
if (it == m_ti_states.end())
return;
auto &state { it->second };
state.preedit_text.clear();
state.preedit_cursor_begin = 0;
state.preedit_cursor_end = 0;
state.preedit_active = false;
state.preedit_cursor_hidden = false;
state.caret_visible = true;
state.caret_timer = 0.0;
}
size_t utf8_length(std::string_view const &s)
{
size_t count = std::count_if(
s.begin(), s.end(), [](auto const &c) { return (c & 0xC0) != 0x80; });
return count;
}
auto ImGui::text_input(usize id, std::pmr::string &str, Rectangle rec,
TextInputOptions options) -> std::bitset<2>
{
assert(id != 0);
assert(
m_font.has_value() && "ImGui font must be set before using text input");
bool submitted { false };
bool changed { false };
auto &state { m_ti_states[id] };
assert(!options.multiline && "Multiline not yet implemented.");
if (m_focused_id == 0)
m_focused_id = id;
if (style().font_size > rec.height) {
TraceLog(LOG_WARNING,
std::format("Text size for text input {} is bigger than height ({} "
"> {}). Clipping will occur.",
id, style().font_size, rec.height)
.c_str());
}
std::string_view str_view(str.data(), str.size());
auto spans { decode_utf8(str_view) };
auto is_space = [](u32 cp) -> bool {
if (cp == '\n' || cp == '\r' || cp == '\t' || cp == '\v' || cp == '\f')
return true;
if (cp <= 0x7F)
return std::isspace(static_cast<unsigned char>(cp)) != 0;
return false;
};
auto clamp_cursor = [&]() -> usize {
int const max_idx = static_cast<int>(spans.size());
state.current_rune_idx = std::clamp(state.current_rune_idx, 0, max_idx);
if (state.current_rune_idx == max_idx)
return str.size();
return spans[(usize)state.current_rune_idx].start;
};
usize caret_byte = clamp_cursor();
auto refresh_spans = [&]() {
str_view = std::string_view(str.data(), str.size());
spans = decode_utf8(str_view);
caret_byte = clamp_cursor();
};
auto erase_range = [&](usize byte_begin, usize byte_end) {
if (byte_end > byte_begin && byte_begin < str.size()) {
str.erase(byte_begin, byte_end - byte_begin);
changed = true;
}
};
auto selection_range_bytes
= [&](int a_idx, int b_idx) -> std::pair<usize, usize> {
int lo = std::max(0, std::min(a_idx, b_idx));
int hi = std::max(0, std::max(a_idx, b_idx));
usize byte_begin
= (lo >= (int)spans.size()) ? str.size() : spans[(usize)lo].start;
usize byte_end
= (hi >= (int)spans.size()) ? str.size() : spans[(usize)hi].start;
return { byte_begin, byte_end };
};
auto erase_selection_if_any = [&]() -> bool {
if (!state.has_selection(state.current_rune_idx))
return false;
auto [b, e] = selection_range_bytes(
state.sel_anchor_idx, state.current_rune_idx);
if (e > b) {
str.erase(b, e - b);
changed = true;
refresh_spans();
state.current_rune_idx = rune_index_for_byte(str_view, b);
state.clear_selection();
}
return true;
};
auto move_left_word = [&]() {
while (state.current_rune_idx > 0
&& is_space(spans[(usize)(state.current_rune_idx - 1)].codepoint))
state.current_rune_idx--;
while (state.current_rune_idx > 0
&& !is_space(spans[(usize)(state.current_rune_idx - 1)].codepoint))
state.current_rune_idx--;
};
auto move_right_word = [&]() {
while (state.current_rune_idx < (int)spans.size()
&& is_space(spans[(usize)state.current_rune_idx].codepoint))
state.current_rune_idx++;
while (state.current_rune_idx < (int)spans.size()
&& !is_space(spans[(usize)state.current_rune_idx].codepoint))
state.current_rune_idx++;
};
bool caret_activity = false;
if (m_focused_id == id && m_rune != 0) {
bool request_refresh = false;
auto handle_backspace = [&]() {
if (state.current_rune_idx <= 0
|| state.current_rune_idx > (int)spans.size())
return;
if (m_ctrl) {
int idx = state.current_rune_idx, scan = idx - 1;
while (scan >= 0 && is_space(spans[(usize)scan].codepoint))
scan--;
while (scan >= 0 && !is_space(spans[(usize)scan].codepoint))
scan--;
int start_idx = std::max(scan + 1, 0);
usize b = spans[(usize)start_idx].start;
usize e = (idx >= (int)spans.size()) ? str.size()
: spans[(usize)idx].start;
erase_range(b, e);
state.current_rune_idx = start_idx;
} else {
auto const &prev = spans[(usize)(state.current_rune_idx - 1)];
erase_range(prev.start, prev.end);
state.current_rune_idx--;
}
request_refresh = true;
};
auto handle_delete = [&]() {
if (state.current_rune_idx < 0
|| state.current_rune_idx >= (int)spans.size()) {
if (!m_ctrl)
return;
}
int idx = state.current_rune_idx;
if (m_ctrl) {
int scan = idx;
while (scan < (int)spans.size()
&& is_space(spans[(usize)scan].codepoint))
scan++;
while (scan < (int)spans.size()
&& !is_space(spans[(usize)scan].codepoint))
scan++;
usize b = (idx < (int)spans.size()) ? spans[(usize)idx].start
: str.size();
usize e = (scan < (int)spans.size()) ? spans[(usize)scan].start
: str.size();
erase_range(b, e);
} else if (idx < (int)spans.size()) {
auto const &curr = spans[(usize)idx];
erase_range(curr.start, curr.end);
}
request_refresh = true;
};
bool extend = m_shift;
switch (m_rune) {
case 1: // Left
if (!extend)
state.clear_selection();
if (state.current_rune_idx > 0) {
if (extend && state.sel_anchor_idx == -1)
state.sel_anchor_idx = state.current_rune_idx;
if (m_ctrl)
move_left_word();
else
state.current_rune_idx--;
caret_byte = clamp_cursor();
}
break;
case 4: // Right
if (!extend)
state.clear_selection();
if (state.current_rune_idx < (int)spans.size()) {
if (extend && state.sel_anchor_idx == -1)
state.sel_anchor_idx = state.current_rune_idx;
if (m_ctrl)
move_right_word();
else
state.current_rune_idx++;
caret_byte = clamp_cursor();
}
break;
case 3: // Up -> home
if (!extend)
state.clear_selection();
if (extend && state.sel_anchor_idx == -1)
state.sel_anchor_idx = state.current_rune_idx;
state.current_rune_idx = 0;
caret_byte = clamp_cursor();
break;
case 2: // Down -> end
if (!extend)
state.clear_selection();
if (extend && state.sel_anchor_idx == -1)
state.sel_anchor_idx = state.current_rune_idx;
state.current_rune_idx = (int)spans.size();
caret_byte = clamp_cursor();
break;
case 8: // Backspace
if (erase_selection_if_any()) {
request_refresh = true;
break;
}
handle_backspace();
break;
case 0x7F: // Delete
if (erase_selection_if_any()) {
request_refresh = true;
break;
}
handle_delete();
break;
case 'a':
if (m_ctrl) {
state.sel_anchor_idx = 0;
state.current_rune_idx = (int)spans.size();
request_refresh = true;
break;
}
[[fallthrough]];
case 'c':
if (m_ctrl) {
if (state.has_selection(state.current_rune_idx)
&& m_clipboard_set) {
auto [b, e] = selection_range_bytes(
state.sel_anchor_idx, state.current_rune_idx);
m_clipboard_set(std::string_view(str.data() + b, e - b));
}
break;
}
[[fallthrough]];
case 'x':
if (m_ctrl) {
if (state.has_selection(state.current_rune_idx)
&& m_clipboard_set) {
auto [b, e] = selection_range_bytes(
state.sel_anchor_idx, state.current_rune_idx);
m_clipboard_set(std::string_view(str.data() + b, e - b));
str.erase(b, e - b);
changed = true;
request_refresh = true;
refresh_spans();
state.current_rune_idx = rune_index_for_byte(str_view, b);
state.clear_selection();
}
break;
}
[[fallthrough]];
case 'v':
if (m_ctrl && !m_clipboard.empty()) {
erase_selection_if_any();
if (!options.multiline) {
std::string clip2;
clip2.reserve(m_clipboard.size());
std::copy_if(m_clipboard.begin(), m_clipboard.end(),
clip2.begin(),
[](char ch) { return ch != '\n' && ch != '\r'; });
str.insert(caret_byte, clip2);
state.current_rune_idx += (int)utf8_length(clip2);
} else {
str.insert(caret_byte, m_clipboard);
state.current_rune_idx += (int)utf8_length(m_clipboard);
}
changed = true;
request_refresh = true;
break;
} else {
goto insert_printable;
}
break;
case '\r':
case '\n':
if (options.multiline) {
erase_selection_if_any();
auto encoded { encode_utf8('\n') };
if (!encoded.empty()) {
str.insert(caret_byte, encoded);
state.current_rune_idx++;
changed = true;
request_refresh = true;
}
} else {
submitted = true;
}
break;
default:
insert_printable:
if (m_rune >= 0x20) {
erase_selection_if_any();
auto encoded { encode_utf8(m_rune) };
if (!encoded.empty()) {
str.insert(caret_byte, encoded);
state.current_rune_idx++;
changed = true;
request_refresh = true;
}
}
break;
}
if (request_refresh)
refresh_spans();
else
caret_byte = clamp_cursor();
caret_activity = true;
}
state.caret_byte = caret_byte;
double const dt = (double)GetFrameTime();
if (m_focused_id == id) {
if (state.preedit_active && state.preedit_cursor_hidden) {
state.caret_visible = false;
state.caret_timer = 0.0;
} else if (caret_activity) {
state.caret_timer = 0.0;
state.caret_visible = true;
} else {
if (state.caret_timer == 0.0)
state.caret_visible = true;
state.caret_timer += dt;
if (state.caret_timer >= CARET_BLINK_INTERVAL) {
int toggles = (int)(state.caret_timer / CARET_BLINK_INTERVAL);
state.caret_timer
= std::fmod(state.caret_timer, CARET_BLINK_INTERVAL);
if (toggles % 2 == 1)
state.caret_visible = !state.caret_visible;
}
}
} else {
state.caret_visible = false;
state.caret_timer = 0.0;
}
Color const bg_col { 16, 16, 16, 100 };
Color const border_col { 220, 220, 220, 180 };
DrawRectangleRec(rec, bg_col);
DrawRectangleLinesEx(rec, 1.0f, border_col);
float const text_top = rec.y + VERTICAL_PADDING;
float const baseline_y = text_top + style().font_size;
float const max_caret_h
= std::max(0.0f, rec.height - 2.0f * VERTICAL_PADDING);
float caret_height = style().font_size * (1.0f + CARET_DESCENT_FRACTION);
caret_height = std::min(
caret_height, max_caret_h > 0.0f ? max_caret_h : caret_height);
if (caret_height <= 0.0f)
caret_height = style().font_size;
float caret_top = baseline_y - style().font_size;
float const min_top = rec.y + VERTICAL_PADDING;
float const max_top = rec.y + rec.height - VERTICAL_PADDING - caret_height;
caret_top = std::clamp(caret_top, min_top, max_top);
float caret_bottom = caret_top + caret_height;
float const desired_bottom
= baseline_y + style().font_size * CARET_DESCENT_FRACTION;
if (caret_bottom < desired_bottom) {
float const adjust = desired_bottom - caret_bottom;
caret_top = std::min(caret_top + adjust, max_top);
caret_bottom = caret_top + caret_height;
}
state.cursor_position.y = caret_top;
float const available_width
= std::max(0.0f, rec.width - 2.0f * HORIZONTAL_PADDING);
float const base_y = px_pos(baseline_y);
int const font_px = (int)style().font_size;
Vector2 prefix_metrics { 0.0f, 0.0f };
{
std::string_view prefix(str.data(), caret_byte);
if (m_text_renderer)
prefix_metrics
= m_text_renderer->measure_text(*m_font, prefix, font_px);
}
Vector2 caret_preedit_metrics { 0.0f, 0.0f };
bool const has_preedit = state.preedit_active
&& (!state.preedit_text.empty() || !state.preedit_cursor_hidden);
if (has_preedit && m_text_renderer) {
auto pe_end = clamp_preedit_index(
state.preedit_cursor_end, state.preedit_text.size());
caret_preedit_metrics = m_text_renderer->measure_text(*m_font,
std::string_view(state.preedit_text.data(), (usize)pe_end),
font_px);
}
Vector2 full_metrics { 0.0f, 0.0f };
{
std::string display;
display.reserve(
str.size() + (has_preedit ? state.preedit_text.size() : 0));
display.append(std::string_view(str.data(), caret_byte));
if (has_preedit)
display.append(state.preedit_text);
display.append(
std::string_view(str.data() + caret_byte, str.size() - caret_byte));
if (m_text_renderer)
full_metrics = m_text_renderer->measure_text(*m_font,
std::string_view(display.data(), display.size()), font_px);
}
float caret_offset = prefix_metrics.x + caret_preedit_metrics.x;
state.cursor_position.x = caret_offset;
if (full_metrics.x <= available_width) {
state.scroll_offset.x = 0.0f;
} else {
float &scroll = state.scroll_offset.x;
float caret_local = caret_offset - scroll;
float const pad = 8.0f;
if (caret_local > available_width - pad)
scroll = caret_offset - (available_width - pad);
else if (caret_local < pad)
scroll = caret_offset - pad;
scroll = std::clamp(
scroll, 0.0f, std::max(0.0f, full_metrics.x - available_width));
}
state.scroll_offset.y = 0.0f;
float const origin = rec.x + HORIZONTAL_PADDING - state.scroll_offset.x;
BeginScissorMode(rec.x, rec.y, rec.width, rec.height);
{
if (m_font.has_value() && m_text_renderer) {
Color const &text_color = style().text_color;
std::string display;
display.reserve(
str.size() + (has_preedit ? state.preedit_text.size() : 0));
display.append(std::string_view(str.data(), caret_byte));
if (has_preedit)
display.append(state.preedit_text);
display.append(std::string_view(
str.data() + caret_byte, str.size() - caret_byte));
m_text_renderer->draw_text(*m_font,
std::string_view(display.data(), display.size()),
{ origin, base_y }, font_px, text_color);
if (state.has_selection(state.current_rune_idx)) {
auto [sb, se] = selection_range_bytes(
state.sel_anchor_idx, state.current_rune_idx);
Vector2 sel_prefix = m_text_renderer->measure_text(
*m_font, std::string_view(str.data(), sb), font_px);
Vector2 sel_width = m_text_renderer->measure_text(*m_font,
std::string_view(str.data() + sb, se - sb), font_px);
Rectangle sel_rect { std::floor(origin + sel_prefix.x + 0.5f),
std::floor(caret_top + 0.5f),
std::max(1.0f, sel_width.x) + 1,
std::max(1.0f, std::round(caret_height)) };
DrawRectangleRec(sel_rect, style().selection_color);
m_text_renderer->draw_text(*m_font,
std::string_view(str.data() + sb, se - sb),
{ origin + sel_prefix.x, base_y }, font_px,
style().selection_text_color);
}
if (m_focused_id == id && state.caret_visible) {
float const caret_x = std::floor(origin + caret_offset + 0.5f);
Vector2 const p0 { caret_x, std::floor(caret_top + 0.5f) };
Vector2 const p1 { caret_x,
std::floor((caret_top + caret_height) + 0.5f) };
DrawLineV(p0, p1, text_color);
}
}
}
EndScissorMode();
if (state.external_change) {
changed = true;
state.external_change = false;
}
return std::bitset<2> { static_cast<unsigned long long>(
(submitted ? 1 : 0) | (changed ? 2 : 0)) };
}
auto ImGui::list_view(usize id, Rectangle bounds, usize elements,
std::function<Vector2(usize i)> draw_cb, ListViewOptions options) -> bool
{
auto const &state { m_lv_states[id] };
bool submitted { false };
m_next_lv_next = false;
m_next_lv_previous = false;
m_next_lv_clear = false;
BeginScissorMode(bounds.x, bounds.y, bounds.width, bounds.height);
EndScissorMode();
m_prev_lv_selected_item = state.selected_item;
return submitted;
}
} // namespace Waylight