diff options
Diffstat (limited to 'src/ui')
| -rw-r--r-- | src/ui/animated_image.cpp | 3 | ||||
| -rw-r--r-- | src/ui/icons.h | 41 | ||||
| -rw-r--r-- | src/ui/macro_expander.cpp | 40 | ||||
| -rw-r--r-- | src/ui/preferences.cpp | 7 | ||||
| -rw-r--r-- | src/ui/preferences.h | 6 | ||||
| -rw-r--r-- | src/ui/source_view.cpp | 53 | ||||
| -rw-r--r-- | src/ui/source_view.h | 5 | ||||
| -rw-r--r-- | src/ui/theme.cpp | 264 | ||||
| -rw-r--r-- | src/ui/theme.h | 62 | ||||
| -rw-r--r-- | src/ui/ui.cpp | 879 | ||||
| -rw-r--r-- | src/ui/ui.h | 31 | ||||
| -rw-r--r-- | src/ui/widgets.cpp | 361 | ||||
| -rw-r--r-- | src/ui/widgets.h | 56 |
13 files changed, 1433 insertions, 375 deletions
diff --git a/src/ui/animated_image.cpp b/src/ui/animated_image.cpp index 94ad8bc..50f9336 100644 --- a/src/ui/animated_image.cpp +++ b/src/ui/animated_image.cpp @@ -47,8 +47,7 @@ namespace Hsdbg if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue; - const unsigned char* near = - rgba + (static_cast<std::size_t>(ny) * width + nx) * 4; + const unsigned char* near = rgba + (static_cast<std::size_t>(ny) * width + nx) * 4; if (near[3] == 0) continue; diff --git a/src/ui/icons.h b/src/ui/icons.h new file mode 100644 index 0000000..deca7bd --- /dev/null +++ b/src/ui/icons.h @@ -0,0 +1,41 @@ +#pragma once + +// Phosphor icon glyphs (regular weight). Generated to match assets/fonts/Phosphor.ttf. +// Each value is the UTF-8 encoding of a Private-Use-Area codepoint; merge the font +// over the ui font and print these strings like any other text. + +namespace Hsdbg +{ + // merge range covering every glyph used below + inline constexpr unsigned int ICON_MIN_PH = 0xE000; + inline constexpr unsigned int ICON_MAX_PH = 0xEE00; +} + +#define ICON_PH_PLAY "\xEE\x8F\x90" // U+E3D0 +#define ICON_PH_PAUSE "\xEE\x8E\x9E" // U+E39E +#define ICON_PH_STOP "\xEE\x91\xAC" // U+E46C +#define ICON_PH_STEP_OVER "\xEE\x80\x96" // U+E016 +#define ICON_PH_STEP_INTO "\xEE\x81\x9C" // U+E05C +#define ICON_PH_STEP_OUT "\xEE\x81\xA6" // U+E066 +#define ICON_PH_FOLDER "\xEE\x89\x8A" // U+E24A +#define ICON_PH_FOLDER_OPEN "\xEE\x89\x96" // U+E256 +#define ICON_PH_FILE "\xEE\x88\xB0" // U+E230 +#define ICON_PH_FILE_CODE "\xEE\xA4\x94" // U+E914 +#define ICON_PH_CARET_RIGHT "\xEE\x84\xBA" // U+E13A +#define ICON_PH_CARET_DOWN "\xEE\x84\xB6" // U+E136 +#define ICON_PH_STACK "\xEE\x91\xA6" // U+E466 +#define ICON_PH_BUG "\xEE\x97\xB4" // U+E5F4 +#define ICON_PH_SEARCH "\xEE\x8C\x8C" // U+E30C +#define ICON_PH_GEAR "\xEE\x89\xB2" // U+E272 +#define ICON_PH_CPU "\xEE\x98\x90" // U+E610 +#define ICON_PH_LIST "\xEE\x8B\xB0" // U+E2F0 +#define ICON_PH_FUNCTION "\xEE\xAF\xA4" // U+EBE4 +#define ICON_PH_SLIDERS "\xEE\x90\xB4" // U+E434 +#define ICON_PH_TERMINAL "\xEE\xAB\xA8" // U+EAE8 +#define ICON_PH_DOT "\xEE\xB3\x9E" // U+ECDE +#define ICON_PH_CIRCLE "\xEE\x86\x8A" // U+E18A +#define ICON_PH_EYE "\xEE\x88\xA0" // U+E220 +#define ICON_PH_CUBE "\xEE\x87\x9A" // U+E1DA +#define ICON_PH_GAUGE "\xEE\x98\xA8" // U+E628 +#define ICON_PH_BRANCH "\xEE\x89\xB8" // U+E278 +#define ICON_PH_BRACKETS "\xEE\xA1\xA0" // U+E860 diff --git a/src/ui/macro_expander.cpp b/src/ui/macro_expander.cpp index a2ca62c..8b90027 100644 --- a/src/ui/macro_expander.cpp +++ b/src/ui/macro_expander.cpp @@ -23,10 +23,8 @@ namespace Hsdbg return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == '_'; } - // pull one preprocessing token out of text starting at index. strings and - // character literals are kept whole, and multi-character punctuators the - // expander actually reasons about (##, ::, ->) are recognised so they do - // not split. everything else falls back to a single-character punctuator + // one preprocessing token from text at index: strings / char literals kept + // whole, multi-char punctuators (##, ::, ->) recognised, else a single char auto lex_one(std::string_view text, size_t& at) -> PpToken { while (at < text.size() && std::isspace(static_cast<unsigned char>(text[at])) != 0) @@ -239,9 +237,8 @@ namespace Hsdbg auto expand_full(const MacroTable& table, std::vector<PpToken> tokens) -> std::vector<PpToken>; - // raw holds each argument exactly as written (what # and ## must see); - // expanded holds the same arguments after full macro expansion (what a - // plain parameter reference is replaced with, per the standard) + // raw = each argument as written (for # and ##); expanded = the same arguments + // after full macro expansion (for a plain parameter reference) auto substitute(const MacroDef& def, const ArgList& raw, const ArgList& expanded) -> std::vector<PpToken> { @@ -342,22 +339,19 @@ namespace Hsdbg // a parameter next to ## on either side keeps its raw argument; // everywhere else it takes the fully expanded one - const bool paste_operand = - (i + 1 < body.size() && body[i + 1].text == "##") || - (i > 0 && body[i - 1].text == "##"); + const bool paste_operand = (i + 1 < body.size() && body[i + 1].text == "##") || + (i > 0 && body[i - 1].text == "##"); if (const int index = param_index(def, tok.text); index >= 0) { - const std::vector<PpToken> value = - paste_operand ? raw_tokens(index) : expanded_tokens(index); + const std::vector<PpToken> value = paste_operand ? raw_tokens(index) : expanded_tokens(index); out.insert(out.end(), value.begin(), value.end()); continue; } if (def.variadic && tok.text == "__VA_ARGS__") { - const std::vector<PpToken> value = - paste_operand ? raw_varargs() : expanded_varargs(); + const std::vector<PpToken> value = paste_operand ? raw_varargs() : expanded_varargs(); out.insert(out.end(), value.begin(), value.end()); continue; } @@ -386,9 +380,8 @@ namespace Hsdbg } } - // gather the arguments of a function-like call. open is the index of '('; - // on success end is set past the matching ')'. returns false if the parens - // never balance, in which case the name is left as a plain identifier + // gather a function-like call's arguments; `open` is the '(' index, `end` is + // set past the ')'. false if the parens never balance auto collect_args(const std::vector<PpToken>& tokens, size_t open, ArgList& args, size_t& end) -> bool { @@ -435,13 +428,11 @@ namespace Hsdbg return false; } - // whether a call supplies enough arguments for a definition. a zero-arg - // macro invoked as NAME() lexes as a single empty argument, so treat that - // as a match + // enough arguments for the definition? a zero-arg macro called as NAME() lexes + // as one empty argument, so treat that as a match auto arity_ok(const MacroDef& def, const ArgList& args) -> bool { - const size_t provided = - args.size() == 1 && args.front().empty() ? 0 : args.size(); + const size_t provided = args.size() == 1 && args.front().empty() ? 0 : args.size(); if (def.variadic) return provided >= def.params.size(); @@ -449,9 +440,8 @@ namespace Hsdbg return provided == def.params.size(); } - // one expansion layer: every eligible macro name in the stream is replaced - // once, and the tokens it produced are left for the next layer to rescan. - // that "one layer per pass" rule is what the level slider steps through + // one expansion layer: every eligible macro name is replaced once and its + // output left for the next layer to rescan — what the level slider steps auto expand_pass(const MacroTable& table, const std::vector<PpToken>& in, std::vector<PpToken>& out, std::vector<std::string>& expanded) -> bool { diff --git a/src/ui/preferences.cpp b/src/ui/preferences.cpp index 0b0d70d..c74f0d4 100644 --- a/src/ui/preferences.cpp +++ b/src/ui/preferences.cpp @@ -54,7 +54,9 @@ namespace Hsdbg const std::string_view key(line.data(), equals); const std::string_view value(line.data() + equals + 1, line.size() - equals - 1); - if (key == "ui_scale") + if (key == "theme") + preferences.theme = std::string(value); + else if (key == "ui_scale") preferences.ui_scale = as_float(value, preferences.ui_scale); else if (key == "accent") std::sscanf(std::string(value).c_str(), "%f %f %f", @@ -109,7 +111,8 @@ namespace Hsdbg return std::to_string(c[0]) + ' ' + std::to_string(c[1]) + ' ' + std::to_string(c[2]); }; - file << "ui_scale=" << preferences.ui_scale << '\n' + file << "theme=" << preferences.theme << '\n' + << "ui_scale=" << preferences.ui_scale << '\n' << "accent=" << color(preferences.accent) << '\n' << "rounding=" << preferences.rounding << '\n' << "show_mascot=" << (preferences.show_mascot ? "true" : "false") << '\n' diff --git a/src/ui/preferences.h b/src/ui/preferences.h index 46a8c3f..b56a46b 100644 --- a/src/ui/preferences.h +++ b/src/ui/preferences.h @@ -1,6 +1,7 @@ #pragma once #include <filesystem> +#include <string> namespace Hsdbg { @@ -9,9 +10,10 @@ namespace Hsdbg struct Preferences { // appearance + std::string theme = "midnight"; // stem of the selected assets/themes/*.toml float ui_scale = 1.0f; // scales every font, 0.75 - 2.0 - float accent[3] = { 0.30f, 0.72f, 0.78f }; // the teal that runs through the theme - float rounding = 4.0f; // corner radius on frames, windows, tabs + float accent[3] = { 0.302f, 0.714f, 0.769f }; // seeded from the theme, then tweakable + float rounding = 6.0f; // corner radius on frames, windows, tabs bool show_mascot = true; // the crying-pepe in the toolbar float mascot_scale = 1.6f; // mascot height as a multiple of a button diff --git a/src/ui/source_view.cpp b/src/ui/source_view.cpp index 3c15c08..205fbca 100644 --- a/src/ui/source_view.cpp +++ b/src/ui/source_view.cpp @@ -98,9 +98,8 @@ namespace Hsdbg }); } - // splits every line into contiguous coloured spans. the whole file is - // walked in order so a block comment opened on one line stays open on the - // next, which a per-line pass could not know + // one pass over the whole file (not per-line) so a block comment opened on + // one line stays open on the next auto highlight_lines(const std::vector<std::string>& lines, Language language, const MacroTable& macros) -> std::vector<std::vector<SourceSpan>> @@ -264,8 +263,7 @@ namespace Hsdbg while (at < size && is_word(line[at])) ++at; - const std::string_view word = - std::string_view(line).substr(start, at - start); + const std::string_view word = std::string_view(line).substr(start, at - start); SyntaxKind kind = classify_word(word, language); // a plain identifier that names a #define is coloured as a @@ -274,9 +272,8 @@ namespace Hsdbg macros.find(word) != nullptr) kind = SyntaxKind::Macro; - // every word gets its own span, including plain identifiers: - // isolating them lets the source view hit-test a name under - // the cursor and show its live value + // every word gets its own span (identifiers included) so the + // view can hit-test a name under the cursor and show its value flush_default(start); emit(start, at - start, kind); run = at; @@ -382,10 +379,8 @@ namespace Hsdbg return text; } - // grab a macro invocation out of a line starting at the name: the name - // alone for an object-like use, or the name plus a balanced argument list - // for a function-like one. a call that runs off the end of the line falls - // back to the bare name rather than guessing where it closes + // grab a macro invocation from the name: bare name for object-like, name plus + // a balanced arg list for function-like; unbalanced falls back to the name auto capture_invocation(const std::string& line, uint32_t start) -> std::string { const size_t size = line.size(); @@ -504,25 +499,33 @@ namespace Hsdbg if (m_lines.empty()) { - ImGui::TextDisabled("no source file open"); + ImGui::TextDisabled("No source file open"); return; } + // the code and its line-number gutter render in the monospace face; the + // open bar above stays in the ui font + if (m_mono_font != nullptr) + ImGui::PushFont(m_mono_font, 0.0f); + draw_lines(debugger); + + if (m_mono_font != nullptr) + ImGui::PopFont(); } auto SourceView::draw_open_bar() -> void { - ImGui::SetNextItemWidth(-ImGui::CalcTextSize("open").x - ImGui::GetStyle().FramePadding.x * 4.0f); + ImGui::SetNextItemWidth(-ImGui::CalcTextSize("Open").x - ImGui::GetStyle().FramePadding.x * 4.0f); const bool submitted = ImGui::InputTextWithHint("##source_path", - "path to a source file", + "Path to a source file", &m_path_input, ImGuiInputTextFlags_EnterReturnsTrue); ImGui::SameLine(); - if (ImGui::Button("open") || submitted) + if (ImGui::Button("Open") || submitted) { if (const auto result = open(m_path_input); !result) { @@ -555,12 +558,11 @@ namespace Hsdbg m_scroll_to_highlight = false; } - // the selected frame's locals, but only when we are stopped in this very - // file (a non-zero highlight means show_frame put us here); used both to - // annotate lines and to answer a hover over a name - const std::span<const Variable> locals = - (debugger.is_stopped() && m_highlighted_line != 0) ? debugger.locals() - : std::span<const Variable>{}; + // the selected frame's locals, but only while stopped in this file (a non-zero + // highlight means show_frame put us here) + const std::span<const Variable> locals = debugger.is_stopped() && m_highlighted_line != 0 + ? debugger.locals() + : std::span<const Variable>{}; ImGuiListClipper clipper; clipper.Begin(static_cast<int>(m_lines.size()), text_height); @@ -695,9 +697,8 @@ namespace Hsdbg } } - // trailing live values: the locals that appear on this line, shown - // only up to the line the pc sits on, since anything past it has - // not run yet and would read as a stale or unset value + // live values for locals on this line, shown only up to the pc — + // anything past it has not run yet and would read stale if (!locals.empty() && line_number <= m_highlighted_line) { int shown = 0; @@ -778,7 +779,7 @@ namespace Hsdbg const size_t steps = expansion.levels.size() - 1; if (steps == 0) - ImGui::TextDisabled("no expansion"); + ImGui::TextDisabled("No expansion"); else if (!expansion.fully_expanded()) ImGui::TextDisabled("%zu+ levels — click to step through", steps); else diff --git a/src/ui/source_view.h b/src/ui/source_view.h index 4c2cbe4..7dd949d 100644 --- a/src/ui/source_view.h +++ b/src/ui/source_view.h @@ -10,6 +10,7 @@ #include <vector> struct ImDrawList; +struct ImFont; namespace Hsdbg { @@ -53,6 +54,9 @@ namespace Hsdbg auto set_syntax_color(size_t kind, unsigned int color) -> void { m_syntax_colors[kind] = color; } auto set_current_line_color(unsigned int color) -> void { m_current_line_color = color; } + // the monospace face the code (and its gutter) is rendered in + auto set_mono_font(ImFont* font) -> void { m_mono_font = font; } + auto path() const -> const std::filesystem::path& { return m_path; } auto line_count() const -> size_t { return m_lines.size(); } auto is_open() const -> bool { return !m_lines.empty(); } @@ -79,6 +83,7 @@ namespace Hsdbg auto draw_macro_span(ImDrawList* draw_list, const std::string& line, const SourceSpan& span) -> void; + ImFont* m_mono_font = nullptr; std::filesystem::path m_path; std::vector<std::string> m_lines; std::vector<std::vector<SourceSpan>> m_spans; diff --git a/src/ui/theme.cpp b/src/ui/theme.cpp new file mode 100644 index 0000000..bce82ff --- /dev/null +++ b/src/ui/theme.cpp @@ -0,0 +1,264 @@ +#include "ui/theme.h" + +#include "core/log.h" + +#include <algorithm> +#include <array> +#include <cctype> +#include <charconv> +#include <cstdint> +#include <fstream> +#include <string> +#include <string_view> +#include <unordered_map> + +namespace Hsdbg +{ + namespace + { + // a deliberately small TOML reader: enough for theme files (tables, one key + // per line, strings / numbers / bools / arrays, inline comments) without a + // dependency; anything unrecognised keeps the default + + auto trim(std::string_view text) -> std::string_view + { + while (!text.empty() && std::isspace(static_cast<unsigned char>(text.front()))) + text.remove_prefix(1); + while (!text.empty() && std::isspace(static_cast<unsigned char>(text.back()))) + text.remove_suffix(1); + + return text; + } + + // drop a trailing "# comment", but only when the '#' sits outside a + // quoted string, so a hex colour like "#88C0D0" survives + auto strip_comment(std::string_view line) -> std::string_view + { + bool in_quote = false; + for (size_t i = 0; i < line.size(); ++i) + { + if (line[i] == '"') + in_quote = !in_quote; + else if (line[i] == '#' && !in_quote) + return line.substr(0, i); + } + return line; + } + + auto unquote(std::string_view value) -> std::string_view + { + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') + return value.substr(1, value.size() - 2); + return value; + } + + auto hex_pair(std::string_view text) -> float + { + int value = 0; + std::from_chars(text.data(), text.data() + text.size(), value, 16); + return static_cast<float>(value) / 255.0f; + } + + // "#rgb", "#rrggbb" or "#rrggbbaa", or an array "[r, g, b(, a)]" of + // floats in 0..1; anything else leaves the fallback in place + auto parse_color(std::string_view value, ImVec4 fallback) -> ImVec4 + { + value = trim(unquote(trim(value))); + if (value.empty()) + return fallback; + + if (value.front() == '#') + { + value.remove_prefix(1); + ImVec4 out = fallback; + out.w = 1.0f; + + if (value.size() == 3) + { + const auto nibble = [&](size_t i) { + const char pair[2] = { value[i], value[i] }; + return hex_pair(std::string_view(pair, 2)); + }; + out.x = nibble(0); + out.y = nibble(1); + out.z = nibble(2); + return out; + } + + if (value.size() == 6 || value.size() == 8) + { + out.x = hex_pair(value.substr(0, 2)); + out.y = hex_pair(value.substr(2, 2)); + out.z = hex_pair(value.substr(4, 2)); + if (value.size() == 8) + out.w = hex_pair(value.substr(6, 2)); + return out; + } + + return fallback; + } + + if (value.front() == '[') + { + value.remove_prefix(1); + if (!value.empty() && value.back() == ']') + value.remove_suffix(1); + + std::array<float, 4> parts = { fallback.x, fallback.y, fallback.z, 1.0f }; + size_t count = 0; + while (!value.empty() && count < parts.size()) + { + const size_t comma = value.find(','); + const std::string_view token = trim(value.substr(0, comma)); + float parsed = parts[count]; + std::from_chars(token.data(), token.data() + token.size(), parsed); + parts[count++] = parsed; + if (comma == std::string_view::npos) + break; + value.remove_prefix(comma + 1); + } + + if (count >= 3) + return ImVec4(parts[0], parts[1], parts[2], parts[3]); + } + + return fallback; + } + + auto parse_float(std::string_view value, float fallback) -> float + { + value = trim(unquote(trim(value))); + float parsed = fallback; + std::from_chars(value.data(), value.data() + value.size(), parsed); + return parsed; + } + + using Section = std::unordered_map<std::string, std::string>; + using Document = std::unordered_map<std::string, Section>; + + auto parse_document(const std::filesystem::path& path, Document& out) -> bool + { + std::ifstream file(path); + if (!file.is_open()) + return false; + + std::string current; + std::string line; + while (std::getline(file, line)) + { + std::string_view view = trim(strip_comment(line)); + if (view.empty()) + continue; + + if (view.front() == '[') + { + const size_t close = view.find(']'); + if (close != std::string_view::npos) + current = std::string(trim(view.substr(1, close - 1))); + continue; + } + + const size_t equals = view.find('='); + if (equals == std::string_view::npos) + continue; + + const std::string key(trim(view.substr(0, equals))); + const std::string value(trim(view.substr(equals + 1))); + out[current][key] = value; + } + + return true; + } + + auto lookup(const Document& doc, std::string_view section, std::string_view key) + -> const std::string* + { + const auto s = doc.find(std::string(section)); + if (s == doc.end()) + return nullptr; + const auto k = s->second.find(std::string(key)); + return k == s->second.end() ? nullptr : &k->second; + } + } + + auto default_theme() -> Theme + { + return Theme{}; + } + + auto load_theme(const std::filesystem::path& path) -> Theme + { + Theme theme = default_theme(); + + Document doc; + if (!parse_document(path, doc)) + { + Log::warn("theme: could not read '{}'", path.string()); + return theme; + } + + if (const std::string* name = lookup(doc, "theme", "name")) + theme.name = std::string(unquote(trim(*name))); + + const auto color = [&](const char* section, const char* key, ImVec4& field) { + if (const std::string* value = lookup(doc, section, key)) + field = parse_color(*value, field); + }; + + color("palette", "text", theme.text); + color("palette", "text_dim", theme.text_dim); + color("palette", "bg", theme.bg); + color("palette", "bg_low", theme.bg_low); + color("palette", "bg_high", theme.bg_high); + color("palette", "surface", theme.surface); + color("palette", "border", theme.border); + color("palette", "accent", theme.accent); + + if (const std::string* value = lookup(doc, "style", "rounding")) + theme.rounding = parse_float(*value, theme.rounding); + if (const std::string* value = lookup(doc, "style", "window_border")) + theme.window_border = parse_float(*value, theme.window_border); + + color("syntax", "keyword", theme.syntax_keyword); + color("syntax", "type", theme.syntax_type); + color("syntax", "string", theme.syntax_string); + color("syntax", "number", theme.syntax_number); + color("syntax", "comment", theme.syntax_comment); + color("syntax", "preprocessor", theme.syntax_preprocessor); + color("syntax", "current_line", theme.current_line); + + return theme; + } + + auto list_themes(const std::filesystem::path& dir) -> std::vector<ThemeEntry> + { + std::vector<ThemeEntry> entries; + + std::error_code error; + if (!std::filesystem::is_directory(dir, error)) + return entries; + + for (const auto& item : std::filesystem::directory_iterator(dir, error)) + { + if (!item.is_regular_file() || item.path().extension() != ".toml") + continue; + + // the display name lives in the file; read just that rather than the + // full palette, since the picker only needs a label + Document doc; + std::string name = item.path().stem().string(); + if (parse_document(item.path(), doc)) + { + if (const std::string* value = lookup(doc, "theme", "name")) + name = std::string(unquote(trim(*value))); + } + + entries.push_back(ThemeEntry{ item.path().stem().string(), std::move(name), item.path() }); + } + + std::sort(entries.begin(), entries.end(), + [](const ThemeEntry& a, const ThemeEntry& b) { return a.name < b.name; }); + + return entries; + } +} diff --git a/src/ui/theme.h b/src/ui/theme.h new file mode 100644 index 0000000..798d9fc --- /dev/null +++ b/src/ui/theme.h @@ -0,0 +1,62 @@ +#pragma once + +#include <imgui.h> + +#include <filesystem> +#include <string> +#include <vector> + +namespace Hsdbg +{ + // a small semantic palette, not a full ImGuiCol table: the ui derives every + // interactive state and the accent from these level-based tokens (light or dark) + struct Theme + { + std::string name = "Midnight"; + + // neutral palette: the theme's identity + ImVec4 text = ImVec4(0.925f, 0.933f, 0.953f, 1.00f); // primary foreground + ImVec4 text_dim = ImVec4(0.514f, 0.545f, 0.620f, 1.00f); // muted labels + ImVec4 bg = ImVec4(0.082f, 0.086f, 0.110f, 1.00f); // window body + ImVec4 bg_low = ImVec4(0.063f, 0.067f, 0.086f, 1.00f); // deepest: titles, tracks + ImVec4 bg_high = ImVec4(0.118f, 0.125f, 0.157f, 1.00f); // raised: inputs, headers + ImVec4 surface = ImVec4(0.149f, 0.161f, 0.200f, 1.00f); // button base, grabs + ImVec4 border = ImVec4(0.212f, 0.227f, 0.278f, 1.00f); + + // seeded into the live preferences when the theme is chosen, so the accent / + // rounding / syntax pickers reflect it yet stay tweakable + ImVec4 accent = ImVec4(0.302f, 0.714f, 0.769f, 1.00f); + float rounding = 6.0f; + float window_border = 1.0f; + + // editor syntax colours, also copied into the live preferences on select + ImVec4 syntax_keyword = ImVec4(0.337f, 0.612f, 0.839f, 1.00f); + ImVec4 syntax_type = ImVec4(0.306f, 0.788f, 0.690f, 1.00f); + ImVec4 syntax_string = ImVec4(0.808f, 0.569f, 0.471f, 1.00f); + ImVec4 syntax_number = ImVec4(0.710f, 0.808f, 0.659f, 1.00f); + ImVec4 syntax_comment = ImVec4(0.416f, 0.600f, 0.333f, 1.00f); + ImVec4 syntax_preprocessor = ImVec4(0.773f, 0.525f, 0.753f, 1.00f); + ImVec4 current_line = ImVec4(0.227f, 0.282f, 0.180f, 1.00f); + }; + + // a theme file on disk: the stem is the stable id persisted in preferences, + // the name is what the picker shows + struct ThemeEntry + { + std::string id; // filename stem, e.g. "midnight" + std::string name; // display name from [theme].name, else the stem + std::filesystem::path path; + }; + + // the built-in fallback, used when no theme file can be read so the ui never + // comes up unstyled + auto default_theme() -> Theme; + + // every *.toml in the directory, sorted by display name; missing directory + // yields an empty list + auto list_themes(const std::filesystem::path& dir) -> std::vector<ThemeEntry>; + + // parse one theme file; any field the file omits keeps the default, and a + // file that cannot be opened returns default_theme() + auto load_theme(const std::filesystem::path& path) -> Theme; +} diff --git a/src/ui/ui.cpp b/src/ui/ui.cpp index 272b224..9a851a3 100644 --- a/src/ui/ui.cpp +++ b/src/ui/ui.cpp @@ -3,6 +3,8 @@ #include "core/log.h" #include "core/window.h" #include "debugger/debugger.h" +#include "ui/icons.h" +#include "ui/widgets.h" #include <imgui.h> #include <imgui_internal.h> @@ -28,19 +30,21 @@ namespace Hsdbg { namespace { - constexpr const char* PANEL_SOURCE = "source"; - constexpr const char* PANEL_BREAKPOINTS = "breakpoints"; - constexpr const char* PANEL_CALL_STACK = "call stack"; - constexpr const char* PANEL_THREADS = "threads"; - constexpr const char* PANEL_SOURCE_TREE = "source tree"; - constexpr const char* PANEL_LOCALS = "locals"; - constexpr const char* PANEL_WATCH = "watch"; - constexpr const char* PANEL_REGISTERS = "registers"; - constexpr const char* PANEL_SYMBOLS = "symbols"; - constexpr const char* PANEL_DISASSEMBLY = "disassembly"; - constexpr const char* PANEL_CONSOLE = "console"; - constexpr const char* PANEL_PROFILER = "profiler"; - constexpr const char* PANEL_MACROS = "macros"; + // Title Case label with a leading icon; the "###id" keeps the persistent + // dock id stable (hashes to the old lowercase name) so saved layouts match + constexpr const char* PANEL_SOURCE = ICON_PH_FILE_CODE " Source###source"; + constexpr const char* PANEL_BREAKPOINTS = ICON_PH_CIRCLE " Breakpoints###breakpoints"; + constexpr const char* PANEL_CALL_STACK = ICON_PH_STACK " Call Stack###call stack"; + constexpr const char* PANEL_THREADS = ICON_PH_BRANCH " Threads###threads"; + constexpr const char* PANEL_SOURCE_TREE = ICON_PH_FOLDER " Source Tree###source tree"; + constexpr const char* PANEL_LOCALS = ICON_PH_CUBE " Locals###locals"; + constexpr const char* PANEL_WATCH = ICON_PH_EYE " Watch###watch"; + constexpr const char* PANEL_REGISTERS = ICON_PH_CPU " Registers###registers"; + constexpr const char* PANEL_SYMBOLS = ICON_PH_FUNCTION " Symbols###symbols"; + constexpr const char* PANEL_DISASSEMBLY = ICON_PH_LIST " Disassembly###disassembly"; + constexpr const char* PANEL_CONSOLE = ICON_PH_TERMINAL " Console###console"; + constexpr const char* PANEL_PROFILER = ICON_PH_GAUGE " Profiler###profiler"; + constexpr const char* PANEL_MACROS = ICON_PH_BRACKETS " Macros###macros"; constexpr const char* LOAD_TARGET_POPUP = "load target"; @@ -64,9 +68,8 @@ namespace Hsdbg const ImU32 BREAKPOINT_DISABLED_COLOR = IM_COL32(120, 90, 90, 255); const ImU32 BREAKPOINT_HOVER_COLOR = IM_COL32(226, 84, 84, 90); - // folders read as structure, so they take a cool tint; the file that is - // currently open in the source view keeps its accent even when the row - // is not the selected one, so it stays easy to find in a long tree + // folders take a cool tint; the file open in the source view keeps its + // accent even when unselected, so it stays easy to find in a long tree const ImU32 SOURCE_FOLDER_COLOR = IM_COL32(150, 178, 214, 255); const ImU32 SOURCE_OPEN_FILE_COLOR = IM_COL32(126, 194, 126, 255); @@ -326,8 +329,7 @@ namespace Hsdbg break; std::error_code error; - const std::filesystem::path relative = - std::filesystem::relative(file.parent_path(), prefix, error); + const std::filesystem::path relative = std::filesystem::relative(file.parent_path(), prefix, error); const std::string text = relative.generic_string(); if (!error && (text.empty() || text == "." || !text.starts_with(".."))) @@ -458,10 +460,10 @@ namespace Hsdbg ImGui::TableSetupColumn("##gutter", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize, DISASSEMBLY_GUTTER_WIDTH); - ImGui::TableSetupColumn("address", ImGuiTableColumnFlags_WidthFixed, 120.0f); - ImGui::TableSetupColumn("mnemonic", ImGuiTableColumnFlags_WidthFixed, 70.0f); - ImGui::TableSetupColumn("operands"); - ImGui::TableSetupColumn("comment"); + ImGui::TableSetupColumn("Address", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("Mnemonic", ImGuiTableColumnFlags_WidthFixed, 70.0f); + ImGui::TableSetupColumn("Operands"); + ImGui::TableSetupColumn("Comment"); ImGui::TableHeadersRow(); const float row_height = ImGui::GetTextLineHeightWithSpacing(); @@ -610,9 +612,10 @@ namespace Hsdbg io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; io.ConfigWindowsMoveFromTitleBarOnly = true; - // the scalable default font re-rasterizes at any size; the classic bitmap - // one only looks clean at 13px and turns to mush when the ui is scaled - io.Fonts->AddFontDefaultVector(); + // the bundled ui / mono / icon faces; these re-rasterize at any size, so + // scaling the ui keeps the text crisp + load_fonts(); + m_source_view.set_mono_font(m_font_mono); // a saved layout beats the built in one, so only build when there is none m_layout_built = io.IniFilename != nullptr && std::filesystem::exists(io.IniFilename); @@ -621,7 +624,12 @@ namespace Hsdbg m_preferences_path = io.IniFilename != nullptr ? std::filesystem::path(io.IniFilename).parent_path() / "hsdbg.ini" : std::filesystem::path("hsdbg.ini"); + + // on a first run nothing is saved, so let the theme seed accent / rounding / + // syntax; after that the saved values win, keeping the user's tweaks + const bool had_preferences = std::filesystem::exists(m_preferences_path); m_preferences = load_preferences(m_preferences_path); + load_selected_theme(!had_preferences); apply_style(); @@ -769,17 +777,24 @@ namespace Hsdbg ImGui::SetNextWindowSize(viewport->WorkSize); ImGui::SetNextWindowViewport(viewport->ID); - constexpr ImGuiWindowFlags root_flags = - ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus; + constexpr ImGuiWindowFlags root_flags = ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoDocking | + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoBringToFrontOnFocus | + ImGuiWindowFlags_NoNavFocus | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse; ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ROOT_PADDING); + // the ground between panels is the theme's deepest level, so the panels + // read as separated tiles once the docking separators widen into gaps + ImGui::PushStyleColor(ImGuiCol_WindowBg, m_theme.bg_low); + ImGui::Begin("##hsdbg_root", nullptr, root_flags); + ImGui::PopStyleColor(); ImGui::PopStyleVar(3); draw_menu_bar(debugger); @@ -792,9 +807,12 @@ namespace Hsdbg const float status_bar_height = ImGui::GetTextLineHeight() + ImGui::GetStyle().ItemSpacing.y * 3.0f; + // NoWindowMenuButton drops the little ▼ docking/collapse menu that imgui + // otherwise stamps into the corner of every panel's tab bar ImGui::DockSpace(dockspace_id, ImVec2(0.0f, ImGui::GetContentRegionAvail().y - status_bar_height), - ImGuiDockNodeFlags_PassthruCentralNode); + ImGuiDockNodeFlags_PassthruCentralNode | + static_cast<int>(ImGuiDockNodeFlags_NoWindowMenuButton)); if (!m_layout_built) { @@ -824,10 +842,8 @@ namespace Hsdbg if (std::optional<std::string> request = m_source_view.take_watch_request()) add_watch(debugger, std::move(*request)); - // let the profiling views appear on their own the moment work starts: - // the profiler (which now holds the flame chart, timings and graphs) when - // something is being sampled, traced or instrumented. the latch means it - // only springs up on the rising edge, so closing it makes it stay closed + // reveal the profiler on the rising edge of any profiling activity, but let + // the user close it again (the latch only fires once) const bool profiling_active = debugger.sampling_enabled() || debugger.instrumentation_active() || !debugger.traces().empty(); @@ -849,16 +865,12 @@ namespace Hsdbg draw_profiler_panel(debugger); draw_macros_panel(debugger); - if (m_visible.demo) - ImGui::ShowDemoWindow(&m_visible.demo); - draw_preferences_window(); draw_command_palette(debugger); - // a window claims its tab when it is first submitted, so this can only be - // asked for once every panel in the node exists. within each tabbed node - // the later focus wins, so raise the tab we want in front last; the very - // last call also lands the keyboard focus, which belongs on the source + // a window claims its tab only once submitted, so raise tabs after every + // panel exists; the later focus wins, and the last call takes keyboard + // focus — which belongs on the source if (m_select_default_tabs) { ImGui::SetWindowFocus(PANEL_SYMBOLS); @@ -910,13 +922,9 @@ namespace Hsdbg ImGui::DockBuilderAddNode(dockspace_id, ImGuiDockNodeFlags_DockSpace); ImGui::DockBuilderSetNodeSize(dockspace_id, ImGui::GetMainViewport()->WorkSize); - // four regions frame a central code view, each holding one stage of the - // debugging loop so related panels sit together instead of scattered: - // left navigator - where to go (files, symbols) over where execution - // currently is (call stack, threads) - // center code - source and disassembly, both tracking the pc - // right inspector - the selected frame's state: locals over registers - // bottom output - the console and the auxiliary read-outs + // four regions frame the central code view, each a stage of the loop: left + // navigator (files/symbols, call stack/threads), center code (source + + // disassembly), right inspector (locals/registers), bottom output ImGuiID center_id = dockspace_id; const ImGuiID left_id = ImGui::DockBuilderSplitNode(center_id, ImGuiDir_Left, 0.20f, nullptr, ¢er_id); const ImGuiID right_id = ImGui::DockBuilderSplitNode(center_id, ImGuiDir_Right, 0.24f, nullptr, ¢er_id); @@ -958,70 +966,76 @@ namespace Hsdbg if (!ImGui::BeginMenuBar()) return; - if (ImGui::BeginMenu("file")) + // brand wordmark in the bold face + ImGui::AlignTextToFramePadding(); + ImGui::PushFont(m_font_strong, 0.0f); + ImGui::TextUnformatted("hsdbg"); + ImGui::PopFont(); + ImGui::SameLine(0.0f, 18.0f); + + if (ImGui::BeginMenu("File")) { // a menu is its own popup, so the request has to be handed back to the // root window or BeginPopupModal never sees a matching id - if (ImGui::MenuItem("load target...")) + if (ImGui::MenuItem("Load Target…")) { m_target_input = debugger.target_path().string(); m_load_target_pending = true; } - if (ImGui::MenuItem("unload target", nullptr, false, debugger.has_target())) + if (ImGui::MenuItem("Unload Target", nullptr, false, debugger.has_target())) debugger.unload_target(); ImGui::Separator(); - if (ImGui::MenuItem("command palette...", "cmd+k")) + if (ImGui::MenuItem("Command Palette…", "Cmd+K")) m_palette_request = true; - ImGui::MenuItem("preferences...", nullptr, &m_show_preferences); + ImGui::MenuItem("Preferences…", nullptr, &m_show_preferences); ImGui::Separator(); - if (ImGui::MenuItem("quit", "cmd+q")) + if (ImGui::MenuItem("Quit", "Cmd+Q")) m_window->set_should_close(true); ImGui::EndMenu(); } - if (ImGui::BeginMenu("run")) + if (ImGui::BeginMenu("Run")) { const bool has_target = debugger.has_target(); - if (ImGui::MenuItem("continue", "f5", false, has_target)) + if (ImGui::MenuItem("Continue", "F5", false, has_target)) report(debugger.resume(), "continue"); - if (ImGui::MenuItem("pause", nullptr, false, debugger.is_running())) + if (ImGui::MenuItem("Pause", nullptr, false, debugger.is_running())) report(debugger.pause(), "pause"); - if (ImGui::MenuItem("stop", nullptr, false, has_target)) + if (ImGui::MenuItem("Stop", nullptr, false, has_target)) report(debugger.terminate(), "stop"); ImGui::Separator(); - const StepMode step_mode = - m_preferences.step_by_instruction ? StepMode::Instruction : StepMode::Line; + const StepMode step_mode = m_preferences.step_by_instruction ? StepMode::Instruction : StepMode::Line; - if (ImGui::MenuItem("step over", "f10", false, has_target)) - report(debugger.step_over(step_mode), "step over"); + if (ImGui::MenuItem("Step Over", "F10", false, has_target)) + report(debugger.step_over(step_mode), "Step Over"); - if (ImGui::MenuItem("step into", "f11", false, has_target)) - report(debugger.step_into(step_mode), "step into"); + if (ImGui::MenuItem("Step Into", "F11", false, has_target)) + report(debugger.step_into(step_mode), "Step Into"); - if (ImGui::MenuItem("step out", "shift+f11", false, has_target)) - report(debugger.step_out(), "step out"); + if (ImGui::MenuItem("Step Out", "Shift+F11", false, has_target)) + report(debugger.step_out(), "Step Out"); ImGui::Separator(); - if (ImGui::MenuItem("clear breakpoints", nullptr, false, !debugger.breakpoints().empty())) + if (ImGui::MenuItem("Clear Breakpoints", nullptr, false, !debugger.breakpoints().empty())) debugger.clear_breakpoints(); ImGui::EndMenu(); } - if (ImGui::BeginMenu("view")) + if (ImGui::BeginMenu("View")) { ImGui::MenuItem(PANEL_SOURCE, nullptr, &m_visible.source); ImGui::MenuItem(PANEL_SOURCE_TREE, nullptr, &m_visible.source_tree); @@ -1039,14 +1053,25 @@ namespace Hsdbg ImGui::Separator(); - if (ImGui::MenuItem("reset layout")) + if (ImGui::MenuItem("Reset Layout")) m_layout_built = false; - ImGui::MenuItem("imgui demo", nullptr, &m_visible.demo); - ImGui::EndMenu(); } + // a right-aligned command-palette affordance, echoing the ⌘K shortcut + const char* palette_hint = ICON_PH_SEARCH " Run a command Cmd+K"; + const float hint_w = ImGui::CalcTextSize(palette_hint).x; + ImGui::SameLine(ImGui::GetWindowWidth() - hint_w - 14.0f); + ImGui::AlignTextToFramePadding(); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(ImGuiCol_TextDisabled)); + ImGui::TextUnformatted(palette_hint); + ImGui::PopStyleColor(); + if (ImGui::IsItemClicked()) + m_palette_request = true; + if (ImGui::IsItemHovered()) + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::EndMenuBar(); } @@ -1061,24 +1086,28 @@ namespace Hsdbg m_mascot.load(std::filesystem::path(HSDBG_ASSET_DIR) / "peepocry.gif"); } - const float button_height = ImGui::GetFrameHeight(); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 9.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, TOOLBAR_PADDING); + + // the square size for the rounded icon buttons that make up the transport + const float control = ImGui::GetFrameHeight(); const bool has_mascot = m_preferences.show_mascot && m_mascot.valid() && m_mascot.height() > 0.0f; - const float mascot_height = has_mascot ? button_height * m_preferences.mascot_scale : 0.0f; - const float row_height = std::max(button_height, mascot_height); + const float mascot_height = has_mascot ? control * m_preferences.mascot_scale : 0.0f; + const float row_height = std::max(control, mascot_height); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 6.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, TOOLBAR_PADDING); ImGui::BeginChild("##toolbar", ImVec2(0.0f, row_height + TOOLBAR_PADDING.y * 2.0f)); const float row_top = ImGui::GetCursorPosY(); - ImGui::SetCursorPosY(row_top + (row_height - button_height) * 0.5f); + ImGui::SetCursorPosY(row_top + (row_height - control) * 0.5f); - const StepMode step_mode = - m_preferences.step_by_instruction ? StepMode::Instruction : StepMode::Line; + const StepMode step_mode = m_preferences.step_by_instruction ? StepMode::Instruction : StepMode::Line; - ImGui::BeginDisabled(!has_target || running); - if (ImGui::Button("run")) + // a spacer between logical groups of transport controls + const auto gap = [] { ImGui::SameLine(0.0f, 14.0f); }; + + if (Widgets::icon_button("run", ICON_PH_PLAY, has_target && !running, "Run", + !has_target || running, control)) { LaunchSpec spec; spec.executable = debugger.target_path(); @@ -1086,53 +1115,35 @@ namespace Hsdbg report(debugger.launch(spec), "run"); } - ImGui::EndDisabled(); ImGui::SameLine(); - - ImGui::BeginDisabled(!running); - if (ImGui::Button("pause")) + if (Widgets::icon_button("pause", ICON_PH_PAUSE, false, "Pause", !running, control)) report(debugger.pause(), "pause"); - ImGui::EndDisabled(); ImGui::SameLine(); - - ImGui::BeginDisabled(!has_target); - if (ImGui::Button("stop")) + if (Widgets::icon_button("stop", ICON_PH_STOP, false, "Stop", !has_target, control)) report(debugger.terminate(), "stop"); - ImGui::SameLine(); - ImGui::TextDisabled("|"); - ImGui::SameLine(); - - if (ImGui::Button("step over")) - report(debugger.step_over(step_mode), "step over"); + gap(); + if (Widgets::icon_button("step_over", ICON_PH_STEP_OVER, false, "Step Over", + !has_target, control)) + report(debugger.step_over(step_mode), "Step Over"); ImGui::SameLine(); - - if (ImGui::Button("step into")) - report(debugger.step_into(step_mode), "step into"); + if (Widgets::icon_button("step_into", ICON_PH_STEP_INTO, false, "Step Into", + !has_target, control)) + report(debugger.step_into(step_mode), "Step Into"); ImGui::SameLine(); + if (Widgets::icon_button("step_out", ICON_PH_STEP_OUT, false, "Step Out", + !has_target, control)) + report(debugger.step_out(), "Step Out"); - if (ImGui::Button("step out")) - report(debugger.step_out(), "step out"); - ImGui::EndDisabled(); - - ImGui::SameLine(); - ImGui::TextDisabled("|"); - ImGui::SameLine(); - - // the way in to profiling, and the way back out: the panel only exists - // while this is on, and lights up to show that it is. the button toggles - // that state, so the tint is keyed off a copy taken before the click or - // the push and pop would not balance - const bool profiler_open = m_visible.profiler; - - if (profiler_open) - ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); - - if (ImGui::Button("profiler")) + // the way in to profiling and back out: the panel only exists while this + // is on, and the accent fill shows that it is + gap(); + if (Widgets::icon_button("profiler", ICON_PH_GAUGE, m_visible.profiler, "Profiler", + false, control)) { m_visible.profiler = !m_visible.profiler; @@ -1140,15 +1151,9 @@ namespace Hsdbg m_focus_profiler = true; } - if (profiler_open) - ImGui::PopStyleColor(); - - ImGui::SameLine(); - ImGui::TextDisabled("|"); - ImGui::SameLine(); - - ImGui::AlignTextToFramePadding(); - ImGui::TextColored(state_color(debugger.state()), "%s", to_string(debugger.state()).data()); + gap(); + Widgets::chip(std::string(to_string(debugger.state())).c_str(), + ImGui::ColorConvertFloat4ToU32(state_color(debugger.state()))); if (has_mascot) { @@ -1163,7 +1168,7 @@ namespace Hsdbg ImGui::Dummy(size); if (ImGui::IsItemHovered()) - ImGui::SetTooltip("it's going to be okay"); + ImGui::SetTooltip("It's going to be okay"); const ImVec2 viewport = ImGui::GetMainViewport()->Pos; m_mascot_pending = true; @@ -1192,7 +1197,7 @@ namespace Hsdbg if (debugger.has_target()) ImGui::Text("%s", debugger.target_path().filename().string().c_str()); else - ImGui::TextDisabled("no target"); + ImGui::TextDisabled("No target"); ImGui::SameLine(); ImGui::TextDisabled("|"); @@ -1228,7 +1233,7 @@ namespace Hsdbg if (!ImGui::BeginPopupModal(LOAD_TARGET_POPUP, nullptr, ImGuiWindowFlags_AlwaysAutoResize)) return; - ImGui::TextDisabled("path to an executable to debug"); + ImGui::TextDisabled("Path to an executable to debug"); ImGui::SetNextItemWidth(420.0f); @@ -1236,7 +1241,7 @@ namespace Hsdbg &m_target_input, ImGuiInputTextFlags_EnterReturnsTrue); - if (ImGui::Button("load") || submitted) + if (ImGui::Button("Load") || submitted) { if (const auto result = debugger.load_target(m_target_input); result) { @@ -1251,7 +1256,7 @@ namespace Hsdbg ImGui::SameLine(); - if (ImGui::Button("cancel")) + if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); ImGui::EndPopup(); @@ -1263,6 +1268,16 @@ namespace Hsdbg // FontGlobalScale which just stretched the baked atlas ImGui::GetStyle().FontScaleMain = m_preferences.ui_scale; + // one-shot: report the framebuffer scale (on macos this, not content scale, + // carries retina density) so font crispness can be reasoned about + static bool logged_scale = false; + if (!logged_scale) + { + logged_scale = true; + const ImVec2 fb = ImGui::GetIO().DisplayFramebufferScale; + Log::info("display: framebuffer scale {}x{}", fb.x, fb.y); + } + m_source_view.set_highlighting(m_preferences.syntax_highlighting); m_source_view.set_line_numbers(m_preferences.show_line_numbers); m_source_view.set_highlight_current_line(m_preferences.highlight_current_line); @@ -1290,7 +1305,16 @@ namespace Hsdbg auto Ui::draw_preferences_window() -> void { if (!m_show_preferences) + { + m_prefs_open_prev = false; return; + } + + // rescan the themes folder when the window opens, so a .toml dropped in + // while the app is running shows up without a restart + if (!m_prefs_open_prev) + m_themes = list_themes(themes_directory()); + m_prefs_open_prev = true; const ImGuiViewport* viewport = ImGui::GetMainViewport(); const ImVec2 center(viewport->WorkPos.x + viewport->WorkSize.x * 0.5f, @@ -1300,23 +1324,27 @@ namespace Hsdbg ImGui::SetNextWindowSize(ImVec2(560.0f, 380.0f), ImGuiCond_Appearing); ImGui::SetNextWindowSizeConstraints(ImVec2(460.0f, 300.0f), ImVec2(FLT_MAX, FLT_MAX)); - if (!ImGui::Begin("preferences", &m_show_preferences, ImGuiWindowFlags_NoDocking)) + if (!ImGui::Begin("Preferences###preferences", &m_show_preferences, ImGuiWindowFlags_NoDocking)) { ImGui::End(); return; } - static constexpr const char* CATEGORIES[] = { "appearance", "editor", "debugger" }; + static constexpr const char* CATEGORIES[] = { "Appearance", "Editor", "Debugger" }; + static constexpr const char* CATEGORY_ICONS[] = { + ICON_PH_SLIDERS, ICON_PH_FILE_CODE, ICON_PH_BUG + }; bool changed = false; bool restyle = false; const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; // left: the category list; right: that category's settings - ImGui::BeginChild("##pref_categories", ImVec2(150.0f, -footer), ImGuiChildFlags_Borders); + ImGui::BeginChild("##pref_categories", ImVec2(158.0f, -footer), ImGuiChildFlags_Borders); for (int index = 0; index < IM_ARRAYSIZE(CATEGORIES); ++index) { - if (ImGui::Selectable(CATEGORIES[index], m_preferences_tab == index)) + const std::string label = std::string(CATEGORY_ICONS[index]) + " " + CATEGORIES[index]; + if (Widgets::selectable_row(label.c_str(), m_preferences_tab == index)) m_preferences_tab = index; } ImGui::EndChild(); @@ -1344,68 +1372,138 @@ namespace Hsdbg changed = true; }; + // a settings row: the label on the left, an ios-style switch pinned to the + // right edge and vertically centred against the text + const auto toggle_row = [&](const char* label, bool* value, const char* help_text = nullptr) { + const float toggle_h = ImGui::GetFrameHeight() * 0.78f; + const float toggle_w = toggle_h * 1.8f; + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + if (help_text != nullptr) + help(help_text); + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - toggle_w); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (ImGui::GetFrameHeight() - toggle_h) * 0.5f); + if (Widgets::toggle(label, value)) + changed = true; + }; + if (m_preferences_tab == 0) { - ImGui::SeparatorText("interface"); - changed |= ImGui::SliderFloat("ui scale", &m_preferences.ui_scale, 0.75f, 2.0f, "%.2fx"); - help("scales every font. the text stays crisp because it is re-rasterized, not stretched."); + Widgets::section_header("theme"); - if (ImGui::ColorEdit3("accent colour", m_preferences.accent, + // preview swatches for the loaded theme, so the accent/rounding + // pickers below read as tweaks on top of a named starting point + const auto swatch = [](ImVec4 colour) { + ImGui::ColorButton("##sw", colour, + ImGuiColorEditFlags_NoTooltip | ImGuiColorEditFlags_NoDragDrop, + ImVec2(14.0f, 14.0f)); + ImGui::SameLine(0.0f, 4.0f); + }; + swatch(m_theme.bg); + swatch(m_theme.bg_high); + swatch(m_theme.surface); + swatch(m_theme.text); + swatch(ImVec4(m_preferences.accent[0], m_preferences.accent[1], + m_preferences.accent[2], 1.0f)); + ImGui::NewLine(); + + const char* current = m_preferences.theme.c_str(); + for (const auto& entry : m_themes) + { + if (entry.id == m_preferences.theme) + { + current = entry.name.c_str(); + break; + } + } + + if (ImGui::BeginCombo("Theme", current)) + { + for (const auto& entry : m_themes) + { + const bool selected = entry.id == m_preferences.theme; + if (ImGui::Selectable(entry.name.c_str(), selected)) + select_theme(entry); // seeds the tweaks below and saves + if (selected) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + help("Themes live in assets/themes as .toml files. drop a new one in, reopen this " + "window, and it appears here. picking one resets the tweaks below to its values."); + + Widgets::section_header("interface"); + changed |= Widgets::slider_float("UI scale", &m_preferences.ui_scale, 0.75f, 2.0f, "%.2fx"); + help("Scales every font. the text stays crisp because it is re-rasterized, not stretched."); + + if (ImGui::ColorEdit3("Accent colour", m_preferences.accent, ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoAlpha)) { changed = true; restyle = true; } - if (ImGui::SliderFloat("corner rounding", &m_preferences.rounding, 0.0f, 12.0f, "%.0f px")) + if (Widgets::slider_float("Corner rounding", &m_preferences.rounding, 0.0f, 12.0f, "%.0f px")) { changed = true; restyle = true; } - changed |= ImGui::Checkbox("show fps in the status bar", &m_preferences.show_fps); + toggle_row("Show FPS in the status bar", &m_preferences.show_fps); - ImGui::SeparatorText("layout"); - if (ImGui::Button("reset window layout")) + Widgets::section_header("layout"); + if (ImGui::Button("Reset window layout")) m_layout_built = false; - help("restores the default arrangement of all the docked panels."); + help("Restores the default arrangement of all the docked panels."); - ImGui::SeparatorText("mascot"); - changed |= ImGui::Checkbox("show the crying pepe", &m_preferences.show_mascot); + Widgets::section_header("mascot"); + toggle_row("Show the crying pepe", &m_preferences.show_mascot); ImGui::BeginDisabled(!m_preferences.show_mascot); - changed |= ImGui::SliderFloat("pepe size", &m_preferences.mascot_scale, 1.0f, 3.0f, "%.1fx"); + changed |= Widgets::slider_float("Pepe size", &m_preferences.mascot_scale, 1.0f, 3.0f, "%.1fx"); ImGui::EndDisabled(); } else if (m_preferences_tab == 1) { - ImGui::SeparatorText("source view"); - changed |= ImGui::Checkbox("syntax highlighting", &m_preferences.syntax_highlighting); - changed |= ImGui::Checkbox("show line numbers", &m_preferences.show_line_numbers); - changed |= ImGui::Checkbox("highlight the current line", - &m_preferences.highlight_current_line); + Widgets::section_header("source view"); + toggle_row("Syntax highlighting", &m_preferences.syntax_highlighting); + toggle_row("Show line numbers", &m_preferences.show_line_numbers); + toggle_row("Highlight the current line", &m_preferences.highlight_current_line); - ImGui::SeparatorText("colours"); + Widgets::section_header("colours"); ImGui::BeginDisabled(!m_preferences.syntax_highlighting); - accent_swatch("keyword", m_preferences.color_keyword); - accent_swatch("type", m_preferences.color_type); - accent_swatch("string", m_preferences.color_string); - accent_swatch("number", m_preferences.color_number); - accent_swatch("comment", m_preferences.color_comment); - accent_swatch("preprocessor", m_preferences.color_preprocessor); + accent_swatch("Keyword", m_preferences.color_keyword); + accent_swatch("Type", m_preferences.color_type); + accent_swatch("String", m_preferences.color_string); + accent_swatch("Number", m_preferences.color_number); + accent_swatch("Comment", m_preferences.color_comment); + accent_swatch("Preprocessor", m_preferences.color_preprocessor); ImGui::EndDisabled(); - accent_swatch("current line", m_preferences.color_current_line); + accent_swatch("Current line", m_preferences.color_current_line); } else if (m_preferences_tab == 2) { - ImGui::SeparatorText("launching"); - changed |= ImGui::Checkbox("break at entry point on launch", &m_preferences.stop_at_entry); - help("stop on the very first instruction instead of running to your breakpoints."); + Widgets::section_header("launching"); + toggle_row("Break at entry point on launch", &m_preferences.stop_at_entry, + "stop on the very first instruction instead of running to your breakpoints."); - ImGui::SeparatorText("stepping"); - changed |= ImGui::Checkbox("step by instruction, not line", - &m_preferences.step_by_instruction); - help("the step over/into buttons advance one machine instruction at a time."); + Widgets::section_header("stepping"); + { + static const char* const step_modes[] = { "Line", "Instruction" }; + const int current_mode = m_preferences.step_by_instruction ? 1 : 0; + const int next_mode = Widgets::segmented("##step_mode", step_modes, 2, current_mode); + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Step granularity"); + help("The step over/into buttons advance a whole source line, or a single " + "machine instruction."); + if (next_mode != current_mode) + { + m_preferences.step_by_instruction = (next_mode == 1); + changed = true; + } + } } ImGui::PopItemWidth(); @@ -1413,15 +1511,18 @@ namespace Hsdbg ImGui::Separator(); - if (ImGui::Button("reset to defaults")) + if (ImGui::Button("Reset to defaults")) { m_preferences = Preferences{}; + // realign the neutral palette with the default theme so the reset + // accent does not sit on top of the previously loaded theme's levels + load_selected_theme(false); changed = true; restyle = true; } ImGui::SameLine(); - ImGui::TextDisabled("saved to %s", m_preferences_path.filename().string().c_str()); + ImGui::TextDisabled("Saved to %s", m_preferences_path.filename().string().c_str()); // persist the moment anything changes, so nothing is lost to a crash if (changed) @@ -1453,7 +1554,7 @@ namespace Hsdbg { if (debugger.breakpoints().empty()) { - ImGui::TextDisabled("no breakpoints, click a gutter in source or disassembly"); + Widgets::empty_state(ICON_PH_CIRCLE, "No breakpoints yet"); } else { @@ -1462,17 +1563,18 @@ namespace Hsdbg if (ImGui::BeginTable("##breakpoints", 8, flags)) { - ImGui::TableSetupColumn("on", ImGuiTableColumnFlags_WidthFixed, 26.0f); - ImGui::TableSetupColumn("id", ImGuiTableColumnFlags_WidthFixed, 30.0f); - ImGui::TableSetupColumn("location"); - ImGui::TableSetupColumn("address"); - ImGui::TableSetupColumn("condition"); - ImGui::TableSetupColumn("skip", ImGuiTableColumnFlags_WidthFixed, 50.0f); - ImGui::TableSetupColumn("hits", ImGuiTableColumnFlags_WidthFixed, 40.0f); + ImGui::TableSetupColumn("On", ImGuiTableColumnFlags_WidthFixed, 26.0f); + ImGui::TableSetupColumn("Id", ImGuiTableColumnFlags_WidthFixed, 30.0f); + ImGui::TableSetupColumn("Location"); + ImGui::TableSetupColumn("Address"); + ImGui::TableSetupColumn("Condition"); + ImGui::TableSetupColumn("Skip", ImGuiTableColumnFlags_WidthFixed, 50.0f); + ImGui::TableSetupColumn("Hits", ImGuiTableColumnFlags_WidthFixed, 40.0f); ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 26.0f); ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableHeadersRow(); + ImGui::PushFont(m_font_mono, 0.0f); uint32_t pending_removal = 0; for (const Breakpoint& breakpoint : debugger.breakpoints()) @@ -1496,11 +1598,9 @@ namespace Hsdbg } else if (breakpoint.line != 0) { - // clicking the location takes the source view there, so - // the breakpoints list doubles as a jump list. plain - // text with a click test, not a Selectable: a full-width - // Selectable in this stretch-sized column feeds its own - // width back into the column solver and lands on NaN + // clicking jumps the source view here. plain text + a click + // test, not a Selectable — a full-width one in this stretch + // column feeds its width to the solver and lands on NaN const std::string location = std::format( "{}:{}", breakpoint.file.filename().string(), breakpoint.line); @@ -1534,7 +1634,7 @@ namespace Hsdbg } else { - ImGui::TextDisabled("pending"); + ImGui::TextDisabled("Pending"); } // imgui keeps its own buffer while an input is focused, so @@ -1544,7 +1644,7 @@ namespace Hsdbg ImGui::SetNextItemWidth(-1.0f); if (ImGui::InputTextWithHint("##condition", - "stop when", + "Stop when…", &condition, ImGuiInputTextFlags_EnterReturnsTrue)) { @@ -1566,12 +1666,13 @@ namespace Hsdbg ImGui::Text("%u", breakpoint.hit_count); ImGui::TableNextColumn(); - if (ImGui::SmallButton("x")) + if (Widgets::remove_button("##remove")) pending_removal = breakpoint.id; ImGui::PopID(); } + ImGui::PopFont(); ImGui::EndTable(); if (pending_removal != 0) @@ -1592,7 +1693,7 @@ namespace Hsdbg { if (debugger.call_stack().empty()) { - ImGui::TextDisabled("no call stack"); + Widgets::empty_state(ICON_PH_STACK, "No call stack"); } else { @@ -1601,7 +1702,7 @@ namespace Hsdbg const bool selected = frame.index == debugger.selected_frame(); const std::string label = std::format("{:>2} {}", frame.index, frame.function); - if (ImGui::Selectable(label.c_str(), selected)) + if (Widgets::selectable_row(label.c_str(), selected)) { debugger.select_frame(frame.index); show_frame(frame); @@ -1614,7 +1715,9 @@ namespace Hsdbg continue; ImGui::SameLine(); + ImGui::PushFont(m_font_mono, 0.0f); ImGui::TextDisabled("%s:%u", frame.file.filename().string().c_str(), frame.line); + ImGui::PopFont(); } } } @@ -1631,7 +1734,7 @@ namespace Hsdbg { if (debugger.threads().empty()) { - ImGui::TextDisabled("no threads"); + Widgets::empty_state(ICON_PH_BRANCH, "No threads"); } else { @@ -1640,7 +1743,7 @@ namespace Hsdbg const bool selected = thread.id == debugger.selected_thread(); const std::string label = std::format("{} {}", thread.id, thread.name); - if (ImGui::Selectable(label.c_str(), selected)) + if (Widgets::selectable_row(label.c_str(), selected)) { debugger.select_thread(thread.id); @@ -1671,19 +1774,18 @@ namespace Hsdbg if (files.empty()) { - ImGui::TextDisabled("no source files"); + Widgets::empty_state(ICON_PH_FOLDER, "No source files"); } else { ImGui::SetNextItemWidth(-1.0f); - ImGui::InputTextWithHint("##source_filter", "filter files", &m_source_filter); + ImGui::InputTextWithHint("##source_filter", "Filter files", &m_source_filter); const SourceNode tree = build_source_tree(files); const std::filesystem::path& open = m_source_view.path(); - // folders stay collapsed until they are needed. when the open file - // changes, the one frame where it differs from what we last showed - // expands the chain down to it; after that the user is in control + // folders stay collapsed until needed; when the open file changes, + // the chain to it expands once, then the user is in control const bool reveal = open != m_revealed_source; ImDrawList* const draw_list = ImGui::GetWindowDrawList(); @@ -1700,7 +1802,8 @@ namespace Hsdbg if (selected) ImGui::PushStyleColor(ImGuiCol_Text, SOURCE_OPEN_FILE_COLOR); - const bool clicked = ImGui::Selectable(node.name.c_str(), selected); + const bool clicked = Widgets::selectable_row( + (std::string(ICON_PH_FILE " ") + node.name).c_str(), selected); if (selected) ImGui::PopStyleColor(); @@ -1721,26 +1824,23 @@ namespace Hsdbg ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_OpenOnDoubleClick; - // a filter opens every folder so its matches are visible; with - // no filter, only the chain down to the open file unfurls, and - // only on the frame it changed so manual collapses survive + // a filter opens every folder; with none, only the chain to the + // open file unfurls, and only on the frame it changed if (!m_source_filter.empty()) ImGui::SetNextItemOpen(true, ImGuiCond_Always); else if (reveal && node_contains(node, open)) ImGui::SetNextItemOpen(true, ImGuiCond_Always); ImGui::PushStyleColor(ImGuiCol_Text, SOURCE_FOLDER_COLOR); - const bool open_node = ImGui::TreeNodeEx(node.name.c_str(), flags); + const bool open_node = ImGui::TreeNodeEx( + (std::string(ICON_PH_FOLDER " ") + node.name).c_str(), flags); ImGui::PopStyleColor(); if (!open_node) return; - // connect this folder to each visible child the way `tree` - // does: a vertical spine down the gutter with a horizontal - // tick out to every child, the spine ending at the last one. - // the glyphs would be tofu in the default font, so the lines - // are drawn by hand + // draw the folder→child spine by hand (a vertical line down the + // gutter with a tick to each child); the glyphs would be tofu const float indent = ImGui::GetStyle().IndentSpacing; const float half_row = ImGui::GetTextLineHeight() * 0.5f; const float spine_x = ImGui::GetCursorScreenPos().x - indent * 0.5f; @@ -1770,7 +1870,7 @@ namespace Hsdbg }; if (tree.children.empty()) - ImGui::TextDisabled("no source files"); + Widgets::empty_state(ICON_PH_FOLDER, "No source files"); else draw_node(tree); @@ -1790,7 +1890,7 @@ namespace Hsdbg { if (debugger.locals().empty()) { - ImGui::TextDisabled("no locals"); + Widgets::empty_state(ICON_PH_CUBE, "No locals"); } else { @@ -1799,14 +1899,16 @@ namespace Hsdbg if (ImGui::BeginTable("##locals", 3, flags)) { - ImGui::TableSetupColumn("name"); - ImGui::TableSetupColumn("type"); - ImGui::TableSetupColumn("value"); + ImGui::TableSetupColumn("Name"); + ImGui::TableSetupColumn("Type"); + ImGui::TableSetupColumn("Value"); ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableHeadersRow(); + ImGui::PushFont(m_font_mono, 0.0f); for (const Variable& variable : debugger.locals()) draw_variable(variable); + ImGui::PopFont(); ImGui::EndTable(); } @@ -1855,9 +1957,8 @@ namespace Hsdbg if (ImGui::Begin(PANEL_WATCH, &m_visible.watch)) { - // re-run the expressions whenever the target stops again or the frame - // the ui is looking at moves, but never every frame: each call jits and - // runs code in the target + // re-run the watches on each stop or frame change, never every frame: + // each evaluation jits and runs code in the target const bool stopped = debugger.is_stopped(); const bool moved = debugger.stop_count() != m_watch_stop || debugger.selected_thread() != m_watch_thread || @@ -1889,12 +1990,12 @@ namespace Hsdbg m_watch_evaluated = false; ImGui::SetNextItemWidth(-60.0f); - const bool submitted = ImGui::InputTextWithHint("##watch_input", "expression to watch", + const bool submitted = ImGui::InputTextWithHint("##watch_input", "Expression to watch", &m_watch_input, ImGuiInputTextFlags_EnterReturnsTrue); ImGui::SameLine(); - const bool add_clicked = ImGui::Button("watch", ImVec2(-1.0f, 0.0f)); + const bool add_clicked = ImGui::Button("Watch", ImVec2(-1.0f, 0.0f)); if ((submitted || add_clicked) && !m_watch_input.empty()) { @@ -1904,7 +2005,7 @@ namespace Hsdbg if (m_watches.empty()) { - ImGui::TextDisabled("watch an expression; it re-evaluates on every stop"); + ImGui::TextDisabled("Watch an expression; it re-evaluates on every stop"); } else { @@ -1913,12 +2014,13 @@ namespace Hsdbg if (ImGui::BeginTable("##watches", 3, flags)) { - ImGui::TableSetupColumn("expression", ImGuiTableColumnFlags_WidthFixed, 140.0f); - ImGui::TableSetupColumn("value"); + ImGui::TableSetupColumn("Expression", ImGuiTableColumnFlags_WidthFixed, 140.0f); + ImGui::TableSetupColumn("Value"); ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 24.0f); ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableHeadersRow(); + ImGui::PushFont(m_font_mono, 0.0f); size_t remove_index = m_watches.size(); for (size_t index = 0; index < m_watches.size(); ++index) @@ -1941,12 +2043,13 @@ namespace Hsdbg watch.value.c_str()); ImGui::TableNextColumn(); - if (ImGui::SmallButton("x")) + if (Widgets::remove_button("##remove")) remove_index = index; ImGui::PopID(); } + ImGui::PopFont(); ImGui::EndTable(); if (remove_index < m_watches.size()) @@ -1967,7 +2070,7 @@ namespace Hsdbg { if (debugger.registers().empty()) { - ImGui::TextDisabled("no registers"); + Widgets::empty_state(ICON_PH_CPU, "No registers"); } else { @@ -1976,11 +2079,12 @@ namespace Hsdbg if (ImGui::BeginTable("##registers", 2, flags)) { - ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_WidthFixed, 70.0f); - ImGui::TableSetupColumn("value"); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthFixed, 70.0f); + ImGui::TableSetupColumn("Value"); ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableHeadersRow(); + ImGui::PushFont(m_font_mono, 0.0f); for (const Register& entry : debugger.registers()) { ImGui::TableNextRow(); @@ -1989,6 +2093,7 @@ namespace Hsdbg ImGui::TableNextColumn(); ImGui::Text("0x%016llx", static_cast<unsigned long long>(entry.value)); } + ImGui::PopFont(); ImGui::EndTable(); } @@ -2009,12 +2114,12 @@ namespace Hsdbg if (symbols.empty()) { - ImGui::TextDisabled("no symbols"); + Widgets::empty_state(ICON_PH_FUNCTION, "No symbols"); } else { ImGui::SetNextItemWidth(-1.0f); - ImGui::InputTextWithHint("##symbol_filter", "filter symbols", &m_symbol_filter); + ImGui::InputTextWithHint("##symbol_filter", "Filter symbols", &m_symbol_filter); std::vector<size_t> visible; @@ -2032,11 +2137,12 @@ namespace Hsdbg if (ImGui::BeginTable("##symbols", 2, flags)) { - ImGui::TableSetupColumn("address", ImGuiTableColumnFlags_WidthFixed, 110.0f); - ImGui::TableSetupColumn("name"); + ImGui::TableSetupColumn("Address", ImGuiTableColumnFlags_WidthFixed, 110.0f); + ImGui::TableSetupColumn("Name"); ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableHeadersRow(); + ImGui::PushFont(m_font_mono, 0.0f); const float row_height = ImGui::GetTextLineHeightWithSpacing(); if (m_scroll_to_symbol) @@ -2088,6 +2194,7 @@ namespace Hsdbg ImGui::PopID(); } } + ImGui::PopFont(); ImGui::EndTable(); } @@ -2108,14 +2215,16 @@ namespace Hsdbg if (instructions.empty()) { - ImGui::TextDisabled("no disassembly"); + Widgets::empty_state(ICON_PH_LIST, "No disassembly"); } else { if (!debugger.disassembly_name().empty()) ImGui::TextUnformatted(debugger.disassembly_name().data()); + ImGui::PushFont(m_font_mono, 0.0f); draw_instruction_table(debugger, instructions, m_scroll_to_program_counter); + ImGui::PopFont(); m_scroll_to_program_counter = false; } } @@ -2134,6 +2243,7 @@ namespace Hsdbg if (ImGui::BeginChild("##console_output", ImVec2(0.0f, -input_height))) { + ImGui::PushFont(m_font_mono, 0.0f); for (const std::string& line : debugger.console_output()) draw_ansi_line(line); @@ -2145,6 +2255,7 @@ namespace Hsdbg ImGui::SetScrollHereY(1.0f); m_console_scroll_pending = false; } + ImGui::PopFont(); } ImGui::EndChild(); @@ -2152,7 +2263,7 @@ namespace Hsdbg ImGui::SetNextItemWidth(-1.0f); const bool submitted = ImGui::InputTextWithHint("##console_input", - "expression to evaluate", + "Expression to evaluate", &m_console_input, ImGuiInputTextFlags_EnterReturnsTrue); @@ -2183,12 +2294,11 @@ namespace Hsdbg const bool instrumented = debugger.instrumentation_active(); const std::span<const FunctionTrace> traces = debugger.traces(); - // compact header: how calls are being gathered. instrumentation, when - // the target carries it, is exact and needs nothing; otherwise sampling - // approximates on any binary. either way calls flow into the flame chart + // header: how calls are gathered — instrumentation (exact, if the target + // carries it) or sampling (approximate, any binary) if (instrumented) { - ImGui::TextDisabled("instrumented target — every function timed automatically"); + ImGui::TextDisabled("Instrumented target — every function timed automatically"); } else { @@ -2213,12 +2323,12 @@ namespace Hsdbg if (ImGui::CollapsingHeader("function timings", ImGuiTreeNodeFlags_DefaultOpen)) { ImGui::SetNextItemWidth(-70.0f); - const bool submitted = ImGui::InputTextWithHint("##trace_input", "function to time", + const bool submitted = ImGui::InputTextWithHint("##trace_input", "Function to time", &m_trace_input, ImGuiInputTextFlags_EnterReturnsTrue); ImGui::SameLine(); - const bool add_clicked = ImGui::Button("trace", ImVec2(-1.0f, 0.0f)); + const bool add_clicked = ImGui::Button("Trace", ImVec2(-1.0f, 0.0f)); if ((submitted || add_clicked) && !m_trace_input.empty()) { @@ -2228,17 +2338,17 @@ namespace Hsdbg if (traces.empty()) { - ImGui::TextDisabled("name a function above to count and time its calls"); + ImGui::TextDisabled("Name a function above to count and time its calls"); } else if (ImGui::BeginTable("##traces", 6, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_Resizable)) { - ImGui::TableSetupColumn("function"); - ImGui::TableSetupColumn("calls", ImGuiTableColumnFlags_WidthFixed, 52.0f); - ImGui::TableSetupColumn("avg", ImGuiTableColumnFlags_WidthFixed, 72.0f); - ImGui::TableSetupColumn("min", ImGuiTableColumnFlags_WidthFixed, 72.0f); - ImGui::TableSetupColumn("max", ImGuiTableColumnFlags_WidthFixed, 72.0f); + ImGui::TableSetupColumn("Function"); + ImGui::TableSetupColumn("Calls", ImGuiTableColumnFlags_WidthFixed, 52.0f); + ImGui::TableSetupColumn("Avg", ImGuiTableColumnFlags_WidthFixed, 72.0f); + ImGui::TableSetupColumn("Min", ImGuiTableColumnFlags_WidthFixed, 72.0f); + ImGui::TableSetupColumn("Max", ImGuiTableColumnFlags_WidthFixed, 72.0f); ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 24.0f); ImGui::TableHeadersRow(); @@ -2279,7 +2389,7 @@ namespace Hsdbg ImGui::TableNextColumn(); ImGui::PushID(static_cast<int>(trace.id)); - if (ImGui::SmallButton("x")) + if (Widgets::remove_button("##remove")) remove_id = trace.id; ImGui::PopID(); } @@ -2299,7 +2409,7 @@ namespace Hsdbg ImGui::Text("%.1f MB", memory.latest()); ImGui::SameLine(); - ImGui::TextDisabled("peak %.1f MB", memory.maximum()); + ImGui::TextDisabled("Peak %.1f MB", memory.maximum()); ImGui::PlotLines("##target_memory", memory.values(), memory.count(), memory.offset(), nullptr, 0.0f, FLT_MAX, ImVec2(-1.0f, 60.0f)); @@ -2314,24 +2424,23 @@ namespace Hsdbg m_profiler.set_paused(paused); ImGui::SameLine(); - if (ImGui::SmallButton("reset")) + if (ImGui::SmallButton("Reset")) m_profiler.reset(); const TimeSeries& frame_ms = m_profiler.frame_times(); const TimeSeries& fps = m_profiler.frame_rates(); - const std::string frame_overlay = - std::format("{:.2f} ms (avg {:.2f} peak {:.2f})", - frame_ms.latest(), frame_ms.average(), frame_ms.maximum()); + const std::string frame_overlay = std::format("{:.2f} ms (avg {:.2f} peak {:.2f})", + frame_ms.latest(), frame_ms.average(), + frame_ms.maximum()); - ImGui::TextUnformatted("frame time"); + ImGui::TextUnformatted("Frame time"); ImGui::PlotLines("##frame_ms", frame_ms.values(), frame_ms.count(), frame_ms.offset(), frame_overlay.c_str(), 0.0f, FLT_MAX, ImVec2(-1.0f, 70.0f)); - const std::string fps_overlay = - std::format("{:.0f} fps (avg {:.0f})", fps.latest(), fps.average()); + const std::string fps_overlay = std::format("{:.0f} fps (avg {:.0f})", fps.latest(), fps.average()); - ImGui::TextUnformatted("frame rate"); + ImGui::TextUnformatted("Frame rate"); ImGui::PlotLines("##fps", fps.values(), fps.count(), fps.offset(), fps_overlay.c_str(), 0.0f, FLT_MAX, ImVec2(-1.0f, 70.0f)); } @@ -2347,11 +2456,11 @@ namespace Hsdbg if (spans.empty()) { if (debugger.instrumentation_active()) - ImGui::TextDisabled("instrumented target — run it to lay out its calls here"); + ImGui::TextDisabled("Instrumented target — run it to lay out its calls here"); else if (debugger.sampling_enabled()) - ImGui::TextDisabled("sampling — run the target and the calls will appear here"); + ImGui::TextDisabled("Sampling — run the target and the calls will appear here"); else - ImGui::TextDisabled("turn on sampling, or trace a function below, then run"); + ImGui::TextDisabled("Turn on sampling, or trace a function below, then run"); return; } @@ -2426,11 +2535,11 @@ namespace Hsdbg { ImGui::BeginTooltip(); ImGui::TextUnformatted(name_of(span.trace_id)); - ImGui::Text("start %.3f ms", (span.start - t_min) * 1000.0); + ImGui::Text("Start %.3f ms", (span.start - t_min) * 1000.0); if (span.duration > 0.0) - ImGui::Text("duration %.3f ms", span.duration * 1000.0); + ImGui::Text("Duration %.3f ms", span.duration * 1000.0); else - ImGui::TextDisabled("running..."); + ImGui::TextDisabled("Running…"); ImGui::EndTooltip(); } } @@ -2447,17 +2556,17 @@ namespace Hsdbg { const MacroTable& table = m_source_view.macros(); - ImGui::TextDisabled("unroll a #define one layer per level"); + ImGui::TextDisabled("Unroll a #define one layer per level"); ImGui::SetNextItemWidth(-FLT_MIN); if (ImGui::InputTextWithHint("##macro_input", - "click a macro in source, or type one like MAX(a, b)", + "Click a macro in Source, or type one like MAX(a, b)", &m_macro_input)) m_macro_level = 0; if (table.empty()) - ImGui::TextDisabled("no #define macros found in the open source file"); + ImGui::TextDisabled("No #define macros found in the open source file"); if (m_macro_input.empty()) { @@ -2494,35 +2603,34 @@ namespace Hsdbg } else { - ImGui::TextDisabled("nothing to expand here"); + ImGui::TextDisabled("Nothing to expand here"); ImGui::SameLine(); } ImGui::SameLine(); - if (ImGui::SmallButton("full")) + if (ImGui::SmallButton("Full")) m_macro_level = max_level; ImGui::SameLine(); - if (ImGui::SmallButton("reset")) + if (ImGui::SmallButton("Reset")) m_macro_level = 0; // status: where we are, and whether the tail is truly the fixpoint if (max_level == 0) - ImGui::TextDisabled("already fully expanded"); + ImGui::TextDisabled("Already fully expanded"); else if (m_macro_level == max_level && expansion.fully_expanded()) - ImGui::TextDisabled("level %d of %d — fully expanded", m_macro_level, max_level); + ImGui::TextDisabled("Level %d of %d — fully expanded", m_macro_level, max_level); else if (m_macro_level == max_level) - ImGui::TextDisabled("level %d — stopped at the expansion cap", m_macro_level); + ImGui::TextDisabled("Level %d — stopped at the expansion cap", m_macro_level); else - ImGui::TextDisabled("level %d of %d", m_macro_level, max_level); + ImGui::TextDisabled("Level %d of %d", m_macro_level, max_level); // which macros the next layer will unroll, so the step reads ahead if (m_macro_level < max_level) { - const std::vector<std::string>& next = - expansion.expanded[static_cast<size_t>(m_macro_level) + 1]; + const std::vector<std::string>& next = expansion.expanded[static_cast<size_t>(m_macro_level) + 1]; if (!next.empty()) { @@ -2557,8 +2665,7 @@ namespace Hsdbg { // cmd/ctrl+k anywhere, or the menu item, opens it; opening resets the query // so it always starts clean and asks for the text field's focus next frame - const bool open_requested = - ImGui::IsKeyChordPressed(ImGuiMod_Ctrl | ImGuiKey_K) || m_palette_request; + const bool open_requested = ImGui::IsKeyChordPressed(ImGuiMod_Ctrl | ImGuiKey_K) || m_palette_request; m_palette_request = false; @@ -2626,9 +2733,9 @@ namespace Hsdbg add_command(stopped, Continue, "continue"); add_command(running, Pause, "pause"); add_command(has_target, Stop, "stop"); - add_command(stopped, StepOver, "step over"); - add_command(stopped, StepInto, "step into"); - add_command(stopped, StepOut, "step out"); + add_command(stopped, StepOver, "Step Over"); + add_command(stopped, StepInto, "Step Into"); + add_command(stopped, StepOut, "Step Out"); add_command(true, ToggleProfiler, m_visible.profiler ? "hide profiler" : "show profiler"); // files and symbols only clutter the list once there is a query to match @@ -2700,9 +2807,9 @@ namespace Hsdbg case Continue: report(debugger.resume(), "continue"); break; case Pause: report(debugger.pause(), "pause"); break; case Stop: report(debugger.terminate(), "stop"); break; - case StepOver: report(debugger.step_over(), "step over"); break; - case StepInto: report(debugger.step_into(), "step into"); break; - case StepOut: report(debugger.step_out(), "step out"); break; + case StepOver: report(debugger.step_over(), "Step Over"); break; + case StepInto: report(debugger.step_into(), "Step Into"); break; + case StepOut: report(debugger.step_out(), "Step Out"); break; case ToggleProfiler: m_visible.profiler = !m_visible.profiler; if (m_visible.profiler) @@ -2724,7 +2831,7 @@ namespace Hsdbg ImGui::SetNextItemWidth(-FLT_MIN); const bool submitted = ImGui::InputTextWithHint("##palette_query", - "jump to a file or symbol, or run a command", + "Jump to a file or symbol, or run a command", &m_palette_query, ImGuiInputTextFlags_EnterReturnsTrue); @@ -2739,7 +2846,7 @@ namespace Hsdbg if (entries.empty()) { - ImGui::TextDisabled("no matches"); + ImGui::TextDisabled("No matches"); } else { @@ -2796,6 +2903,127 @@ namespace Hsdbg push_console(std::format("{} failed: {}", action, result.error())); } + auto Ui::load_fonts() -> void + { + ImGuiIO& io = ImGui::GetIO(); + const std::filesystem::path dir = std::filesystem::path(HSDBG_ASSET_DIR) / "fonts"; + + // the icon glyphs sit in the private-use area and are merged over each ui + // face, so ICON_PH_* strings render inline with ordinary text + static const ImWchar icon_range[] = { ICON_MIN_PH, ICON_MAX_PH, 0 }; + + const auto load = [&](const char* file, float size, bool merge_icons) -> ImFont* { + const std::string path = (dir / file).string(); + + // a hair of extra coverage so the text holds up on the dark ground + ImFontConfig base; + base.RasterizerMultiply = 1.03f; + + ImFont* font = io.Fonts->AddFontFromFileTTF(path.c_str(), size, &base); + if (font == nullptr) + { + Log::warn("fonts: could not load '{}'", path); + return nullptr; + } + if (merge_icons) + { + ImFontConfig cfg; + cfg.MergeMode = true; + cfg.PixelSnapH = true; + cfg.GlyphMinAdvanceX = size; // one cell per icon, so they align + cfg.GlyphOffset = ImVec2(0.0f, 1.0f); // drop onto the text baseline + io.Fonts->AddFontFromFileTTF((dir / "Phosphor.ttf").string().c_str(), + size, &cfg, icon_range); + } + return font; + }; + + constexpr float UI_SIZE = 16.0f; + + // the first face added is the default and fixes the base size the rest of + // the ui scales from + m_font_ui = load("Nunito-Medium.ttf", UI_SIZE, true); + m_font_strong = load("Nunito-Bold.ttf", UI_SIZE, true); + m_font_mono = load("JetBrainsMono-Regular.ttf", UI_SIZE, false); + + // never come up unstyled: fall back to the built-in vector face + if (m_font_ui == nullptr) + { + Log::warn("fonts: bundled faces missing, using the built-in font"); + m_font_ui = io.Fonts->AddFontDefaultVector(); + } + if (m_font_strong == nullptr) + m_font_strong = m_font_ui; + if (m_font_mono == nullptr) + m_font_mono = m_font_ui; + + io.FontDefault = m_font_ui; + Widgets::set_fonts(m_font_ui, m_font_strong, m_font_mono); + } + + auto Ui::themes_directory() -> std::filesystem::path + { + return std::filesystem::path(HSDBG_ASSET_DIR) / "themes"; + } + + auto Ui::seed_preferences_from_theme() -> void + { + const auto copy = [](const ImVec4& from, float (&to)[3]) { + to[0] = from.x; + to[1] = from.y; + to[2] = from.z; + }; + + copy(m_theme.accent, m_preferences.accent); + m_preferences.rounding = m_theme.rounding; + copy(m_theme.syntax_keyword, m_preferences.color_keyword); + copy(m_theme.syntax_type, m_preferences.color_type); + copy(m_theme.syntax_string, m_preferences.color_string); + copy(m_theme.syntax_number, m_preferences.color_number); + copy(m_theme.syntax_comment, m_preferences.color_comment); + copy(m_theme.syntax_preprocessor, m_preferences.color_preprocessor); + copy(m_theme.current_line, m_preferences.color_current_line); + } + + auto Ui::load_selected_theme(bool seed_preferences) -> void + { + m_themes = list_themes(themes_directory()); + + // resolve the saved id to a file; fall back to the first theme found, + // then to the baked-in default, so the ui is never left unstyled + std::filesystem::path path; + for (const auto& entry : m_themes) + { + if (entry.id == m_preferences.theme) + { + path = entry.path; + break; + } + } + if (path.empty() && !m_themes.empty()) + { + path = m_themes.front().path; + m_preferences.theme = m_themes.front().id; + } + + m_theme = path.empty() ? default_theme() : load_theme(path); + + if (seed_preferences) + seed_preferences_from_theme(); + } + + auto Ui::select_theme(const ThemeEntry& entry) -> void + { + m_preferences.theme = entry.id; + m_theme = load_theme(entry.path); + + // picking a theme resets the tweakable accent / rounding / syntax to its + // values; the source view picks up the new syntax next frame + seed_preferences_from_theme(); + m_restyle_pending = true; + save_preferences(m_preferences_path, m_preferences); + } + auto Ui::apply_style() -> void { ImGuiStyle& style = ImGui::GetStyle(); @@ -2810,21 +3038,34 @@ namespace Hsdbg style.GrabRounding = radius; style.TabRounding = radius; - style.WindowBorderSize = 1.0f; + style.WindowBorderSize = m_theme.window_border; style.FrameBorderSize = 0.0f; - style.WindowPadding = ImVec2(8.0f, 8.0f); - style.FramePadding = ImVec2(8.0f, 4.0f); - style.ItemSpacing = ImVec2(8.0f, 5.0f); + style.WindowPadding = ImVec2(9.0f, 7.0f); + style.FramePadding = ImVec2(9.0f, 5.0f); + style.ItemSpacing = ImVec2(7.0f, 5.0f); + style.ItemInnerSpacing = ImVec2(6.0f, 4.0f); + style.IndentSpacing = 16.0f; + style.CellPadding = ImVec2(7.0f, 4.0f); style.ScrollbarSize = 11.0f; - style.GrabMinSize = 9.0f; + style.GrabMinSize = 10.0f; + style.TabBarBorderSize = 0.0f; + style.DockingSeparatorSize = 7.0f; // widen the splitter into a visible gap style.WindowTitleAlign = ImVec2(0.0f, 0.5f); style.SeparatorTextBorderSize = 1.0f; - // the accent is user-chosen; the hover/active shades are blended from it - // toward the button base so any colour stays coherent across the theme + // neutral levels come from the theme; interactive states are derived, so a + // theme names only a handful of colours. "emphasis" nudges a level toward + // the text colour, so one rule brightens dark themes and darkens light ones + const ImVec4 text = m_theme.text; + const ImVec4 bg = m_theme.bg; + const ImVec4 bg_low = m_theme.bg_low; + const ImVec4 bg_high = m_theme.bg_high; + const ImVec4 surface = m_theme.surface; + + // the accent is user-chosen (seeded from the theme); its hover/active + // shades blend from the button base toward it so it stays coherent const ImVec4 accent(m_preferences.accent[0], m_preferences.accent[1], m_preferences.accent[2], 1.0f); - const ImVec4 base(0.18f, 0.18f, 0.22f, 1.0f); const auto mix = [](ImVec4 from, ImVec4 to, float t) { return ImVec4(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t, @@ -2833,59 +3074,63 @@ namespace Hsdbg const auto fade = [](ImVec4 colour, float alpha) { return ImVec4(colour.x, colour.y, colour.z, alpha); }; + const auto emphasis = [&](ImVec4 colour, float t) { return mix(colour, text, t); }; const ImVec4 accent_bright = mix(accent, ImVec4(1.0f, 1.0f, 1.0f, 1.0f), 0.14f); - const ImVec4 accent_hover = mix(base, accent, 0.42f); - const ImVec4 accent_active = mix(base, accent, 0.68f); + const ImVec4 accent_hover = mix(surface, accent, 0.42f); + const ImVec4 accent_active = mix(surface, accent, 0.68f); + const ImVec4 grab = emphasis(bg, 0.18f); ImVec4* colors = style.Colors; - colors[ImGuiCol_Text] = ImVec4(0.86f, 0.86f, 0.88f, 1.00f); - colors[ImGuiCol_TextDisabled] = ImVec4(0.44f, 0.44f, 0.48f, 1.00f); - colors[ImGuiCol_WindowBg] = ImVec4(0.09f, 0.09f, 0.11f, 1.00f); - colors[ImGuiCol_ChildBg] = ImVec4(0.10f, 0.10f, 0.12f, 1.00f); - colors[ImGuiCol_PopupBg] = ImVec4(0.12f, 0.12f, 0.15f, 1.00f); - colors[ImGuiCol_Border] = ImVec4(0.20f, 0.20f, 0.24f, 1.00f); + colors[ImGuiCol_Text] = text; + colors[ImGuiCol_TextDisabled] = m_theme.text_dim; + colors[ImGuiCol_WindowBg] = bg; + colors[ImGuiCol_ChildBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); + colors[ImGuiCol_PopupBg] = bg_high; + colors[ImGuiCol_Border] = m_theme.border; colors[ImGuiCol_BorderShadow] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); - colors[ImGuiCol_FrameBg] = ImVec4(0.16f, 0.16f, 0.19f, 1.00f); - colors[ImGuiCol_FrameBgHovered] = ImVec4(0.21f, 0.21f, 0.26f, 1.00f); - colors[ImGuiCol_FrameBgActive] = ImVec4(0.25f, 0.25f, 0.31f, 1.00f); - colors[ImGuiCol_TitleBg] = ImVec4(0.07f, 0.07f, 0.09f, 1.00f); - colors[ImGuiCol_TitleBgActive] = ImVec4(0.11f, 0.11f, 0.14f, 1.00f); - colors[ImGuiCol_TitleBgCollapsed] = ImVec4(0.07f, 0.07f, 0.09f, 1.00f); - colors[ImGuiCol_MenuBarBg] = ImVec4(0.11f, 0.11f, 0.14f, 1.00f); - colors[ImGuiCol_ScrollbarBg] = ImVec4(0.09f, 0.09f, 0.11f, 1.00f); - colors[ImGuiCol_ScrollbarGrab] = ImVec4(0.24f, 0.24f, 0.29f, 1.00f); - colors[ImGuiCol_ScrollbarGrabHovered] = ImVec4(0.30f, 0.30f, 0.36f, 1.00f); - colors[ImGuiCol_ScrollbarGrabActive] = ImVec4(0.36f, 0.36f, 0.43f, 1.00f); + colors[ImGuiCol_FrameBg] = bg_high; + colors[ImGuiCol_FrameBgHovered] = emphasis(bg_high, 0.06f); + colors[ImGuiCol_FrameBgActive] = emphasis(bg_high, 0.10f); + colors[ImGuiCol_TitleBg] = bg_low; + colors[ImGuiCol_TitleBgActive] = emphasis(bg_low, 0.05f); + colors[ImGuiCol_TitleBgCollapsed] = bg_low; + colors[ImGuiCol_MenuBarBg] = emphasis(bg_low, 0.04f); + colors[ImGuiCol_ScrollbarBg] = bg; + colors[ImGuiCol_ScrollbarGrab] = grab; + colors[ImGuiCol_ScrollbarGrabHovered] = emphasis(bg, 0.28f); + colors[ImGuiCol_ScrollbarGrabActive] = emphasis(bg, 0.38f); colors[ImGuiCol_CheckMark] = accent; colors[ImGuiCol_SliderGrab] = accent; colors[ImGuiCol_SliderGrabActive] = accent_bright; - colors[ImGuiCol_Button] = base; + colors[ImGuiCol_Button] = surface; colors[ImGuiCol_ButtonHovered] = accent_hover; colors[ImGuiCol_ButtonActive] = accent_active; - colors[ImGuiCol_Header] = ImVec4(0.20f, 0.20f, 0.25f, 1.00f); + colors[ImGuiCol_Header] = surface; colors[ImGuiCol_HeaderHovered] = accent_hover; colors[ImGuiCol_HeaderActive] = accent_active; - colors[ImGuiCol_Separator] = ImVec4(0.20f, 0.20f, 0.24f, 1.00f); + colors[ImGuiCol_Separator] = m_theme.border; colors[ImGuiCol_SeparatorHovered] = fade(accent, 0.60f); colors[ImGuiCol_SeparatorActive] = accent; - colors[ImGuiCol_ResizeGrip] = ImVec4(0.24f, 0.24f, 0.29f, 1.00f); + colors[ImGuiCol_ResizeGrip] = grab; colors[ImGuiCol_ResizeGripHovered] = fade(accent, 0.60f); colors[ImGuiCol_ResizeGripActive] = accent; - colors[ImGuiCol_Tab] = ImVec4(0.11f, 0.11f, 0.14f, 1.00f); - colors[ImGuiCol_TabHovered] = accent_hover; - colors[ImGuiCol_TabSelected] = mix(base, accent, 0.30f); + // the selected tab takes the panel-body colour so it reads as connected to + // its content, not a floating chip; the accent overline marks it + colors[ImGuiCol_Tab] = bg_low; + colors[ImGuiCol_TabHovered] = emphasis(bg_low, 0.10f); + colors[ImGuiCol_TabSelected] = bg; colors[ImGuiCol_TabSelectedOverline] = accent; - colors[ImGuiCol_TabDimmed] = ImVec4(0.09f, 0.09f, 0.11f, 1.00f); - colors[ImGuiCol_TabDimmedSelected] = mix(ImVec4(0.14f, 0.14f, 0.17f, 1.0f), accent, 0.16f); + colors[ImGuiCol_TabDimmed] = bg_low; + colors[ImGuiCol_TabDimmedSelected] = bg; colors[ImGuiCol_DockingPreview] = fade(accent, 0.50f); - colors[ImGuiCol_DockingEmptyBg] = ImVec4(0.07f, 0.07f, 0.09f, 1.00f); - colors[ImGuiCol_TableHeaderBg] = ImVec4(0.14f, 0.14f, 0.17f, 1.00f); - colors[ImGuiCol_TableBorderStrong] = ImVec4(0.20f, 0.20f, 0.24f, 1.00f); - colors[ImGuiCol_TableBorderLight] = ImVec4(0.16f, 0.16f, 0.19f, 1.00f); + colors[ImGuiCol_DockingEmptyBg] = bg_low; + colors[ImGuiCol_TableHeaderBg] = bg_high; + colors[ImGuiCol_TableBorderStrong] = m_theme.border; + colors[ImGuiCol_TableBorderLight] = mix(bg, m_theme.border, 0.5f); colors[ImGuiCol_TableRowBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); - colors[ImGuiCol_TableRowBgAlt] = ImVec4(1.00f, 1.00f, 1.00f, 0.02f); + colors[ImGuiCol_TableRowBgAlt] = fade(text, 0.03f); colors[ImGuiCol_TextSelectedBg] = fade(accent, 0.35f); colors[ImGuiCol_NavCursor] = accent; } diff --git a/src/ui/ui.h b/src/ui/ui.h index 2bc758d..a404fc1 100644 --- a/src/ui/ui.h +++ b/src/ui/ui.h @@ -6,6 +6,7 @@ #include "ui/preferences.h" #include "ui/profiler.h" #include "ui/source_view.h" +#include "ui/theme.h" #include <cstdint> #include <filesystem> @@ -58,10 +59,24 @@ namespace Hsdbg // view when you unroll a macro from source bool profiler = false; bool macros = false; - bool demo = false; }; auto apply_style() -> void; + + // loads the bundled Inter / JetBrains Mono / Phosphor faces from + // assets/fonts and merges the icons over the ui fonts; falls back to the + // built-in vector font if the files are missing + auto load_fonts() -> void; + + // theme handling: the neutral palette lives in m_theme (reloaded from + // disk at startup and whenever a theme is picked); the accent, rounding + // and syntax colours are seeded from it into the live preferences so they + // stay individually tweakable afterwards + auto load_selected_theme(bool seed_preferences) -> void; + auto seed_preferences_from_theme() -> void; + auto select_theme(const ThemeEntry& entry) -> void; + static auto themes_directory() -> std::filesystem::path; + static auto build_default_layout(uint32_t dockspace_id) -> void; // a fuzzy omnibar (cmd/ctrl+k) over files, symbols and execution commands, @@ -120,6 +135,20 @@ namespace Hsdbg bool m_restyle_pending = false; int m_preferences_tab = 0; + // the loaded theme's neutral palette and style metrics, and the cached + // list of theme files shown in the picker (rescanned when the window + // opens so newly dropped-in files appear) + Theme m_theme; + std::vector<ThemeEntry> m_themes; + bool m_prefs_open_prev = false; + + // the bundled faces, all with Phosphor icons merged in: ui is Nunito (its + // rounded letterforms echo the rounded ui), the strong variant is its + // SemiBold for headers and toolbar labels, mono is JetBrains Mono for code + ImFont* m_font_ui = nullptr; + ImFont* m_font_strong = nullptr; + ImFont* m_font_mono = nullptr; + // the crying-pepe that lives in the toolbar; loaded on the first frame // once there is a gl context to upload its textures to, and drawn with a // dedicated shader after imgui rather than through ImGui::Image diff --git a/src/ui/widgets.cpp b/src/ui/widgets.cpp new file mode 100644 index 0000000..68f4875 --- /dev/null +++ b/src/ui/widgets.cpp @@ -0,0 +1,361 @@ +#include "ui/widgets.h" + +#include <imgui_internal.h> + +#include <cctype> +#include <cstdio> +#include <cstring> +#include <string> + +namespace Hsdbg::Widgets +{ + namespace + { + ImFont* g_ui = nullptr; + ImFont* g_strong = nullptr; + [[maybe_unused]] ImFont* g_mono = nullptr; + + auto accent_color() -> ImVec4 + { + return ImGui::GetStyle().Colors[ImGuiCol_CheckMark]; + } + + // a colour that reads on top of the accent: dark ink on a light accent, + // light ink on a dark one, so on-accent labels stay legible in any theme + auto accent_ink() -> ImU32 + { + const ImVec4 a = accent_color(); + const float luma = 0.299f * a.x + 0.587f * a.y + 0.114f * a.z; + return luma > 0.6f ? IM_COL32(12, 16, 20, 255) : IM_COL32(245, 247, 250, 255); + } + + auto with_alpha(ImVec4 colour, float alpha) -> ImU32 + { + colour.w = alpha; + return ImGui::ColorConvertFloat4ToU32(colour); + } + } + + auto set_fonts(ImFont* ui, ImFont* strong, ImFont* mono) -> void + { + g_ui = ui; + g_strong = strong; + g_mono = mono; + } + + auto section_header(const char* label) -> void + { + std::string caps; + caps.reserve(24); + for (const char* c = label; *c != '\0'; ++c) + caps.push_back(static_cast<char>(std::toupper(static_cast<unsigned char>(*c)))); + + ImGui::Spacing(); + + // small bold caps, a little dimmer than body text so it labels rather + // than shouts; then a hairline out to the right edge of the content + const float base = ImGui::GetStyle().FontSizeBase; + ImGui::PushFont(g_strong, base * 0.80f); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(ImGuiCol_Text, 0.58f)); + ImGui::TextUnformatted(caps.c_str()); + ImGui::PopStyleColor(); + ImGui::PopFont(); + + const ImVec2 lo = ImGui::GetItemRectMin(); + const ImVec2 hi = ImGui::GetItemRectMax(); + const float cy = (lo.y + hi.y) * 0.5f; + const float right = ImGui::GetWindowPos().x + ImGui::GetWindowContentRegionMax().x; + if (right > hi.x + 10.0f) + ImGui::GetWindowDrawList()->AddLine(ImVec2(hi.x + 8.0f, cy), ImVec2(right, cy), + ImGui::GetColorU32(ImGuiCol_Border), 1.0f); + } + + auto slider_float(const char* label, float* value, float min, float max, const char* fmt) -> bool + { + ImGui::PushID(label); + + const float width = ImGui::CalcItemWidth(); + const float height = ImGui::GetFrameHeight(); + const ImVec2 p = ImGui::GetCursorScreenPos(); + + ImGui::InvisibleButton("##track", ImVec2(width, height)); + const bool active = ImGui::IsItemActive(); + const bool hovered = ImGui::IsItemHovered(); + + const float before = *value; + if (active && ImGui::GetIO().MouseDown[0] && width > 0.0f) + { + const float t = ImClamp((ImGui::GetIO().MousePos.x - p.x) / width, 0.0f, 1.0f); + *value = min + t * (max - min); + } + + ImDrawList* draw = ImGui::GetWindowDrawList(); + const float th = 6.0f; + const float ty = p.y + (height - th) * 0.5f; + const float frac = (max > min) ? ImClamp((*value - min) / (max - min), 0.0f, 1.0f) : 0.0f; + const float gx = p.x + frac * width; + const ImU32 accent = ImGui::ColorConvertFloat4ToU32(accent_color()); + + draw->AddRectFilled(ImVec2(p.x, ty), ImVec2(p.x + width, ty + th), + ImGui::GetColorU32(ImGuiCol_FrameBg), th * 0.5f); + draw->AddRectFilled(ImVec2(p.x, ty), ImVec2(gx, ty + th), accent, th * 0.5f); + + const float r = (active || hovered) ? height * 0.30f : height * 0.26f; + draw->AddCircleFilled(ImVec2(gx, p.y + height * 0.5f), r, accent); + draw->AddCircleFilled(ImVec2(gx, p.y + height * 0.5f), r * 0.42f, accent_ink()); + + char buf[64]; + std::snprintf(buf, sizeof(buf), fmt, static_cast<double>(*value)); + const ImVec2 ts = ImGui::CalcTextSize(buf); + draw->AddText(ImVec2(p.x + (width - ts.x) * 0.5f, p.y + (height - ts.y) * 0.5f), + ImGui::GetColorU32(ImGuiCol_Text), buf); + + ImGui::PopID(); + + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + + return *value != before; + } + + auto icon_button(const char* id, const char* icon, bool active, const char* tooltip, + bool disabled, float size) -> bool + { + if (disabled) + ImGui::BeginDisabled(); + + ImGui::PushID(id); + + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 10.0f); + if (active) + ImGui::PushStyleColor(ImGuiCol_Button, accent_color()); + + // Button draws the bg + handles the click; the glyph is drawn by hand + const ImVec2 origin = ImGui::GetCursorScreenPos(); + const bool clicked = ImGui::Button("##btn", ImVec2(size, size)); + + if (active) + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); + + // centre on the glyph's visual bounds so an asymmetric glyph (the play + // triangle) sits dead-centre, not offset by its advance box or line-gap + const ImU32 glyph_col = active ? accent_ink() : ImGui::GetColorU32(ImGuiCol_Text); + const float cx = origin.x + size * 0.5f; + const float cy = origin.y + size * 0.5f; + + unsigned int codepoint = 0; + ImTextCharFromUtf8(&codepoint, icon, icon + std::strlen(icon)); + ImFontBaked* baked = ImGui::GetFontBaked(); + const ImFontGlyph* g = baked != nullptr ? baked->FindGlyph(static_cast<ImWchar>(codepoint)) + : nullptr; + + ImVec2 pen; + if (g != nullptr) + pen = ImVec2(cx - (g->X0 + g->X1) * 0.5f, cy - (g->Y0 + g->Y1) * 0.5f); + else + pen = ImVec2(cx - ImGui::CalcTextSize(icon).x * 0.5f, cy - ImGui::GetFontSize() * 0.5f); + + ImGui::GetWindowDrawList()->AddText(pen, glyph_col, icon); + + ImGui::PopID(); + + if (disabled) + ImGui::EndDisabled(); + + if (tooltip != nullptr && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("%s", tooltip); + + return clicked; + } + + auto toggle(const char* id, bool* value) -> bool + { + const float height = ImGui::GetFrameHeight() * 0.78f; + const float width = height * 1.8f; + const ImVec2 pos = ImGui::GetCursorScreenPos(); + + ImGui::InvisibleButton(id, ImVec2(width, height)); + const bool clicked = ImGui::IsItemClicked(); + if (clicked) + *value = !*value; + + const bool hovered = ImGui::IsItemHovered(); + ImDrawList* draw = ImGui::GetWindowDrawList(); + + const float radius = height * 0.5f; + const ImVec2 end(pos.x + width, pos.y + height); + const ImU32 track = *value ? ImGui::ColorConvertFloat4ToU32(accent_color()) + : ImGui::GetColorU32(ImGuiCol_FrameBg); + draw->AddRectFilled(pos, end, track, radius); + if (!*value) + draw->AddRect(pos, end, ImGui::GetColorU32(hovered ? ImGuiCol_Border : ImGuiCol_FrameBg), + radius); + + const float knob = radius - 2.5f; + const float knob_x = *value ? (end.x - radius) : (pos.x + radius); + const ImU32 knob_col = *value ? accent_ink() : ImGui::GetColorU32(ImGuiCol_TextDisabled); + draw->AddCircleFilled(ImVec2(knob_x, pos.y + radius), knob, knob_col); + + return clicked; + } + + auto segmented(const char* id, const char* const* labels, int count, int current) -> int + { + const ImGuiStyle& style = ImGui::GetStyle(); + ImDrawList* draw = ImGui::GetWindowDrawList(); + + const float height = ImGui::GetFrameHeight(); + const float pad = 8.0f; + const ImVec2 origin = ImGui::GetCursorScreenPos(); + + // measure so the track wraps the labels exactly + float total = 4.0f; + for (int i = 0; i < count; ++i) + total += ImGui::CalcTextSize(labels[i]).x + pad * 2.0f + 2.0f; + + const ImU32 track = ImGui::GetColorU32(ImGuiCol_FrameBg); + draw->AddRectFilled(origin, ImVec2(origin.x + total, origin.y + height), track, height * 0.5f); + + int result = current; + ImGui::PushID(id); + float x = origin.x + 3.0f; + const float seg_h = height - 6.0f; + for (int i = 0; i < count; ++i) + { + const float w = ImGui::CalcTextSize(labels[i]).x + pad * 2.0f; + const ImVec2 min(x, origin.y + 3.0f); + const ImVec2 max(x + w, origin.y + 3.0f + seg_h); + + ImGui::SetCursorScreenPos(min); + ImGui::InvisibleButton(labels[i], ImVec2(w, seg_h)); + const bool on = i == current; + if (ImGui::IsItemClicked()) + result = i; + const bool hovered = ImGui::IsItemHovered(); + + if (on) + draw->AddRectFilled(min, max, ImGui::GetColorU32(ImGuiCol_TabSelected), seg_h * 0.5f); + else if (hovered) + draw->AddRectFilled(min, max, with_alpha(style.Colors[ImGuiCol_Text], 0.06f), + seg_h * 0.5f); + + const ImU32 text = ImGui::GetColorU32(on ? ImGuiCol_Text : ImGuiCol_TextDisabled); + const ImVec2 ts = ImGui::CalcTextSize(labels[i]); + draw->AddText(ImVec2(x + (w - ts.x) * 0.5f, origin.y + (height - ts.y) * 0.5f), text, + labels[i]); + x += w + 2.0f; + } + ImGui::PopID(); + + ImGui::SetCursorScreenPos(origin); + ImGui::Dummy(ImVec2(total, height)); + return result; + } + + auto selectable_row(const char* label, bool selected, ImGuiSelectableFlags flags) -> bool + { + ImDrawList* draw = ImGui::GetWindowDrawList(); + ImDrawListSplitter split; + split.Split(draw, 2); + + // content first, on the upper channel; the rounded fill goes behind it + // once its rect and hover state are known + split.SetCurrentChannel(draw, 1); + ImGui::PushStyleColor(ImGuiCol_Header, IM_COL32(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, IM_COL32(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, IM_COL32(0, 0, 0, 0)); + const bool clicked = ImGui::Selectable(label, selected, flags); + ImGui::PopStyleColor(3); + + const bool hovered = ImGui::IsItemHovered(); + if (selected || hovered) + { + ImVec2 min = ImGui::GetItemRectMin(); + ImVec2 max = ImGui::GetItemRectMax(); + min.x += 2.0f; + max.x -= 2.0f; + const ImU32 fill = selected ? with_alpha(accent_color(), 0.18f) + : with_alpha(ImGui::GetStyle().Colors[ImGuiCol_Text], 0.055f); + split.SetCurrentChannel(draw, 0); + draw->AddRectFilled(min, max, fill, 7.0f); + if (selected) + draw->AddRect(min, max, with_alpha(accent_color(), 0.40f), 7.0f); + } + + split.Merge(draw); + return clicked; + } + + auto chip(const char* text, ImU32 dot_color) -> void + { + ImDrawList* draw = ImGui::GetWindowDrawList(); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const ImVec2 ts = ImGui::CalcTextSize(text); + + const float dot = 7.0f; + const float pad_x = 11.0f; + const float gap = 8.0f; + const float height = ImGui::GetFrameHeight(); // sit level with icon buttons + const float width = pad_x + dot + gap + ts.x + pad_x; + const ImVec2 end(pos.x + width, pos.y + height); + + draw->AddRectFilled(pos, end, ImGui::GetColorU32(ImGuiCol_FrameBg), height * 0.5f); + draw->AddRect(pos, end, ImGui::GetColorU32(ImGuiCol_Border), height * 0.5f); + draw->AddCircleFilled(ImVec2(pos.x + pad_x + dot * 0.5f, pos.y + height * 0.5f), dot * 0.5f, + dot_color); + draw->AddText(ImVec2(pos.x + pad_x + dot + gap, pos.y + (height - ts.y) * 0.5f), + ImGui::GetColorU32(ImGuiCol_Text), text); + + ImGui::Dummy(ImVec2(width, height)); + } + + auto empty_state(const char* icon, const char* text) -> void + { + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const float icon_size = ImGui::GetStyle().FontSizeBase * 2.3f; + + ImGui::PushFont(g_ui, icon_size); + const ImVec2 is = ImGui::CalcTextSize(icon); + ImGui::PopFont(); + const ImVec2 ts = ImGui::CalcTextSize(text); + const float total = is.y + ImGui::GetStyle().ItemSpacing.y + ts.y; + + const float ox = ImGui::GetCursorPosX(); + const float oy = ImGui::GetCursorPosY(); + if (avail.y > total) + ImGui::SetCursorPosY(oy + (avail.y - total) * 0.5f); + + ImGui::SetCursorPosX(ox + ImMax(0.0f, (avail.x - is.x) * 0.5f)); + ImGui::PushFont(g_ui, icon_size); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(ImGuiCol_TextDisabled, 0.65f)); + ImGui::TextUnformatted(icon); + ImGui::PopStyleColor(); + ImGui::PopFont(); + + ImGui::SetCursorPosX(ox + ImMax(0.0f, (avail.x - ts.x) * 0.5f)); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(ImGuiCol_TextDisabled)); + ImGui::TextUnformatted(text); + ImGui::PopStyleColor(); + } + + auto remove_button(const char* id) -> bool + { + const float s = ImGui::GetTextLineHeight(); + const ImVec2 p = ImGui::GetCursorScreenPos(); + + ImGui::InvisibleButton(id, ImVec2(s, s)); + const bool clicked = ImGui::IsItemClicked(); + const bool hovered = ImGui::IsItemHovered(); + + const ImU32 col = hovered ? IM_COL32(232, 106, 100, 255) + : ImGui::GetColorU32(ImGuiCol_TextDisabled); + const float pad = s * 0.30f; + ImDrawList* draw = ImGui::GetWindowDrawList(); + draw->AddLine(ImVec2(p.x + pad, p.y + pad), ImVec2(p.x + s - pad, p.y + s - pad), col, 1.7f); + draw->AddLine(ImVec2(p.x + s - pad, p.y + pad), ImVec2(p.x + pad, p.y + s - pad), col, 1.7f); + + return clicked; + } +} diff --git a/src/ui/widgets.h b/src/ui/widgets.h new file mode 100644 index 0000000..b034357 --- /dev/null +++ b/src/ui/widgets.h @@ -0,0 +1,56 @@ +#pragma once + +#include <imgui.h> + +// a small kit of hand-drawn controls that move the ui away from stock imgui: +// rounded icon buttons, ios-style toggles, pill segmented controls, rounded +// selection rows and status chips. every colour is pulled from the active +// ImGuiStyle, so these follow the theme like everything else. +// i wonder when stock imgui will stop looking like dog shit +namespace Hsdbg::Widgets +{ + // register the bundled faces so the kit can set headers in the bold face and + // values in mono; call once after the fonts are loaded + auto set_fonts(ImFont* ui, ImFont* strong, ImFont* mono) -> void; + + // a small, bold, upper-cased section label with a hairline running off to the + // right — the strong equivalent of ImGui::SeparatorText + auto section_header(const char* label) -> void; + + // a modern slider: a thin rounded track with an accent-filled portion and a + // round grab, the value centred on the track, then the label to its right + // (like ImGui::SliderFloat). returns true on the frames the value changes. + auto slider_float(const char* label, float* value, float min, float max, + const char* fmt) -> bool; + + // a rounded square button whose label is a (Phosphor) icon glyph. `active` + // fills it with the accent to mark a toggled-on tool. returns true on click. + auto icon_button(const char* id, const char* icon, bool active = false, + const char* tooltip = nullptr, bool disabled = false, + float size = 34.0f) -> bool; + + // an ios-style switch bound to *value; returns true on the frame it flips + auto toggle(const char* id, bool* value) -> bool; + + // a pill segmented control; returns the index the user ends on (unchanged + // when nothing was clicked this frame) + auto segmented(const char* id, const char* const* labels, int count, int current) -> int; + + // a full-width, rounded selection row that behaves like ImGui::Selectable + // but draws a rounded accent highlight; give it a label that may lead with an + // icon glyph. returns true on click. + auto selectable_row(const char* label, bool selected, + ImGuiSelectableFlags flags = 0) -> bool; + + // a small status chip: a coloured dot followed by text inside a rounded + // outline. advances the cursor like a normal item. + auto chip(const char* text, ImU32 dot_color) -> void; + + // a centred, dimmed icon over a caption, filling the panel — the resting + // state for a panel that has nothing to show yet + auto empty_state(const char* icon, const char* text) -> void; + + // a small borderless × for removing a row (watch, breakpoint, trace); dim at + // rest, red on hover. sized to the text line so it fits inside a table cell. + auto remove_button(const char* id) -> bool; +} |
