aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/core/window.cpp8
-rw-r--r--src/debugger/debugger.cpp40
-rw-r--r--src/debugger/types.h14
-rw-r--r--src/ui/animated_image.cpp3
-rw-r--r--src/ui/icons.h41
-rw-r--r--src/ui/macro_expander.cpp40
-rw-r--r--src/ui/preferences.cpp7
-rw-r--r--src/ui/preferences.h6
-rw-r--r--src/ui/source_view.cpp53
-rw-r--r--src/ui/source_view.h5
-rw-r--r--src/ui/theme.cpp264
-rw-r--r--src/ui/theme.h62
-rw-r--r--src/ui/ui.cpp879
-rw-r--r--src/ui/ui.h31
-rw-r--r--src/ui/widgets.cpp361
-rw-r--r--src/ui/widgets.h56
16 files changed, 1459 insertions, 411 deletions
diff --git a/src/core/window.cpp b/src/core/window.cpp
index a87798a..73a57ad 100644
--- a/src/core/window.cpp
+++ b/src/core/window.cpp
@@ -63,12 +63,16 @@ namespace Hsdbg
m_framebuffer_width = static_cast<uint32_t>(framebuffer_width);
m_framebuffer_height = static_cast<uint32_t>(framebuffer_height);
- Log::info("window '{}' created at {}x{} ({}x{} framebuffer)",
+ float content_scale = 1.0f;
+ glfwGetWindowContentScale(m_handle, &content_scale, nullptr);
+
+ Log::info("window '{}' created at {}x{} ({}x{} framebuffer, {}x content scale)",
m_title,
m_width,
m_height,
m_framebuffer_width,
- m_framebuffer_height);
+ m_framebuffer_height,
+ content_scale);
}
Window::~Window()
diff --git a/src/debugger/debugger.cpp b/src/debugger/debugger.cpp
index aba3aca..525ad65 100644
--- a/src/debugger/debugger.cpp
+++ b/src/debugger/debugger.cpp
@@ -40,9 +40,8 @@ namespace Hsdbg
#endif
}
- // lldb spawns a helper to control the inferior on unix. windows needs none,
- // linux ships lldb-server beside liblldb, and macos requires one entitled
- // with com.apple.private.cs.debugger, which only the xcode copy carries
+ // lldb needs a helper to control the inferior: none on windows, lldb-server on
+ // linux, and on macos the entitled debugserver that only xcode ships
auto debug_server_candidates() -> std::vector<std::filesystem::path>
{
const std::filesystem::path llvm_prefix(HSDBG_LLVM_PREFIX);
@@ -70,9 +69,7 @@ namespace Hsdbg
{
const std::vector<std::filesystem::path> candidates = debug_server_candidates();
- // on windows the list is empty by design and this returns before the
- // warning below; cppcheck only sees the platform it runs on, where the
- // list is never empty
+ // windows returns above with an empty list, so this is unreachable there
// cppcheck-suppress knownConditionTrueFalse
if (candidates.empty())
return;
@@ -131,10 +128,8 @@ namespace Hsdbg
return output;
}
- // rust ships lldb data formatters (the same ones rust-lldb sources) that
- // teach lldb how to print String, Vec, Option, enums and friends. without
- // them those show as raw structs, so pull them in if a toolchain is around.
- // harmless for c/c++ targets, and a no-op if lldb has no python scripting
+ // pull in rust's lldb data formatters (String, Vec, Option, enums) if a
+ // toolchain is around; harmless for c/c++ and a no-op without python
auto load_rust_formatters(lldb::SBDebugger& debugger) -> void
{
const std::string sysroot = capture_command("rustc --print sysroot 2>/dev/null");
@@ -142,8 +137,7 @@ namespace Hsdbg
if (sysroot.empty())
return;
- const std::filesystem::path etc =
- std::filesystem::path(sysroot) / "lib" / "rustlib" / "etc";
+ const std::filesystem::path etc = std::filesystem::path(sysroot) / "lib" / "rustlib" / "etc";
std::error_code error;
if (!std::filesystem::exists(etc / "lldb_commands", error))
@@ -152,8 +146,7 @@ namespace Hsdbg
lldb::SBCommandInterpreter interpreter = debugger.GetCommandInterpreter();
lldb::SBCommandReturnObject result;
- const std::string import =
- std::format("command script import \"{}\"", (etc / "lldb_lookup.py").string());
+ const std::string import = std::format("command script import \"{}\"", (etc / "lldb_lookup.py").string());
interpreter.HandleCommand(import.c_str(), result);
if (!result.Succeeded())
@@ -163,8 +156,7 @@ namespace Hsdbg
return;
}
- const std::string source =
- std::format("command source \"{}\"", (etc / "lldb_commands").string());
+ const std::string source = std::format("command source \"{}\"", (etc / "lldb_commands").string());
interpreter.HandleCommand(source.c_str(), result);
if (result.Succeeded())
@@ -1176,13 +1168,9 @@ namespace Hsdbg
if (!frame.IsValid())
return fail("no frame selected");
- // the injected code is compiled by lldb's clang and jitted into the
- // inferior, so any side effect it has (a = 60, *p = ...) writes real
- // process memory and survives the resume, exactly as if the source had
- // run it. the options here keep that from derailing the session: a fault
- // in the snippet is unwound instead of left mid-flight, breakpoints the
- // snippet reaches are ignored so it cannot stop inside itself, and a
- // timeout stops a runaway call from hanging the ui
+ // lldb jits the expression into the inferior, so side effects (a = 60, *p = ...)
+ // write real process memory. the options unwind a fault, ignore breakpoints the
+ // snippet hits, and time out a runaway call
lldb::SBExpressionOptions options;
options.SetUnwindOnError(true);
options.SetIgnoreBreakpoints(true);
@@ -1447,8 +1435,7 @@ namespace Hsdbg
for (size_t pair = 0; pair < pairs; ++pair)
{
- const auto backend_id =
- static_cast<int32_t>(thread.GetStopReasonDataAtIndex(pair * 2));
+ const auto backend_id = static_cast<int32_t>(thread.GetStopReasonDataAtIndex(pair * 2));
const auto trace = std::ranges::find(m_traces, backend_id,
&FunctionTrace::entry_backend_id);
@@ -1709,8 +1696,7 @@ namespace Hsdbg
const InstrOpenCall open = stack.back();
stack.pop_back();
- const double duration =
- static_cast<double>(record.timestamp_ns - open.start_ns) / 1.0e9;
+ const double duration = static_cast<double>(record.timestamp_ns - open.start_ns) / 1.0e9;
if (open.span_index < m_timeline.size())
m_timeline[open.span_index].duration = duration;
diff --git a/src/debugger/types.h b/src/debugger/types.h
index 0c4e95a..53c6622 100644
--- a/src/debugger/types.h
+++ b/src/debugger/types.h
@@ -39,13 +39,13 @@ namespace Hsdbg
{
switch (state)
{
- case TargetState::NoTarget: return "no target";
- case TargetState::Loaded: return "loaded";
- case TargetState::Launching: return "launching";
- case TargetState::Running: return "running";
- case TargetState::Stopped: return "stopped";
- case TargetState::Exited: return "exited";
- case TargetState::Crashed: return "crashed";
+ case TargetState::NoTarget: return "No target";
+ case TargetState::Loaded: return "Loaded";
+ case TargetState::Launching: return "Launching";
+ case TargetState::Running: return "Running";
+ case TargetState::Stopped: return "Stopped";
+ case TargetState::Exited: return "Exited";
+ case TargetState::Crashed: return "Crashed";
}
return "unknown";
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, &center_id);
const ImGuiID right_id = ImGui::DockBuilderSplitNode(center_id, ImGuiDir_Right, 0.24f, nullptr, &center_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;
+}