aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhachem <im@hachem.wtf>2026-09-18 12:25:58 +0200
committerhachem <im@hachem.wtf>2026-09-18 12:25:58 +0200
commit4f74d1400180ca4ad7cfcd1903386fda8779818d (patch)
tree258a332c3076045461b5d6f8592a52140225abd1
parent0846724177d823b632f2a4deffdcb5b6e226cd56 (diff)
feat: improve layout and themes
-rw-r--r--assets/themes/catppuccin-latte.toml25
-rw-r--r--assets/themes/everforest-light.toml25
-rw-r--r--assets/themes/everforest.toml25
-rw-r--r--assets/themes/github-light.toml25
-rw-r--r--assets/themes/gruvbox-light.toml25
-rw-r--r--assets/themes/kanagawa.toml25
-rw-r--r--assets/themes/monokai.toml25
-rw-r--r--assets/themes/one-dark.toml25
-rw-r--r--assets/themes/one-light.toml25
-rw-r--r--assets/themes/rosepine-dawn.toml25
-rw-r--r--assets/themes/solarized-dark.toml25
-rw-r--r--assets/themes/solarized-light.toml25
-rw-r--r--hsdbg.ini20
-rw-r--r--meson.build2
-rw-r--r--src/core/application.cpp63
-rw-r--r--src/debugger/debugger.cpp42
-rw-r--r--src/debugger/debugger.h3
-rw-r--r--src/debugger/types.h9
-rw-r--r--src/main.cpp59
-rw-r--r--src/ui/icons.h75
-rw-r--r--src/ui/preferences.cpp3
-rw-r--r--src/ui/preferences.h1
-rw-r--r--src/ui/source_view.cpp288
-rw-r--r--src/ui/source_view.h29
-rw-r--r--src/ui/ui.cpp796
-rw-r--r--src/ui/ui.h25
-rw-r--r--src/ui/ui_preferences.cpp667
-rw-r--r--src/ui/widgets.cpp178
-rw-r--r--src/ui/widgets.h41
-rwxr-xr-xtools/capture.py108
30 files changed, 2266 insertions, 443 deletions
diff --git a/assets/themes/catppuccin-latte.toml b/assets/themes/catppuccin-latte.toml
new file mode 100644
index 0000000..527d559
--- /dev/null
+++ b/assets/themes/catppuccin-latte.toml
@@ -0,0 +1,25 @@
+[theme]
+name = "Catppuccin Latte"
+
+[palette]
+text = "#4C4F69"
+text_dim = "#8C8FA1"
+bg = "#EFF1F5"
+bg_low = "#E6E9EF"
+bg_high = "#F9FAFC"
+surface = "#DCE0E8"
+border = "#CCD0DA"
+accent = "#1E66F5"
+
+[style]
+rounding = 7
+window_border = 1
+
+[syntax]
+keyword = "#8839EF"
+type = "#DF8E1D"
+string = "#40A02B"
+number = "#FE640B"
+comment = "#9CA0B0"
+preprocessor = "#EA76CB"
+current_line = "#DCE0E8"
diff --git a/assets/themes/everforest-light.toml b/assets/themes/everforest-light.toml
new file mode 100644
index 0000000..f5fc9c3
--- /dev/null
+++ b/assets/themes/everforest-light.toml
@@ -0,0 +1,25 @@
+[theme]
+name = "Everforest Light"
+
+[palette]
+text = "#5C6A72"
+text_dim = "#939F91"
+bg = "#FDF6E3"
+bg_low = "#F4F0D9"
+bg_high = "#FFFBEF"
+surface = "#EFEBD4"
+border = "#E0DCC7"
+accent = "#8DA101"
+
+[style]
+rounding = 6
+window_border = 1
+
+[syntax]
+keyword = "#F85552"
+type = "#DFA000"
+string = "#35A77C"
+number = "#DF69BA"
+comment = "#939F91"
+preprocessor = "#F57D26"
+current_line = "#F0F2D4"
diff --git a/assets/themes/everforest.toml b/assets/themes/everforest.toml
new file mode 100644
index 0000000..e222fda
--- /dev/null
+++ b/assets/themes/everforest.toml
@@ -0,0 +1,25 @@
+[theme]
+name = "Everforest"
+
+[palette]
+text = "#D3C6AA"
+text_dim = "#859289"
+bg = "#2D353B"
+bg_low = "#232A2E"
+bg_high = "#343F44"
+surface = "#3D484D"
+border = "#475258"
+accent = "#A7C080"
+
+[style]
+rounding = 6
+window_border = 1
+
+[syntax]
+keyword = "#E67E80"
+type = "#DBBC7F"
+string = "#83C092"
+number = "#D699B6"
+comment = "#859289"
+preprocessor = "#E69875"
+current_line = "#3D484D"
diff --git a/assets/themes/github-light.toml b/assets/themes/github-light.toml
new file mode 100644
index 0000000..9809052
--- /dev/null
+++ b/assets/themes/github-light.toml
@@ -0,0 +1,25 @@
+[theme]
+name = "GitHub Light"
+
+[palette]
+text = "#1F2328"
+text_dim = "#656D76"
+bg = "#FFFFFF"
+bg_low = "#F6F8FA"
+bg_high = "#F6F8FA"
+surface = "#EAEEF2"
+border = "#D0D7DE"
+accent = "#0969DA"
+
+[style]
+rounding = 6
+window_border = 1
+
+[syntax]
+keyword = "#CF222E"
+type = "#953800"
+string = "#0A3069"
+number = "#0550AE"
+comment = "#6E7781"
+preprocessor = "#8250DF"
+current_line = "#FFF8C5"
diff --git a/assets/themes/gruvbox-light.toml b/assets/themes/gruvbox-light.toml
new file mode 100644
index 0000000..29e195c
--- /dev/null
+++ b/assets/themes/gruvbox-light.toml
@@ -0,0 +1,25 @@
+[theme]
+name = "Gruvbox Light"
+
+[palette]
+text = "#3C3836"
+text_dim = "#7C6F64"
+bg = "#FBF1C7"
+bg_low = "#F2E5BC"
+bg_high = "#F9F5D7"
+surface = "#EBDBB2"
+border = "#D5C4A1"
+accent = "#D65D0E"
+
+[style]
+rounding = 5
+window_border = 1
+
+[syntax]
+keyword = "#9D0006"
+type = "#B57614"
+string = "#79740E"
+number = "#8F3F71"
+comment = "#928374"
+preprocessor = "#427B58"
+current_line = "#EBDBB2"
diff --git a/assets/themes/kanagawa.toml b/assets/themes/kanagawa.toml
new file mode 100644
index 0000000..b4a8a7a
--- /dev/null
+++ b/assets/themes/kanagawa.toml
@@ -0,0 +1,25 @@
+[theme]
+name = "Kanagawa"
+
+[palette]
+text = "#DCD7BA"
+text_dim = "#727169"
+bg = "#1F1F28"
+bg_low = "#16161D"
+bg_high = "#2A2A37"
+surface = "#363646"
+border = "#54546D"
+accent = "#7E9CD8"
+
+[style]
+rounding = 6
+window_border = 1
+
+[syntax]
+keyword = "#957FB8"
+type = "#7AA89F"
+string = "#98BB6C"
+number = "#D27E99"
+comment = "#727169"
+preprocessor = "#E46876"
+current_line = "#2A2A37"
diff --git a/assets/themes/monokai.toml b/assets/themes/monokai.toml
new file mode 100644
index 0000000..1d0e506
--- /dev/null
+++ b/assets/themes/monokai.toml
@@ -0,0 +1,25 @@
+[theme]
+name = "Monokai"
+
+[palette]
+text = "#F8F8F2"
+text_dim = "#75715E"
+bg = "#272822"
+bg_low = "#1E1F1C"
+bg_high = "#33342E"
+surface = "#3E3D32"
+border = "#49483E"
+accent = "#A6E22E"
+
+[style]
+rounding = 5
+window_border = 1
+
+[syntax]
+keyword = "#F92672"
+type = "#66D9EF"
+string = "#E6DB74"
+number = "#AE81FF"
+comment = "#75715E"
+preprocessor = "#FD971F"
+current_line = "#3E3D32"
diff --git a/assets/themes/one-dark.toml b/assets/themes/one-dark.toml
new file mode 100644
index 0000000..ddb2e51
--- /dev/null
+++ b/assets/themes/one-dark.toml
@@ -0,0 +1,25 @@
+[theme]
+name = "One Dark"
+
+[palette]
+text = "#ABB2BF"
+text_dim = "#5C6370"
+bg = "#282C34"
+bg_low = "#21252B"
+bg_high = "#2C313A"
+surface = "#353B45"
+border = "#3E4452"
+accent = "#61AFEF"
+
+[style]
+rounding = 6
+window_border = 1
+
+[syntax]
+keyword = "#C678DD"
+type = "#E5C07B"
+string = "#98C379"
+number = "#D19A66"
+comment = "#5C6370"
+preprocessor = "#E06C75"
+current_line = "#2C313C"
diff --git a/assets/themes/one-light.toml b/assets/themes/one-light.toml
new file mode 100644
index 0000000..80ddfa8
--- /dev/null
+++ b/assets/themes/one-light.toml
@@ -0,0 +1,25 @@
+[theme]
+name = "One Light"
+
+[palette]
+text = "#383A42"
+text_dim = "#A0A1A7"
+bg = "#FAFAFA"
+bg_low = "#EAEAEB"
+bg_high = "#FFFFFF"
+surface = "#E5E5E6"
+border = "#D3D3D4"
+accent = "#4078F2"
+
+[style]
+rounding = 6
+window_border = 1
+
+[syntax]
+keyword = "#A626A4"
+type = "#C18401"
+string = "#50A14F"
+number = "#986801"
+comment = "#A0A1A7"
+preprocessor = "#E45649"
+current_line = "#EDEDEE"
diff --git a/assets/themes/rosepine-dawn.toml b/assets/themes/rosepine-dawn.toml
new file mode 100644
index 0000000..3e73f12
--- /dev/null
+++ b/assets/themes/rosepine-dawn.toml
@@ -0,0 +1,25 @@
+[theme]
+name = "Rosé Pine Dawn"
+
+[palette]
+text = "#575279"
+text_dim = "#9893A5"
+bg = "#FAF4ED"
+bg_low = "#F2E9E1"
+bg_high = "#FFFAF3"
+surface = "#E9E0D8"
+border = "#DFDAD9"
+accent = "#907AA9"
+
+[style]
+rounding = 7
+window_border = 1
+
+[syntax]
+keyword = "#286983"
+type = "#56949F"
+string = "#EA9D34"
+number = "#D7827E"
+comment = "#9893A5"
+preprocessor = "#B4637A"
+current_line = "#EFE5DC"
diff --git a/assets/themes/solarized-dark.toml b/assets/themes/solarized-dark.toml
new file mode 100644
index 0000000..3fd3313
--- /dev/null
+++ b/assets/themes/solarized-dark.toml
@@ -0,0 +1,25 @@
+[theme]
+name = "Solarized Dark"
+
+[palette]
+text = "#93A1A1"
+text_dim = "#586E75"
+bg = "#002B36"
+bg_low = "#00212B"
+bg_high = "#073642"
+surface = "#0F4552"
+border = "#1C5361"
+accent = "#268BD2"
+
+[style]
+rounding = 6
+window_border = 1
+
+[syntax]
+keyword = "#859900"
+type = "#B58900"
+string = "#2AA198"
+number = "#D33682"
+comment = "#586E75"
+preprocessor = "#CB4B16"
+current_line = "#073642"
diff --git a/assets/themes/solarized-light.toml b/assets/themes/solarized-light.toml
new file mode 100644
index 0000000..6737ffd
--- /dev/null
+++ b/assets/themes/solarized-light.toml
@@ -0,0 +1,25 @@
+[theme]
+name = "Solarized Light"
+
+[palette]
+text = "#586E75"
+text_dim = "#93A1A1"
+bg = "#FDF6E3"
+bg_low = "#EEE8D5"
+bg_high = "#FFFBF0"
+surface = "#E9E2CD"
+border = "#D9D2BD"
+accent = "#268BD2"
+
+[style]
+rounding = 6
+window_border = 1
+
+[syntax]
+keyword = "#859900"
+type = "#B58900"
+string = "#2AA198"
+number = "#D33682"
+comment = "#93A1A1"
+preprocessor = "#CB4B16"
+current_line = "#EEE8D5"
diff --git a/hsdbg.ini b/hsdbg.ini
new file mode 100644
index 0000000..bdb8c5b
--- /dev/null
+++ b/hsdbg.ini
@@ -0,0 +1,20 @@
+theme=midnight
+ui_scale=1.26316
+accent=0.373000 0.788000 0.561000
+rounding=6
+show_mascot=true
+mascot_scale=1
+source_font_size=12.9947
+syntax_highlighting=true
+show_line_numbers=true
+highlight_current_line=true
+color_keyword=0.337255 0.611765 0.839216
+color_type=0.305882 0.788235 0.690196
+color_string=0.807843 0.568627 0.470588
+color_number=0.709804 0.807843 0.658824
+color_comment=0.415686 0.600000 0.333333
+color_preprocessor=0.772549 0.525490 0.752941
+color_current_line=0.227451 0.282353 0.180392
+stop_at_entry=true
+step_by_instruction=false
+show_fps=true
diff --git a/meson.build b/meson.build
index 394dcad..fc83629 100644
--- a/meson.build
+++ b/meson.build
@@ -213,6 +213,7 @@ imgui_dep = declare_dependency(
hsdbg_src = files(
'src/main.cpp',
'src/ui/ui.cpp',
+ 'src/ui/ui_preferences.cpp',
'src/ui/profiler.cpp',
'src/ui/preferences.cpp',
'src/ui/theme.cpp',
@@ -235,6 +236,7 @@ hsdbg_defines = [
'-D_CRT_SECURE_NO_WARNINGS',
'-DHSDBG_LLVM_PREFIX="' + llvm_prefix + '"',
'-DHSDBG_ASSET_DIR="' + asset_dir + '"',
+ '-DHSDBG_VERSION="' + meson.project_version() + '"',
]
if is_windows
diff --git a/src/core/application.cpp b/src/core/application.cpp
index 54ee788..8832499 100644
--- a/src/core/application.cpp
+++ b/src/core/application.cpp
@@ -9,7 +9,12 @@
#include <glad/gl.h>
+#include <algorithm>
+#include <charconv>
#include <chrono>
+#include <cstdlib>
+#include <fstream>
+#include <vector>
namespace Hsdbg
{
@@ -19,6 +24,41 @@ namespace Hsdbg
constexpr float MAX_DELTA_TIME = 0.25f;
constexpr double MINIMIZED_WAIT_TIMEOUT = 0.1;
+
+ // dev aid: HSDBG_CAPTURE=<file.ppm> writes the frame numbered
+ // HSDBG_CAPTURE_FRAME (default 240) as a binary ppm; returns true on
+ // that frame so the caller can quit
+ auto capture_frame(uint64_t frame, uint32_t width, uint32_t height) -> bool
+ {
+ static const char* path = std::getenv("HSDBG_CAPTURE");
+ if (path == nullptr)
+ return false;
+
+ static const uint64_t target = [] {
+ uint64_t value = 240;
+ if (const char* text = std::getenv("HSDBG_CAPTURE_FRAME"))
+ std::from_chars(text, text + std::char_traits<char>::length(text), value);
+ return value;
+ }();
+
+ if (frame != target)
+ return false;
+
+ const size_t stride = static_cast<size_t>(width) * 3;
+ std::vector<unsigned char> pixels(stride * height);
+ glPixelStorei(GL_PACK_ALIGNMENT, 1);
+ glReadPixels(0, 0, static_cast<GLsizei>(width), static_cast<GLsizei>(height),
+ GL_RGB, GL_UNSIGNED_BYTE, pixels.data());
+
+ std::ofstream out(path, std::ios::binary);
+ out << "P6\n" << width << ' ' << height << "\n255\n";
+ for (uint32_t y = height; y-- > 0;)
+ out.write(reinterpret_cast<const char*>(pixels.data() + y * stride),
+ static_cast<std::streamsize>(stride));
+
+ Log::info("capture: wrote '{}' ({}x{})", path, width, height);
+ return true;
+ }
}
Application* Application::s_instance = nullptr;
@@ -109,6 +149,26 @@ namespace Hsdbg
auto Application::update() -> void
{
m_debugger->update();
+
+ // dev aid: HSDBG_STEP_AT=<frame>[,<frame>…] steps over a line on those
+ // frames while stopped, so a capture can show a target that has moved
+ static const std::vector<uint64_t> step_frames = [] {
+ std::vector<uint64_t> frames;
+ const char* text = std::getenv("HSDBG_STEP_AT");
+ for (const char* at = text; at != nullptr && *at != '\0';)
+ {
+ uint64_t value = 0;
+ const auto [next, error] = std::from_chars(at, at + std::char_traits<char>::length(at), value);
+ if (error != std::errc{})
+ break;
+ frames.push_back(value);
+ at = *next == ',' ? next + 1 : next;
+ }
+ return frames;
+ }();
+
+ if (std::ranges::find(step_frames, m_frame_count) != step_frames.end() && m_debugger->is_stopped())
+ (void)m_debugger->step_over();
}
auto Application::render() -> void
@@ -125,5 +185,8 @@ namespace Hsdbg
m_ui->begin_frame();
m_ui->draw(*m_debugger);
m_ui->end_frame();
+
+ if (capture_frame(m_frame_count, m_window->framebuffer_width(), m_window->framebuffer_height()))
+ close();
}
}
diff --git a/src/debugger/debugger.cpp b/src/debugger/debugger.cpp
index 525ad65..c2b4c22 100644
--- a/src/debugger/debugger.cpp
+++ b/src/debugger/debugger.cpp
@@ -558,6 +558,19 @@ namespace Hsdbg
lldb::SBDebugger::Terminate();
}
+ auto Debugger::backend_version() -> std::string
+ {
+ // "lldb version 22.1.0 (https://… revision …)\n clang revision …": keep the number
+ std::string banner = lldb::SBDebugger::GetVersionString();
+ if (const size_t cut = banner.find_first_of("(\n"); cut != std::string::npos)
+ banner.resize(cut);
+ if (banner.starts_with("lldb version "))
+ banner.erase(0, 13);
+ while (!banner.empty() && banner.back() == ' ')
+ banner.pop_back();
+ return banner;
+ }
+
auto Debugger::load_target(const std::filesystem::path& executable) -> Result<void>
{
std::error_code error;
@@ -2142,6 +2155,19 @@ namespace Hsdbg
frame.line = entry.GetLine();
}
+ // the function's first and last line entries: its opening and, via
+ // the last instruction, its closing line
+ if (lldb::SBFunction function = source.GetFunction(); function.IsValid())
+ {
+ if (lldb::SBLineEntry first = function.GetStartAddress().GetLineEntry(); first.IsValid())
+ frame.function_first_line = first.GetLine();
+
+ const lldb::addr_t end = function.GetEndAddress().GetLoadAddress(m_session->target);
+ lldb::SBAddress tail = m_session->target.ResolveLoadAddress(end - 1);
+ if (lldb::SBLineEntry last = tail.GetLineEntry(); last.IsValid())
+ frame.function_last_line = std::max(last.GetLine(), frame.line);
+ }
+
m_call_stack.push_back(std::move(frame));
}
}
@@ -2162,13 +2188,25 @@ namespace Hsdbg
return;
lldb::SBValueList variables = frame.GetVariables(true, true, false, true);
+ const std::filesystem::path frame_file = path_of(frame.GetLineEntry().GetFileSpec());
for (uint32_t index = 0; index < variables.GetSize(); ++index)
{
lldb::SBValue value = variables.GetValueAtIndex(index);
- if (value.IsValid())
- m_locals.push_back(to_variable(value, 0));
+ if (!value.IsValid())
+ continue;
+
+ Variable variable = to_variable(value, 0);
+
+ if (lldb::SBDeclaration declaration = value.GetDeclaration();
+ declaration.IsValid() && !frame_file.empty() &&
+ path_of(declaration.GetFileSpec()) == frame_file)
+ {
+ variable.declaration_line = declaration.GetLine();
+ }
+
+ m_locals.push_back(std::move(variable));
}
lldb::SBValueList sets = frame.GetRegisters();
diff --git a/src/debugger/debugger.h b/src/debugger/debugger.h
index 840dff1..7608934 100644
--- a/src/debugger/debugger.h
+++ b/src/debugger/debugger.h
@@ -31,6 +31,9 @@ namespace Hsdbg
auto operator=(const Debugger&) -> Debugger& = delete;
auto operator=(Debugger&&) -> Debugger& = delete;
+ // the lldb build behind the session: the first line of its version banner
+ static auto backend_version() -> std::string;
+
// target lifetime
auto load_target(const std::filesystem::path& executable) -> Result<void>;
auto unload_target() -> void;
diff --git a/src/debugger/types.h b/src/debugger/types.h
index 53c6622..288df6b 100644
--- a/src/debugger/types.h
+++ b/src/debugger/types.h
@@ -110,6 +110,11 @@ namespace Hsdbg
std::string function;
std::filesystem::path file;
uint32_t line = 0;
+
+ // the lines the function spans in that file (zero when unknown, as for
+ // an inlined frame), so the ui can keep its annotations inside it
+ uint32_t function_first_line = 0;
+ uint32_t function_last_line = 0;
};
struct Thread
@@ -125,6 +130,10 @@ namespace Hsdbg
std::string type;
std::string value;
std::vector<Variable> children;
+
+ // the line the variable is declared on in the frame's own file (a
+ // parameter's is the signature), zero when unknown or elsewhere
+ uint32_t declaration_line = 0;
};
struct Register
diff --git a/src/main.cpp b/src/main.cpp
index b96a74a..4b0aee4 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -3,7 +3,63 @@
#include "debugger/debugger.h"
+#include <algorithm>
+#include <charconv>
+#include <cstdlib>
+#include <filesystem>
#include <span>
+#include <string_view>
+
+namespace
+{
+ // dev aid: HSDBG_WINDOW=<w>x<h> sizes the window, and HSDBG_BREAK=<file>:<line>
+ // plants a breakpoint and launches as soon as the target loads, so a capture
+ // (HSDBG_CAPTURE) lands on a stopped target with every panel populated
+ auto dev_window_size(Hsdbg::ApplicationSpec& spec) -> void
+ {
+ const char* text = std::getenv("HSDBG_WINDOW");
+ if (text == nullptr)
+ return;
+
+ const std::string_view value(text);
+ const size_t x = value.find('x');
+ if (x == std::string_view::npos)
+ return;
+
+ std::from_chars(value.data(), value.data() + x, spec.width);
+ std::from_chars(value.data() + x + 1, value.data() + value.size(), spec.height);
+ }
+
+ auto dev_break_and_launch(Hsdbg::Debugger& debugger) -> void
+ {
+ const char* text = std::getenv("HSDBG_BREAK");
+ if (text == nullptr || !debugger.has_target())
+ return;
+
+ const std::string_view value(text);
+ const size_t colon = value.rfind(':');
+ if (colon == std::string_view::npos)
+ return;
+
+ uint32_t line = 0;
+ std::from_chars(value.data() + colon + 1, value.data() + value.size(), line);
+
+ // pick the target's own path for the file so it matches what lldb reports
+ const std::string_view name = value.substr(0, colon);
+ const std::span<const std::filesystem::path> files = debugger.source_files();
+ const auto known = std::ranges::find_if(files, [&](const std::filesystem::path& candidate)
+ {
+ return candidate.filename() == name;
+ });
+
+ debugger.add_breakpoint(known != files.end() ? *known : std::filesystem::path(name), line);
+
+ Hsdbg::LaunchSpec launch;
+ launch.executable = debugger.target_path();
+ if (const auto result = debugger.launch(launch); !result)
+ Hsdbg::Log::error("{}", result.error());
+ }
+}
auto main(int argc, char** argv) -> int
{
@@ -13,6 +69,7 @@ auto main(int argc, char** argv) -> int
spec.name = "hsdbg";
spec.width = 1600;
spec.height = 900;
+ dev_window_size(spec);
Hsdbg::Application app(spec);
@@ -20,6 +77,8 @@ auto main(int argc, char** argv) -> int
{
if (const auto result = app.debugger().load_target(arguments[1]); !result)
Hsdbg::Log::error("{}", result.error());
+
+ dev_break_and_launch(app.debugger());
}
app.run();
diff --git a/src/ui/icons.h b/src/ui/icons.h
index deca7bd..cbff13d 100644
--- a/src/ui/icons.h
+++ b/src/ui/icons.h
@@ -11,31 +11,50 @@ namespace Hsdbg
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
+#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\xB0" // U+E270
+#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
+#define ICON_PH_TARGET "\xEE\x91\xBC" // U+E47C
+#define ICON_PH_MAP_PIN "\xEE\x8C\x96" // U+E316
+#define ICON_PH_HASH "\xEE\x8A\xA2" // U+E2A2
+#define ICON_PH_WARNING "\xEE\x93\xA0" // U+E4E0
+#define ICON_PH_PLUS "\xEE\x8F\x94" // U+E3D4
+#define ICON_PH_PENCIL "\xEE\x8E\xB4" // U+E3B4
+#define ICON_PH_CHECK "\xEE\x86\x82" // U+E182
+#define ICON_PH_X "\xEE\x93\xB6" // U+E4F6
+#define ICON_PH_TIMER "\xEE\x92\x92" // U+E492
+#define ICON_PH_MEMORY "\xEE\xA7\x84" // U+E9C4
+#define ICON_PH_PULSE "\xEE\x80\x80" // U+E000
+#define ICON_PH_FILE_C "\xEE\xAC\xB2" // U+EB32
+#define ICON_PH_FILE_CPP "\xEE\xAC\xAE" // U+EB2E
+#define ICON_PH_FILE_RS "\xEE\xAC\xA8" // U+EB28
+#define ICON_PH_CIRCLE_DASHED "\xEE\x98\x82" // U+E602
+#define ICON_PH_ARROW_RIGHT "\xEE\x81\xAC" // U+E06C
+#define ICON_PH_CLOCK "\xEE\x86\x9A" // U+E19A
+#define ICON_PH_LIGHTNING "\xEE\x8B\x9E" // U+E2DE
+#define ICON_PH_INFO "\xEE\x8B\x8E" // U+E2CE
diff --git a/src/ui/preferences.cpp b/src/ui/preferences.cpp
index c74f0d4..db5017b 100644
--- a/src/ui/preferences.cpp
+++ b/src/ui/preferences.cpp
@@ -67,6 +67,8 @@ namespace Hsdbg
preferences.show_mascot = as_bool(value, preferences.show_mascot);
else if (key == "mascot_scale")
preferences.mascot_scale = as_float(value, preferences.mascot_scale);
+ else if (key == "source_font_size")
+ preferences.source_font_size = as_float(value, preferences.source_font_size);
else if (key == "syntax_highlighting")
preferences.syntax_highlighting = as_bool(value, preferences.syntax_highlighting);
else if (key == "show_line_numbers")
@@ -117,6 +119,7 @@ namespace Hsdbg
<< "rounding=" << preferences.rounding << '\n'
<< "show_mascot=" << (preferences.show_mascot ? "true" : "false") << '\n'
<< "mascot_scale=" << preferences.mascot_scale << '\n'
+ << "source_font_size=" << preferences.source_font_size << '\n'
<< "syntax_highlighting=" << (preferences.syntax_highlighting ? "true" : "false") << '\n'
<< "show_line_numbers=" << (preferences.show_line_numbers ? "true" : "false") << '\n'
<< "highlight_current_line=" << (preferences.highlight_current_line ? "true" : "false") << '\n'
diff --git a/src/ui/preferences.h b/src/ui/preferences.h
index b56a46b..a8c98ae 100644
--- a/src/ui/preferences.h
+++ b/src/ui/preferences.h
@@ -18,6 +18,7 @@ namespace Hsdbg
float mascot_scale = 1.6f; // mascot height as a multiple of a button
// editor
+ float source_font_size = 16.0f; // px before ui scaling; cmd/ctrl+scroll over the source nudges it
bool syntax_highlighting = true;
bool show_line_numbers = true;
bool highlight_current_line = true;
diff --git a/src/ui/source_view.cpp b/src/ui/source_view.cpp
index 205fbca..af389f8 100644
--- a/src/ui/source_view.cpp
+++ b/src/ui/source_view.cpp
@@ -2,6 +2,8 @@
#include "core/log.h"
#include "debugger/debugger.h"
+#include "ui/icons.h"
+#include "ui/widgets.h"
#include <imgui.h>
#include <imgui_internal.h>
@@ -9,10 +11,14 @@
#include <algorithm>
#include <cctype>
+#include <cfloat>
+#include <format>
#include <fstream>
+#include <iterator>
#include <optional>
#include <span>
#include <string_view>
+#include <unordered_map>
#include <unordered_set>
namespace Hsdbg
@@ -21,6 +27,9 @@ namespace Hsdbg
{
constexpr float BREAKPOINT_RADIUS = 5.0f;
+ constexpr float MIN_FONT_SIZE = 9.0f;
+ constexpr float MAX_FONT_SIZE = 32.0f;
+ constexpr float DEFAULT_FONT_SIZE = 16.0f;
enum class Language : uint8_t
{
@@ -325,6 +334,24 @@ namespace Hsdbg
// green of comments so they do not read as part of the code
const ImU32 INLINE_VALUE_COLOR = IM_COL32(122, 140, 170, 235);
+ // the accent bar down the left edge of the line execution is on
+ constexpr float EXECUTION_BAR_WIDTH = 3.0f;
+
+ // the directory of a path cut to its last two components, so a deep path
+ // leaves room for the file name in the location bar
+ auto short_directory(const std::filesystem::path& path) -> std::string
+ {
+ const std::filesystem::path parent = path.parent_path();
+ std::vector<std::string> parts;
+ std::ranges::transform(parent, std::back_inserter(parts),
+ [](const std::filesystem::path& part) { return part.string(); });
+
+ if (parts.size() <= 3)
+ return parent.generic_string() + "/";
+
+ return "…/" + parts[parts.size() - 2] + "/" + parts.back() + "/";
+ }
+
auto line_number_width(size_t line_count) -> float
{
return ImGui::CalcTextSize(std::to_string(line_count).c_str()).x;
@@ -361,6 +388,22 @@ namespace Hsdbg
return false;
}
+ // whether the line holds `name` as an identifier: by its spans when it
+ // has them (so a mention inside a comment or string does not count),
+ // else by the raw text
+ auto line_mentions(const std::string& line, const std::vector<SourceSpan>& spans,
+ const std::string& name) -> bool
+ {
+ if (spans.empty())
+ return contains_word(line, name);
+
+ return std::ranges::any_of(spans, [&](const SourceSpan& span)
+ {
+ return span.kind == SyntaxKind::Default && span.length == name.size() &&
+ line.compare(span.start, span.length, name) == 0;
+ });
+ }
+
// a single-line, length-capped rendering of a value for the inline annotation
auto inline_value(const Variable& local) -> std::string
{
@@ -491,51 +534,153 @@ namespace Hsdbg
m_scroll_to_highlight = line != 0;
}
+ auto SourceView::set_frame_extent(uint32_t first, uint32_t last) -> void
+ {
+ m_frame_first_line = first;
+ m_frame_last_line = last;
+ }
+
auto SourceView::draw(Debugger& debugger) -> void
{
draw_open_bar();
- ImGui::Separator();
-
if (m_lines.empty())
{
- ImGui::TextDisabled("No source file open");
+ Widgets::empty_state(ICON_PH_FILE_CODE, "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);
-
+ // the code and its line-number gutter render in the monospace face at the
+ // zoomable size; the location bar above stays in the ui font
+ ImGui::PushFont(m_mono_font, m_font_size);
draw_lines(debugger);
+ ImGui::PopFont();
+ }
- if (m_mono_font != nullptr)
- ImGui::PopFont();
+ auto SourceView::handle_zoom() -> void
+ {
+ // cmd/ctrl + wheel over the code resizes it, and cmd/ctrl + 0 resets it.
+ // owning the wheel while the modifier is down keeps the view from
+ // scrolling too (imgui only holds off on its own for the real ctrl key)
+ const ImGuiIO& io = ImGui::GetIO();
+ if (!ImGui::IsWindowHovered() || !(io.KeyCtrl || io.KeySuper))
+ {
+ m_zoom_accumulator = 0.0f;
+ return;
+ }
+
+ ImGui::SetKeyOwner(ImGuiKey_MouseWheelY, ImGui::GetID("##zoom"));
+
+ // a mouse notch is a whole step; a trackpad gets there in small increments
+ float size = m_font_size;
+ m_zoom_accumulator += io.MouseWheel;
+ while (m_zoom_accumulator >= 1.0f)
+ {
+ size += 1.0f;
+ m_zoom_accumulator -= 1.0f;
+ }
+ while (m_zoom_accumulator <= -1.0f)
+ {
+ size -= 1.0f;
+ m_zoom_accumulator += 1.0f;
+ }
+
+ if (ImGui::IsKeyPressed(ImGuiKey_0))
+ size = DEFAULT_FONT_SIZE;
+
+ size = std::clamp(size, MIN_FONT_SIZE, MAX_FONT_SIZE);
+ if (size != m_font_size)
+ {
+ m_font_size = size;
+ m_zoom_badge_until = ImGui::GetTime() + 1.2;
+ }
}
auto SourceView::draw_open_bar() -> void
{
- ImGui::SetNextItemWidth(-ImGui::CalcTextSize("Open").x - ImGui::GetStyle().FramePadding.x * 4.0f);
-
- const bool submitted = ImGui::InputTextWithHint("##source_path",
- "Path to a source file",
- &m_path_input,
- ImGuiInputTextFlags_EnterReturnsTrue);
+ // with a file open the bar is a breadcrumb (dim directory, bright name)
+ // that a click turns into the path field; with none, the field is there
+ // from the start
+ if (m_editing_path || m_path.empty())
+ {
+ if (m_focus_path)
+ {
+ ImGui::SetKeyboardFocusHere();
+ m_focus_path = false;
+ }
- ImGui::SameLine();
+ ImGui::SetNextItemWidth(-FLT_MIN);
+ const bool submitted = Widgets::input_with_icon("##source_path", ICON_PH_FILE_CODE,
+ "Path to a source file, Enter to open",
+ &m_path_input,
+ ImGuiInputTextFlags_EnterReturnsTrue);
- if (ImGui::Button("Open") || submitted)
+ if (submitted)
+ {
+ if (const auto result = open(m_path_input); !result)
+ {
+ m_error = result.error();
+ Log::error("{}", m_error);
+ }
+ else
+ {
+ m_editing_path = false;
+ }
+ }
+ else if (m_editing_path && (ImGui::IsItemDeactivated() || ImGui::IsKeyPressed(ImGuiKey_Escape)))
+ {
+ m_editing_path = false;
+ m_path_input = m_path.string();
+ }
+ }
+ else
{
- if (const auto result = open(m_path_input); !result)
+ const ImGuiStyle& style = ImGui::GetStyle();
+ const ImVec2 pos = ImGui::GetCursorScreenPos();
+ const float height = ImGui::GetFrameHeight();
+ const float width = ImGui::GetContentRegionAvail().x;
+
+ ImGui::InvisibleButton("##path_row", ImVec2(width, height));
+ const bool hovered = ImGui::IsItemHovered();
+
+ ImDrawList* draw_list = ImGui::GetWindowDrawList();
+ if (hovered)
+ {
+ draw_list->AddRectFilled(pos, ImVec2(pos.x + width, pos.y + height),
+ ImGui::GetColorU32(ImGuiCol_FrameBg), style.FrameRounding);
+ ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
+ ImGui::SetTooltip("%s\nClick to type a path", m_path.string().c_str());
+ }
+
+ const ImU32 dim = ImGui::GetColorU32(ImGuiCol_TextDisabled);
+ const ImU32 bright = ImGui::GetColorU32(ImGuiCol_Text);
+ const std::string directory = short_directory(m_path);
+ const std::string name = m_path.filename().string();
+
+ float x = pos.x + style.FramePadding.x;
+ const float y = pos.y + style.FramePadding.y;
+ draw_list->AddText(ImVec2(x, y), dim, ICON_PH_FILE_CODE);
+ x += ImGui::CalcTextSize(ICON_PH_FILE_CODE).x + 6.0f;
+ draw_list->AddText(ImVec2(x, y), dim, directory.c_str());
+ x += ImGui::CalcTextSize(directory.c_str()).x;
+ draw_list->AddText(ImVec2(x, y), bright, name.c_str());
+
+ if (hovered)
+ {
+ const float pencil = ImGui::CalcTextSize(ICON_PH_PENCIL).x;
+ draw_list->AddText(ImVec2(pos.x + width - pencil - style.FramePadding.x, y), dim, ICON_PH_PENCIL);
+ }
+
+ if (ImGui::IsItemClicked())
{
- m_error = result.error();
- Log::error("{}", m_error);
+ m_editing_path = true;
+ m_focus_path = true;
+ m_path_input = m_path.string();
}
}
if (!m_error.empty())
- ImGui::TextColored(ImVec4(0.89f, 0.33f, 0.33f, 1.0f), "%s", m_error.c_str());
+ ImGui::TextColored(ImVec4(0.89f, 0.33f, 0.33f, 1.0f), ICON_PH_WARNING " %s", m_error.c_str());
}
auto SourceView::draw_lines(Debugger& debugger) -> void
@@ -551,6 +696,8 @@ namespace Hsdbg
return;
}
+ handle_zoom();
+
if (m_scroll_to_highlight && m_highlighted_line != 0)
{
ImGui::SetScrollY(static_cast<float>(m_highlighted_line - 1) * text_height -
@@ -564,6 +711,40 @@ namespace Hsdbg
? debugger.locals()
: std::span<const Variable>{};
+ // each local is annotated once, on the line that declares it: the
+ // declaration the debug info names (a parameter's is the signature), else
+ // its first mention inside the current function. never past the pc, as
+ // anything there has not run and would read stale
+ std::unordered_map<std::string, uint32_t> pinned;
+ if (!locals.empty())
+ {
+ for (const Variable& local : locals)
+ {
+ if (!local.value.empty() && local.declaration_line != 0 &&
+ local.declaration_line <= m_highlighted_line && local.declaration_line <= m_lines.size())
+ pinned.emplace(local.name, local.declaration_line);
+ }
+
+ const uint32_t first = std::max(m_frame_first_line, 1u);
+ const uint32_t last = std::min({ m_highlighted_line,
+ m_frame_last_line != 0 ? m_frame_last_line : m_highlighted_line,
+ static_cast<uint32_t>(m_lines.size()) });
+
+ for (uint32_t line = first; line <= last; ++line)
+ {
+ for (const Variable& local : locals)
+ {
+ if (local.value.empty() || pinned.contains(local.name))
+ continue;
+
+ const size_t at = line - 1;
+ const std::vector<SourceSpan>& spans = at < m_spans.size() ? m_spans[at] : std::vector<SourceSpan>{};
+ if (line_mentions(m_lines[at], spans, local.name))
+ pinned.emplace(local.name, line);
+ }
+ }
+ }
+
ImGuiListClipper clipper;
clipper.Begin(static_cast<int>(m_lines.size()), text_height);
@@ -574,12 +755,18 @@ namespace Hsdbg
const uint32_t line_number = static_cast<uint32_t>(index) + 1;
const ImVec2 row_start = ImGui::GetCursorScreenPos();
+ // the line execution sits on: a full-bleed tint plus an accent bar
+ // down the panel's left edge
if (line_number == m_highlighted_line && m_highlight_current_line)
{
- draw_list->AddRectFilled(row_start,
- ImVec2(row_start.x + ImGui::GetContentRegionAvail().x,
- row_start.y + text_height),
+ const float left = ImGui::GetWindowPos().x + 1.0f;
+ const float right = ImGui::GetWindowPos().x + ImGui::GetWindowWidth() - 1.0f;
+ draw_list->AddRectFilled(ImVec2(left, row_start.y),
+ ImVec2(right, row_start.y + text_height),
m_current_line_color);
+ draw_list->AddRectFilled(ImVec2(left, row_start.y),
+ ImVec2(left + EXECUTION_BAR_WIDTH, row_start.y + text_height),
+ ImGui::ColorConvertFloat4ToU32(Widgets::accent()));
}
ImGui::PushID(index);
@@ -636,7 +823,11 @@ namespace Hsdbg
ImGui::SameLine(0.0f, 0.0f);
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + number_width -
ImGui::CalcTextSize(number_text.c_str()).x);
- ImGui::TextDisabled("%s", number_text.c_str());
+
+ if (line_number == m_highlighted_line)
+ ImGui::TextUnformatted(number_text.c_str());
+ else
+ ImGui::TextDisabled("%s", number_text.c_str());
}
ImGui::SameLine(0.0f, GUTTER_MARGIN * 2.0f);
@@ -697,9 +888,7 @@ namespace Hsdbg
}
}
- // 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)
+ if (!pinned.empty())
{
int shown = 0;
@@ -708,18 +897,37 @@ namespace Hsdbg
if (shown >= 4)
break;
- if (local.value.empty() || !contains_word(text, local.name))
+ const auto pin = pinned.find(local.name);
+ if (pin == pinned.end() || pin->second != line_number)
continue;
- ImGui::SameLine(0.0f, shown == 0 ? GUTTER_MARGIN * 3.0f : GUTTER_MARGIN);
- ImGui::PushStyleColor(ImGuiCol_Text, INLINE_VALUE_COLOR);
- ImGui::TextUnformatted(inline_value(local).c_str());
+ const bool changed = m_changed_locals != nullptr && m_changed_locals->contains(local.name);
+
+ // set in a dim pill so it reads as the debugger's note on
+ // the line rather than as more code
+ const std::string label = inline_value(local);
+ ImGui::SameLine(0.0f, shown == 0 ? GUTTER_MARGIN * 4.0f : GUTTER_MARGIN);
+
+ constexpr float pad = 5.0f;
+ const ImVec2 pos = ImGui::GetCursorScreenPos();
+ const ImVec2 size = ImGui::CalcTextSize(label.c_str());
+ draw_list->AddRectFilled(ImVec2(pos.x, pos.y - 1.0f),
+ ImVec2(pos.x + size.x + pad * 2.0f, pos.y + size.y + 1.0f),
+ ImGui::GetColorU32(ImGuiCol_FrameBg, 0.85f), 4.0f);
+
+ ImGui::SetCursorScreenPos(ImVec2(pos.x + pad, pos.y));
+ ImGui::PushStyleColor(ImGuiCol_Text, changed ? ImGui::ColorConvertFloat4ToU32(Widgets::accent())
+ : INLINE_VALUE_COLOR);
+ ImGui::TextUnformatted(label.c_str());
ImGui::PopStyleColor();
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s = %s%s%s", local.name.c_str(), local.value.c_str(),
local.type.empty() ? "" : "\n", local.type.c_str());
+ ImGui::SameLine(0.0f, 0.0f);
+ ImGui::Dummy(ImVec2(pad, 0.0f));
+
++shown;
}
}
@@ -728,6 +936,20 @@ namespace Hsdbg
clipper.End();
+ // a passing badge with the new size, top right, after a zoom
+ if (ImGui::GetTime() < m_zoom_badge_until)
+ {
+ const std::string label = std::format("{:.0f} px", m_font_size);
+ const ImVec2 ts = ImGui::CalcTextSize(label.c_str());
+ const ImVec2 win = ImGui::GetWindowPos();
+ const ImVec2 end(win.x + ImGui::GetWindowWidth() - 16.0f, win.y + 10.0f + ts.y + 10.0f);
+ const ImVec2 start(end.x - ts.x - 20.0f, win.y + 10.0f);
+ ImDrawList* overlay = ImGui::GetWindowDrawList();
+ overlay->AddRectFilled(start, end, ImGui::GetColorU32(ImGuiCol_FrameBg), 6.0f);
+ overlay->AddRect(start, end, ImGui::GetColorU32(ImGuiCol_Border), 6.0f);
+ overlay->AddText(ImVec2(start.x + 10.0f, start.y + 5.0f), ImGui::GetColorU32(ImGuiCol_Text), label.c_str());
+ }
+
ImGui::EndChild();
}
diff --git a/src/ui/source_view.h b/src/ui/source_view.h
index 7dd949d..1e68265 100644
--- a/src/ui/source_view.h
+++ b/src/ui/source_view.h
@@ -7,6 +7,7 @@
#include <filesystem>
#include <optional>
#include <string>
+#include <unordered_set>
#include <vector>
struct ImDrawList;
@@ -46,6 +47,14 @@ namespace Hsdbg
auto draw(Debugger& debugger) -> void;
auto set_highlighted_line(uint32_t line) -> void;
+
+ // the lines of the function the selected frame is in (zero for unknown):
+ // live values are only annotated inside it
+ auto set_frame_extent(uint32_t first, uint32_t last) -> void;
+
+ // names of locals that moved since the previous stop; their annotations
+ // take the accent. the set is owned by the caller and read each frame
+ auto set_changed_locals(const std::unordered_set<std::string>* names) -> void { m_changed_locals = names; }
auto set_highlighting(bool enabled) -> void { m_highlighting_enabled = enabled; }
auto set_line_numbers(bool enabled) -> void { m_line_numbers = enabled; }
auto set_highlight_current_line(bool enabled) -> void { m_highlight_current_line = enabled; }
@@ -54,8 +63,12 @@ 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
+ // the monospace face the code (and its gutter) is rendered in, and its
+ // size in px before ui scaling: cmd/ctrl + wheel over the code nudges the
+ // size, and the ui reads it back to persist it
auto set_mono_font(ImFont* font) -> void { m_mono_font = font; }
+ auto set_font_size(float size) -> void { m_font_size = size; }
+ auto font_size() const -> float { return m_font_size; }
auto path() const -> const std::filesystem::path& { return m_path; }
auto line_count() const -> size_t { return m_lines.size(); }
@@ -77,6 +90,7 @@ namespace Hsdbg
private:
auto draw_open_bar() -> void;
auto draw_lines(Debugger& debugger) -> void;
+ auto handle_zoom() -> void;
// renders a highlighted macro name: hovering previews its expansion,
// clicking records the invocation for the macros panel to open
@@ -107,5 +121,18 @@ namespace Hsdbg
std::string m_error;
uint32_t m_highlighted_line = 0;
bool m_scroll_to_highlight = false;
+
+ // the location bar shows the open path as a breadcrumb; a click swaps in
+ // the text field (and asks for its focus) until a file opens or it loses focus
+ bool m_editing_path = false;
+ bool m_focus_path = false;
+
+ float m_font_size = 16.0f;
+ float m_zoom_accumulator = 0.0f; // wheel travel not yet turned into a step
+ double m_zoom_badge_until = 0.0; // the "NN px" badge shows until this time
+
+ uint32_t m_frame_first_line = 0;
+ uint32_t m_frame_last_line = 0;
+ const std::unordered_set<std::string>* m_changed_locals = nullptr;
};
}
diff --git a/src/ui/ui.cpp b/src/ui/ui.cpp
index 78d585d..6f025e6 100644
--- a/src/ui/ui.cpp
+++ b/src/ui/ui.cpp
@@ -16,6 +16,8 @@
#include <algorithm>
#include <cctype>
#include <cfloat>
+#include <charconv>
+#include <cstdlib>
#include <filesystem>
#include <format>
#include <optional>
@@ -58,9 +60,6 @@ namespace Hsdbg
const ImVec2 ROOT_PADDING(8.0f, 6.0f);
const ImVec2 TOOLBAR_PADDING(4.0f, 4.0f);
- // the same green the source view puts behind the current line
- const ImU32 CURRENT_INSTRUCTION_COLOR = IM_COL32(58, 72, 46, 255);
-
constexpr float BREAKPOINT_RADIUS = 5.0f;
constexpr float DISASSEMBLY_GUTTER_WIDTH = 22.0f;
@@ -68,10 +67,22 @@ 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 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);
+ // folders take a tint between the text and the accent, and the file open
+ // in the source view the accent itself, so both follow light and dark
+ // themes and the open file stays easy to find in a long tree
+ auto source_folder_color() -> ImU32
+ {
+ const ImVec4 text = ImGui::GetStyleColorVec4(ImGuiCol_Text);
+ const ImVec4 accent = Widgets::accent();
+ return ImGui::GetColorU32(ImVec4(text.x + (accent.x - text.x) * 0.55f,
+ text.y + (accent.y - text.y) * 0.55f,
+ text.z + (accent.z - text.z) * 0.55f, 1.0f));
+ }
+
+ auto source_open_file_color() -> ImU32
+ {
+ return ImGui::GetColorU32(Widgets::accent());
+ }
// the dim connectors drawn between a folder and its children
const ImU32 SOURCE_TREE_LINE_COLOR = IM_COL32(110, 110, 122, 160);
@@ -448,7 +459,7 @@ namespace Hsdbg
auto draw_instruction_table(Debugger& debugger,
std::span<const Instruction> instructions,
- bool scroll_to_current) -> void
+ bool scroll_to_current, ImU32 current_row_color) -> void
{
constexpr ImGuiTableFlags flags = ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV |
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY |
@@ -501,9 +512,7 @@ namespace Hsdbg
ImGui::PushID(index);
if (instruction.current)
- {
- ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, CURRENT_INSTRUCTION_COLOR);
- }
+ ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, current_row_color);
ImGui::TableNextColumn();
@@ -512,6 +521,12 @@ namespace Hsdbg
if (ImGui::InvisibleButton("##gutter", ImVec2(DISASSEMBLY_GUTTER_WIDTH, row_height)))
toggle_instruction_breakpoint(debugger, instruction);
+ // the same accent bar the source view puts beside its current line
+ if (instruction.current)
+ draw_list->AddRectFilled(gutter_min,
+ ImVec2(gutter_min.x + 3.0f, gutter_min.y + row_height),
+ ImGui::ColorConvertFloat4ToU32(Widgets::accent()));
+
const bool gutter_hovered = ImGui::IsItemHovered();
const ImVec2 marker_center(gutter_min.x + DISASSEMBLY_GUTTER_WIDTH * 0.5f,
gutter_min.y + row_height * 0.5f);
@@ -565,40 +580,133 @@ namespace Hsdbg
}
}
- auto draw_variable(const Variable& variable) -> void
+ auto pack_color(const float (&c)[3]) -> ImU32
{
- ImGui::TableNextRow();
- ImGui::TableNextColumn();
+ return IM_COL32(static_cast<int>(c[0] * 255.0f), static_cast<int>(c[1] * 255.0f),
+ static_cast<int>(c[2] * 255.0f), 255);
+ }
- if (variable.children.empty())
- {
- ImGui::TreeNodeEx(variable.name.c_str(),
- ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen |
- ImGuiTreeNodeFlags_SpanFullWidth);
+ // how a value cell is coloured: numbers, strings and literals borrow the
+ // editor's syntax colours, and a value that moved since the last stop
+ // takes the accent
+ struct ValueStyle
+ {
+ ImU32 number = 0;
+ ImU32 string = 0;
+ ImU32 keyword = 0;
+ ImU32 changed = 0;
+ };
- ImGui::TableNextColumn();
- ImGui::TextUnformatted(variable.type.c_str());
- ImGui::TableNextColumn();
- ImGui::TextUnformatted(variable.value.c_str());
+ auto value_style(const Preferences& preferences) -> ValueStyle
+ {
+ return ValueStyle{ pack_color(preferences.color_number), pack_color(preferences.color_string),
+ pack_color(preferences.color_keyword),
+ ImGui::ColorConvertFloat4ToU32(Widgets::accent()) };
+ }
- return;
+ auto value_color(std::string_view value, const ValueStyle& style) -> std::optional<ImU32>
+ {
+ if (value.empty())
+ return std::nullopt;
+
+ const char first = value.front();
+ if (first == '"' || first == '\'')
+ return style.string;
+
+ if (value == "true" || value == "false" || value == "nullptr" || value == "NULL")
+ return style.keyword;
+
+ const bool digit = std::isdigit(static_cast<unsigned char>(first)) != 0;
+ const bool signed_digit = (first == '-' || first == '.') && value.size() > 1 &&
+ std::isdigit(static_cast<unsigned char>(value[1])) != 0;
+ if (digit || signed_digit)
+ return style.number;
+
+ return std::nullopt;
+ }
+
+ auto draw_value(const std::string& value, const ValueStyle& style, bool changed) -> void
+ {
+ const std::optional<ImU32> colour = changed ? std::optional<ImU32>(style.changed)
+ : value_color(value, style);
+ if (colour)
+ ImGui::PushStyleColor(ImGuiCol_Text, *colour);
+ ImGui::TextUnformatted(value.c_str());
+ if (colour)
+ ImGui::PopStyleColor();
+ }
+
+ using ValueSnapshot = std::unordered_map<std::string, std::string>;
+
+ auto flatten_variables(std::span<const Variable> variables, const std::string& prefix,
+ ValueSnapshot& out) -> void
+ {
+ for (const Variable& variable : variables)
+ {
+ const std::string path = prefix.empty() ? variable.name : prefix + "." + variable.name;
+ out[path] = variable.value;
+ flatten_variables(variable.children, path, out);
}
+ }
+
+ // one variable row (name, dimmed type, coloured value), recursing into
+ // members; `path` is the dotted name the change snapshot is keyed by, and
+ // `previous` the last stop's values, or null when they are not comparable
+ auto draw_variable(const Variable& variable, const std::string& path, const ValueStyle& style,
+ const ValueSnapshot* previous) -> void
+ {
+ ImGui::TableNextRow();
+ ImGui::TableNextColumn();
- const bool open = ImGui::TreeNodeEx(variable.name.c_str(), ImGuiTreeNodeFlags_SpanFullWidth);
+ const bool leaf = variable.children.empty();
+ const ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_SpanFullWidth |
+ (leaf ? ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen : 0);
+ const bool open = ImGui::TreeNodeEx(variable.name.c_str(), flags);
ImGui::TableNextColumn();
- ImGui::TextUnformatted(variable.type.c_str());
+ ImGui::TextDisabled("%s", variable.type.c_str());
+
ImGui::TableNextColumn();
- ImGui::TextUnformatted(variable.value.c_str());
+ const std::string* before = nullptr;
+ if (previous != nullptr)
+ {
+ if (const auto found = previous->find(path); found != previous->end())
+ before = &found->second;
+ }
+ const bool changed = before != nullptr && *before != variable.value;
+ draw_value(variable.value, style, changed);
+
+ if (changed && ImGui::IsItemHovered())
+ ImGui::SetTooltip("Was %s", before->c_str());
- if (!open)
+ if (leaf || !open)
return;
for (const Variable& child : variable.children)
- draw_variable(child);
+ draw_variable(child, path + "." + child.name, style, previous);
ImGui::TreePop();
}
+
+ auto file_icon(const std::filesystem::path& path) -> const char*
+ {
+ std::string ext = path.extension().string();
+ std::ranges::transform(ext, ext.begin(), [](unsigned char c)
+ {
+ return static_cast<char>(std::tolower(c));
+ });
+
+ if (ext == ".c")
+ return ICON_PH_FILE_C;
+ if (ext == ".rs")
+ return ICON_PH_FILE_RS;
+ if (ext == ".cpp" || ext == ".cc" || ext == ".cxx" || ext == ".c++" || ext == ".h" ||
+ ext == ".hpp" || ext == ".hh" || ext == ".hxx" || ext == ".inl" || ext == ".ipp" ||
+ ext == ".m" || ext == ".mm")
+ return ICON_PH_FILE_CPP;
+
+ return ICON_PH_FILE;
+ }
}
Ui::Ui(Window& window)
@@ -616,6 +724,7 @@ namespace Hsdbg
// scaling the ui keeps the text crisp
load_fonts();
m_source_view.set_mono_font(m_font_mono);
+ m_source_view.set_changed_locals(&m_changed_locals);
// 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);
@@ -753,6 +862,50 @@ namespace Hsdbg
}
}
+ auto Ui::snapshot_values(const Debugger& debugger) -> void
+ {
+ if (!debugger.is_stopped())
+ return;
+
+ const bool same_stop = debugger.stop_count() == m_snapshot_stop;
+ const bool same_place = debugger.selected_thread() == m_snapshot_thread &&
+ debugger.selected_frame() == m_snapshot_frame;
+ if (same_stop && same_place)
+ return;
+
+ const std::span<const StackFrame> stack = debugger.call_stack();
+ const auto frame = std::ranges::find(stack, debugger.selected_frame(), &StackFrame::index);
+ std::string function = frame != stack.end() ? frame->function : std::string{};
+
+ // only a new stop in the very same frame (thread, depth and function) is
+ // compared against the last one; a frame the user switched to is not
+ m_snapshot_comparable = !same_stop && same_place && function == m_snapshot_function;
+ m_snapshot_stop = debugger.stop_count();
+ m_snapshot_thread = debugger.selected_thread();
+ m_snapshot_frame = debugger.selected_frame();
+ m_snapshot_function = std::move(function);
+
+ m_prev_locals = std::move(m_curr_locals);
+ m_curr_locals.clear();
+ flatten_variables(debugger.locals(), "", m_curr_locals);
+
+ m_changed_locals.clear();
+ if (m_snapshot_comparable)
+ {
+ for (const auto& [name, value] : m_curr_locals)
+ {
+ const auto before = m_prev_locals.find(name);
+ if (before != m_prev_locals.end() && before->second != value)
+ m_changed_locals.insert(name);
+ }
+ }
+
+ m_prev_registers = std::move(m_curr_registers);
+ m_curr_registers.clear();
+ for (const Register& entry : debugger.registers())
+ m_curr_registers[entry.name] = entry.value;
+ }
+
auto Ui::show_frame(const StackFrame& frame) -> void
{
if (frame.file.empty() || frame.line == 0)
@@ -762,6 +915,7 @@ namespace Hsdbg
open_source(frame.file);
m_source_view.set_highlighted_line(frame.line);
+ m_source_view.set_frame_extent(frame.function_first_line, frame.function_last_line);
}
auto Ui::draw(Debugger& debugger) -> void
@@ -797,22 +951,39 @@ namespace Hsdbg
ImGui::PopStyleColor();
ImGui::PopStyleVar(3);
+ // dev aid: HSDBG_OPEN=palette|preferences|target raises that overlay on
+ // the second frame, so a capture can show it
+ static const char* dev_open = std::getenv("HSDBG_OPEN");
+ if (dev_open != nullptr && ImGui::GetFrameCount() == 2)
+ {
+ const std::string_view which(dev_open);
+ m_palette_request = which == "palette";
+ m_show_preferences = which.starts_with("preferences");
+ m_load_target_pending = which == "target";
+ if (const size_t colon = which.find(':'); colon != std::string_view::npos)
+ std::from_chars(which.data() + colon + 1, which.data() + which.size(), m_preferences_tab);
+ }
+
draw_menu_bar(debugger);
draw_toolbar(debugger);
+ handle_shortcuts(debugger);
follow_stop(debugger);
follow_target(debugger);
+ snapshot_values(debugger);
const ImGuiID dockspace_id = ImGui::GetID("##hsdbg_dockspace");
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
+ // NoWindowMenuButton drops the little ▼ docking/collapse menu and
+ // NoCloseButton the node-wide ×, so a tab bar carries only its tabs (each
+ // tab's own × shows on hover)
ImGui::DockSpace(dockspace_id,
ImVec2(0.0f, ImGui::GetContentRegionAvail().y - status_bar_height),
ImGuiDockNodeFlags_PassthruCentralNode |
- static_cast<int>(ImGuiDockNodeFlags_NoWindowMenuButton));
+ static_cast<int>(ImGuiDockNodeFlags_NoWindowMenuButton) |
+ static_cast<int>(ImGuiDockNodeFlags_NoCloseButton));
if (!m_layout_built)
{
@@ -828,6 +999,14 @@ namespace Hsdbg
draw_source_panel(debugger);
+ // cmd/ctrl + scroll in the source view resized its font; keep the
+ // preference (and the file) in step
+ if (m_source_view.font_size() != m_preferences.source_font_size)
+ {
+ m_preferences.source_font_size = m_source_view.font_size();
+ save_preferences(m_preferences_path, m_preferences);
+ }
+
// a click on a highlighted macro in the source view loads it into the
// macros panel, brings the panel up and jumps its focus there
if (std::optional<std::string> request = m_source_view.take_macro_request())
@@ -1106,37 +1285,52 @@ namespace Hsdbg
// 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();
- spec.stop_at_entry = m_preferences.stop_at_entry;
+ // the transport reads like the state chip: play is green whenever it can
+ // go (and continues a stopped target, as f5 does), pause is amber only
+ // while running, stop is red only while there is a process to kill
+ const bool stopped = debugger.is_stopped();
+ const bool alive = running || stopped;
+ const ImVec4 go = state_color(TargetState::Running);
+ const ImVec4 hold = state_color(TargetState::Stopped);
+ const ImVec4 kill = state_color(TargetState::Crashed);
- report(debugger.launch(spec), "run");
+ if (Widgets::icon_button("run", ICON_PH_PLAY, false, stopped ? "Continue (F5)" : "Run (F5)",
+ !has_target || running, control, go))
+ {
+ if (stopped)
+ {
+ report(debugger.resume(), "continue");
+ }
+ else
+ {
+ LaunchSpec spec;
+ spec.executable = debugger.target_path();
+ spec.stop_at_entry = m_preferences.stop_at_entry;
+ report(debugger.launch(spec), "run");
+ }
}
ImGui::SameLine();
- if (Widgets::icon_button("pause", ICON_PH_PAUSE, false, "Pause", !running, control))
+ if (Widgets::icon_button("pause", ICON_PH_PAUSE, false, "Pause", !running, control, hold))
report(debugger.pause(), "pause");
ImGui::SameLine();
- if (Widgets::icon_button("stop", ICON_PH_STOP, false, "Stop", !has_target, control))
+ if (Widgets::icon_button("stop", ICON_PH_STOP, false, "Stop", !alive, control, kill))
report(debugger.terminate(), "stop");
gap();
- if (Widgets::icon_button("step_over", ICON_PH_STEP_OVER, false, "Step Over",
- !has_target, control))
+ if (Widgets::icon_button("step_over", ICON_PH_STEP_OVER, false, "Step Over (F10)",
+ !stopped, control))
report(debugger.step_over(step_mode), "Step Over");
ImGui::SameLine();
- if (Widgets::icon_button("step_into", ICON_PH_STEP_INTO, false, "Step Into",
- !has_target, control))
+ if (Widgets::icon_button("step_into", ICON_PH_STEP_INTO, false, "Step Into (F11)",
+ !stopped, 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))
+ if (Widgets::icon_button("step_out", ICON_PH_STEP_OUT, false, "Step Out (Shift+F11)",
+ !stopped, control))
report(debugger.step_out(), "Step Out");
// the way in to profiling and back out: the panel only exists while this
@@ -1183,27 +1377,120 @@ namespace Hsdbg
ImGui::PopStyleVar(2);
}
+ auto Ui::handle_shortcuts(Debugger& debugger) -> void
+ {
+ if (!debugger.has_target())
+ return;
+
+ const StepMode step_mode = m_preferences.step_by_instruction ? StepMode::Instruction : StepMode::Line;
+
+ // f5 continues a stopped target and runs one that is not going yet
+ if (ImGui::Shortcut(ImGuiKey_F5, ImGuiInputFlags_RouteGlobal))
+ {
+ if (debugger.is_stopped())
+ {
+ report(debugger.resume(), "continue");
+ }
+ else if (!debugger.is_running())
+ {
+ LaunchSpec spec;
+ spec.executable = debugger.target_path();
+ spec.stop_at_entry = m_preferences.stop_at_entry;
+ report(debugger.launch(spec), "run");
+ }
+ }
+
+ if (!debugger.is_stopped())
+ return;
+
+ if (ImGui::Shortcut(ImGuiKey_F10, ImGuiInputFlags_RouteGlobal))
+ report(debugger.step_over(step_mode), "Step Over");
+ if (ImGui::Shortcut(ImGuiKey_F11, ImGuiInputFlags_RouteGlobal))
+ report(debugger.step_into(step_mode), "Step Into");
+ if (ImGui::Shortcut(ImGuiMod_Shift | ImGuiKey_F11, ImGuiInputFlags_RouteGlobal))
+ report(debugger.step_out(), "Step Out");
+ }
+
auto Ui::draw_status_bar(const Debugger& debugger) -> void
{
ImGui::Separator();
+ const auto sep = []
+ {
+ ImGui::SameLine(0.0f, 10.0f);
+ ImGui::TextDisabled("·");
+ ImGui::SameLine(0.0f, 10.0f);
+ };
+
+ const auto item = [](const char* icon, const std::string& text)
+ {
+ ImGui::TextDisabled("%s", icon);
+ ImGui::SameLine(0.0f, 5.0f);
+ ImGui::TextUnformatted(text.c_str());
+ };
+
ImGui::AlignTextToFramePadding();
- ImGui::TextColored(state_color(debugger.state()), "%s", to_string(debugger.state()).data());
- ImGui::SameLine();
- ImGui::TextDisabled("|");
- ImGui::SameLine();
+ // the state as a coloured dot and word, then its reason while stopped
+ {
+ const ImVec4 colour = state_color(debugger.state());
+ const ImVec2 p = ImGui::GetCursorScreenPos();
+ const float cy = p.y + ImGui::GetStyle().FramePadding.y + ImGui::GetTextLineHeight() * 0.5f;
+ ImGui::GetWindowDrawList()->AddCircleFilled(ImVec2(p.x + 4.0f, cy), 3.5f,
+ ImGui::ColorConvertFloat4ToU32(colour));
+ ImGui::Dummy(ImVec2(8.0f, ImGui::GetTextLineHeight()));
+ ImGui::SameLine(0.0f, 6.0f);
+ ImGui::TextColored(colour, "%s", to_string(debugger.state()).data());
+
+ if (debugger.is_stopped() && debugger.stop_reason() != StopReason::None)
+ {
+ ImGui::SameLine(0.0f, 5.0f);
+ ImGui::TextDisabled("(%s)", to_string(debugger.stop_reason()).data());
+ }
+ }
if (debugger.has_target())
- ImGui::Text("%s", debugger.target_path().filename().string().c_str());
- else
- ImGui::TextDisabled("No target");
+ {
+ sep();
+ item(ICON_PH_TARGET, debugger.target_path().filename().string());
- ImGui::SameLine();
- ImGui::TextDisabled("|");
- ImGui::SameLine();
+ if (debugger.process_id() != 0 && (debugger.is_running() || debugger.is_stopped()))
+ {
+ ImGui::SameLine(0.0f, 8.0f);
+ ImGui::TextDisabled(ICON_PH_HASH " %llu", static_cast<unsigned long long>(debugger.process_id()));
+ }
+ }
+
+ // where execution sits: the selected frame's function and file:line
+ if (debugger.is_stopped())
+ {
+ const std::span<const StackFrame> stack = debugger.call_stack();
+ const auto frame = std::ranges::find(stack, debugger.selected_frame(), &StackFrame::index);
+
+ if (frame != stack.end())
+ {
+ sep();
+ item(ICON_PH_MAP_PIN, frame->function);
- ImGui::Text("%zu breakpoints", debugger.breakpoints().size());
+ if (frame->line != 0)
+ {
+ ImGui::SameLine(0.0f, 8.0f);
+ ImGui::PushFont(m_font_mono, 0.0f);
+ ImGui::TextDisabled("%s:%u", frame->file.filename().string().c_str(), frame->line);
+ ImGui::PopFont();
+ }
+ }
+
+ if (const size_t threads = debugger.threads().size(); threads > 1)
+ {
+ sep();
+ item(ICON_PH_BRANCH, std::format("{} threads", threads));
+ }
+ }
+
+ sep();
+ const size_t breakpoints = debugger.breakpoints().size();
+ item(ICON_PH_CIRCLE, std::format("{} breakpoint{}", breakpoints, breakpoints == 1 ? "" : "s"));
if (m_preferences.show_fps)
{
@@ -1230,16 +1517,23 @@ namespace Hsdbg
ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
- if (!ImGui::BeginPopupModal(LOAD_TARGET_POPUP, nullptr, ImGuiWindowFlags_AlwaysAutoResize))
+ if (!ImGui::BeginPopupModal(LOAD_TARGET_POPUP, nullptr,
+ ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar))
return;
+ ImGui::PushFont(m_font_strong, 0.0f);
+ ImGui::TextUnformatted(ICON_PH_TARGET " Load target");
+ ImGui::PopFont();
ImGui::TextDisabled("Path to an executable to debug");
+ ImGui::Spacing();
- ImGui::SetNextItemWidth(420.0f);
+ ImGui::SetNextItemWidth(460.0f);
+ if (ImGui::IsWindowAppearing())
+ ImGui::SetKeyboardFocusHere();
- const bool submitted = ImGui::InputText("##target_path",
- &m_target_input,
- ImGuiInputTextFlags_EnterReturnsTrue);
+ const bool submitted = Widgets::input_with_icon("##target_path", ICON_PH_FILE, "/path/to/a.out",
+ &m_target_input,
+ ImGuiInputTextFlags_EnterReturnsTrue);
if (ImGui::Button("Load") || submitted)
{
@@ -1281,6 +1575,7 @@ namespace Hsdbg
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);
+ m_source_view.set_font_size(m_preferences.source_font_size);
const auto pack = [](const float c[3]) {
return IM_COL32(static_cast<int>(c[0] * 255.0f), static_cast<int>(c[1] * 255.0f),
@@ -1302,233 +1597,6 @@ 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,
- viewport->WorkPos.y + viewport->WorkSize.y * 0.5f);
-
- ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
- 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###preferences", &m_show_preferences, ImGuiWindowFlags_NoDocking))
- {
- ImGui::End();
- return;
- }
-
- 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(158.0f, -footer), ImGuiChildFlags_Borders);
- for (int index = 0; index < IM_ARRAYSIZE(CATEGORIES); ++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();
-
- ImGui::SameLine();
-
- ImGui::BeginChild("##pref_content", ImVec2(0.0f, -footer));
- ImGui::PushItemWidth(-150.0f);
-
- const auto help = [](const char* text) {
- ImGui::SameLine();
- ImGui::TextDisabled("(?)");
- if (ImGui::BeginItemTooltip())
- {
- ImGui::PushTextWrapPos(ImGui::GetFontSize() * 20.0f);
- ImGui::TextUnformatted(text);
- ImGui::PopTextWrapPos();
- ImGui::EndTooltip();
- }
- };
-
- const auto accent_swatch = [&](const char* label, float (&value)[3]) {
- if (ImGui::ColorEdit3(label, value,
- ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoAlpha))
- 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)
- {
- Widgets::section_header("theme");
-
- // 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();
- const auto named = std::ranges::find(m_themes, m_preferences.theme, &ThemeEntry::id);
- if (named != m_themes.end())
- current = named->name.c_str();
-
- 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 (Widgets::slider_float("Corner rounding", &m_preferences.rounding, 0.0f, 12.0f, "%.0f px"))
- {
- changed = true;
- restyle = true;
- }
-
- toggle_row("Show FPS in the status bar", &m_preferences.show_fps);
-
- Widgets::section_header("layout");
- if (ImGui::Button("Reset window layout"))
- m_layout_built = false;
- help("Restores the default arrangement of all the docked panels.");
-
- Widgets::section_header("mascot");
- toggle_row("Show the crying pepe", &m_preferences.show_mascot);
-
- ImGui::BeginDisabled(!m_preferences.show_mascot);
- changed |= Widgets::slider_float("Pepe size", &m_preferences.mascot_scale, 1.0f, 3.0f, "%.1fx");
- ImGui::EndDisabled();
- }
- else if (m_preferences_tab == 1)
- {
- 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);
-
- 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);
- ImGui::EndDisabled();
- accent_swatch("Current line", m_preferences.color_current_line);
- }
- else if (m_preferences_tab == 2)
- {
- 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.");
-
- 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();
- ImGui::EndChild();
-
- ImGui::Separator();
-
- 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());
-
- // persist the moment anything changes, so nothing is lost to a crash
- if (changed)
- save_preferences(m_preferences_path, m_preferences);
-
- if (restyle)
- m_restyle_pending = true;
-
- ImGui::End();
- }
-
auto Ui::draw_source_panel(Debugger& debugger) -> void
{
if (!m_visible.source)
@@ -1579,7 +1647,8 @@ namespace Hsdbg
ImGui::TableNextColumn();
bool enabled = breakpoint.enabled;
- if (ImGui::Checkbox("##enabled", &enabled))
+ if (Widgets::dot_toggle("##enabled", &enabled, BREAKPOINT_COLOR,
+ BREAKPOINT_DISABLED_COLOR, !breakpoint.resolved))
debugger.set_breakpoint_enabled(breakpoint.id, enabled);
ImGui::TableNextColumn();
@@ -1593,18 +1662,13 @@ namespace Hsdbg
}
else if (breakpoint.line != 0)
{
- // 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
+ // clicking jumps the source view here. a link, 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);
- ImGui::TextUnformatted(location.c_str());
-
- if (ImGui::IsItemHovered())
- ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
-
- if (ImGui::IsItemClicked() && !breakpoint.file.empty())
+ if (Widgets::link_text(location.c_str()) && !breakpoint.file.empty())
{
open_source(breakpoint.file);
m_source_view.set_highlighted_line(breakpoint.line);
@@ -1774,7 +1838,7 @@ namespace Hsdbg
else
{
ImGui::SetNextItemWidth(-1.0f);
- ImGui::InputTextWithHint("##source_filter", "Filter files", &m_source_filter);
+ Widgets::input_with_icon("##source_filter", ICON_PH_SEARCH, "Filter files", &m_source_filter);
const SourceNode tree = build_source_tree(files);
const std::filesystem::path& open = m_source_view.path();
@@ -1795,10 +1859,10 @@ namespace Hsdbg
const bool selected = node.path == open;
if (selected)
- ImGui::PushStyleColor(ImGuiCol_Text, SOURCE_OPEN_FILE_COLOR);
+ ImGui::PushStyleColor(ImGuiCol_Text, source_open_file_color());
const bool clicked = Widgets::selectable_row(
- (std::string(ICON_PH_FILE " ") + node.name).c_str(), selected);
+ (std::string(file_icon(node.path)) + " " + node.name).c_str(), selected);
if (selected)
ImGui::PopStyleColor();
@@ -1826,7 +1890,7 @@ namespace Hsdbg
else if (reveal && node_contains(node, open))
ImGui::SetNextItemOpen(true, ImGuiCond_Always);
- ImGui::PushStyleColor(ImGuiCol_Text, SOURCE_FOLDER_COLOR);
+ ImGui::PushStyleColor(ImGuiCol_Text, source_folder_color());
const bool open_node = ImGui::TreeNodeEx(
(std::string(ICON_PH_FOLDER " ") + node.name).c_str(), flags);
ImGui::PopStyleColor();
@@ -1900,9 +1964,12 @@ namespace Hsdbg
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableHeadersRow();
+ const ValueStyle style = value_style(m_preferences);
+ const ValueSnapshot* previous = m_snapshot_comparable ? &m_prev_locals : nullptr;
+
ImGui::PushFont(m_font_mono, 0.0f);
for (const Variable& variable : debugger.locals())
- draw_variable(variable);
+ draw_variable(variable, variable.name, style, previous);
ImGui::PopFont();
ImGui::EndTable();
@@ -1968,6 +2035,9 @@ namespace Hsdbg
for (Watch& watch : m_watches)
{
+ const bool was_ok = watch.ok;
+ const std::string before = watch.value;
+
if (const auto result = debugger.evaluate(watch.expression))
{
watch.value = *result;
@@ -1978,6 +2048,8 @@ namespace Hsdbg
watch.value = result.error();
watch.ok = false;
}
+
+ watch.changed = was_ok && watch.ok && before != watch.value;
}
}
@@ -1985,8 +2057,8 @@ namespace Hsdbg
m_watch_evaluated = false;
ImGui::SetNextItemWidth(-60.0f);
- const bool submitted = ImGui::InputTextWithHint("##watch_input", "Expression to watch",
- &m_watch_input,
+ const bool submitted = Widgets::input_with_icon("##watch_input", ICON_PH_PLUS,
+ "Expression to watch", &m_watch_input,
ImGuiInputTextFlags_EnterReturnsTrue);
ImGui::SameLine();
@@ -2000,7 +2072,7 @@ namespace Hsdbg
if (m_watches.empty())
{
- ImGui::TextDisabled("Watch an expression; it re-evaluates on every stop");
+ Widgets::empty_state(ICON_PH_EYE, "Watch an expression; it re-evaluates on every stop");
}
else
{
@@ -2015,6 +2087,8 @@ namespace Hsdbg
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableHeadersRow();
+ const ValueStyle style = value_style(m_preferences);
+
ImGui::PushFont(m_font_mono, 0.0f);
size_t remove_index = m_watches.size();
@@ -2032,7 +2106,7 @@ namespace Hsdbg
if (!m_watch_evaluated && !stopped)
ImGui::TextDisabled("-");
else if (watch.ok)
- ImGui::TextUnformatted(watch.value.c_str());
+ draw_value(watch.value, style, watch.changed);
else
ImGui::TextColored(ImVec4(0.85f, 0.45f, 0.45f, 1.0f), "%s",
watch.value.c_str());
@@ -2079,6 +2153,8 @@ namespace Hsdbg
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableHeadersRow();
+ const ImU32 changed_colour = ImGui::ColorConvertFloat4ToU32(Widgets::accent());
+
ImGui::PushFont(m_font_mono, 0.0f);
for (const Register& entry : debugger.registers())
{
@@ -2086,7 +2162,20 @@ namespace Hsdbg
ImGui::TableNextColumn();
ImGui::TextUnformatted(entry.name.c_str());
ImGui::TableNextColumn();
- ImGui::Text("0x%016llx", static_cast<unsigned long long>(entry.value));
+
+ // a register that moved since the last stop takes the accent
+ const auto before = m_prev_registers.find(entry.name);
+ const bool changed = m_snapshot_comparable && before != m_prev_registers.end() &&
+ before->second != entry.value;
+
+ if (changed)
+ ImGui::PushStyleColor(ImGuiCol_Text, changed_colour);
+ Widgets::hex_value(entry.value, 16);
+ if (changed)
+ ImGui::PopStyleColor();
+
+ if (changed && ImGui::IsItemHovered())
+ ImGui::SetTooltip("Was 0x%016llx", static_cast<unsigned long long>(before->second));
}
ImGui::PopFont();
@@ -2114,7 +2203,7 @@ namespace Hsdbg
else
{
ImGui::SetNextItemWidth(-1.0f);
- ImGui::InputTextWithHint("##symbol_filter", "Filter symbols", &m_symbol_filter);
+ Widgets::input_with_icon("##symbol_filter", ICON_PH_SEARCH, "Filter symbols", &m_symbol_filter);
std::vector<size_t> visible;
@@ -2215,10 +2304,17 @@ namespace Hsdbg
else
{
if (!debugger.disassembly_name().empty())
- ImGui::TextUnformatted(debugger.disassembly_name().data());
+ {
+ ImGui::PushFont(m_font_strong, 0.0f);
+ ImGui::Text(ICON_PH_FUNCTION " %s", debugger.disassembly_name().data());
+ ImGui::PopFont();
+ ImGui::SameLine();
+ ImGui::TextDisabled("%zu instructions", instructions.size());
+ }
ImGui::PushFont(m_font_mono, 0.0f);
- draw_instruction_table(debugger, instructions, m_scroll_to_program_counter);
+ draw_instruction_table(debugger, instructions, m_scroll_to_program_counter,
+ pack_color(m_preferences.color_current_line));
ImGui::PopFont();
m_scroll_to_program_counter = false;
}
@@ -2238,12 +2334,27 @@ namespace Hsdbg
if (ImGui::BeginChild("##console_output", ImVec2(0.0f, -input_height)))
{
+ if (debugger.console_output().empty() && m_console_lines.empty())
+ Widgets::empty_state(ICON_PH_TERMINAL, "Target output lands here; type C or C++ below to run it in the target");
+
ImGui::PushFont(m_font_mono, 0.0f);
for (const std::string& line : debugger.console_output())
draw_ansi_line(line);
+ // the ui's own lines: an echoed command in the accent, a failure in
+ // red, so they stand apart from the target's output
+ const ImU32 echo_colour = ImGui::ColorConvertFloat4ToU32(Widgets::accent());
for (const std::string& line : m_console_lines)
+ {
+ const bool echo = line.starts_with("> ");
+ const bool failure = line.starts_with("error") || line.contains(" failed: ");
+
+ if (echo || failure)
+ ImGui::PushStyleColor(ImGuiCol_Text, echo ? echo_colour : IM_COL32(232, 106, 100, 255));
draw_ansi_line(line);
+ if (echo || failure)
+ ImGui::PopStyleColor();
+ }
if (m_console_scroll_pending)
{
@@ -2257,8 +2368,8 @@ namespace Hsdbg
ImGui::SetNextItemWidth(-1.0f);
- const bool submitted = ImGui::InputTextWithHint("##console_input",
- "Expression to evaluate",
+ const bool submitted = Widgets::input_with_icon("##console_input", ICON_PH_CARET_RIGHT,
+ "Expression to evaluate in the target",
&m_console_input,
ImGuiInputTextFlags_EnterReturnsTrue);
@@ -2298,10 +2409,12 @@ namespace Hsdbg
else
{
bool sampling = debugger.sampling_enabled();
- if (ImGui::Checkbox("sample while running", &sampling))
+ if (Widgets::toggle("##sampling", &sampling))
debugger.set_sampling_enabled(sampling);
ImGui::SameLine();
+ ImGui::TextUnformatted("Sample while running");
+ ImGui::SameLine();
ImGui::TextDisabled("(any binary, approximate)");
}
@@ -2315,11 +2428,11 @@ namespace Hsdbg
ImGui::EndChild();
// exact per-function timings, and the box to add another
- if (ImGui::CollapsingHeader("function timings", ImGuiTreeNodeFlags_DefaultOpen))
+ if (ImGui::CollapsingHeader(ICON_PH_TIMER " Function timings", ImGuiTreeNodeFlags_DefaultOpen))
{
ImGui::SetNextItemWidth(-70.0f);
- const bool submitted = ImGui::InputTextWithHint("##trace_input", "Function to time",
- &m_trace_input,
+ const bool submitted = Widgets::input_with_icon("##trace_input", ICON_PH_PLUS,
+ "Function to time", &m_trace_input,
ImGuiInputTextFlags_EnterReturnsTrue);
ImGui::SameLine();
@@ -2398,7 +2511,7 @@ namespace Hsdbg
// the live footprint of the process under debug
if (debugger.has_target() &&
- ImGui::CollapsingHeader("resident memory", ImGuiTreeNodeFlags_DefaultOpen))
+ ImGui::CollapsingHeader(ICON_PH_MEMORY " Resident memory", ImGuiTreeNodeFlags_DefaultOpen))
{
const TimeSeries& memory = m_profiler.target_memory_mb();
@@ -2412,13 +2525,16 @@ namespace Hsdbg
// hsdbg's own frame cost, tucked away: useful when the ui itself feels
// slow, not part of profiling the target
- if (ImGui::CollapsingHeader("hsdbg self-timing"))
+ if (ImGui::CollapsingHeader(ICON_PH_PULSE " hsdbg self-timing"))
{
bool paused = m_profiler.paused();
- if (ImGui::Checkbox("pause graphs", &paused))
+ if (Widgets::toggle("##pause_graphs", &paused))
m_profiler.set_paused(paused);
ImGui::SameLine();
+ ImGui::TextUnformatted("Pause graphs");
+
+ ImGui::SameLine();
if (ImGui::SmallButton("Reset"))
m_profiler.reset();
@@ -2510,7 +2626,7 @@ namespace Hsdbg
const float y1 = baseline - static_cast<float>(span.depth) * row_height;
const float y0 = y1 - (row_height - 2.0f);
- const ImU32 fill = ImColor::HSV(span.trace_id * 0.13f, 0.55f, 0.78f);
+ const ImU32 fill = ImColor::HSV(static_cast<float>(span.trace_id) * 0.13f, 0.55f, 0.78f);
draw_list->AddRectFilled(ImVec2(x0, y0), ImVec2(x0 + width, y1), fill, 2.0f);
draw_list->AddRect(ImVec2(x0, y0), ImVec2(x0 + width, y1), IM_COL32(0, 0, 0, 90), 2.0f);
@@ -2687,7 +2803,11 @@ namespace Hsdbg
constexpr ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings;
- if (!ImGui::BeginPopup("##command_palette", flags))
+ ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10.0f, 10.0f));
+ const bool open = ImGui::BeginPopup("##command_palette", flags);
+ ImGui::PopStyleVar();
+
+ if (!open)
{
// esc or a click outside closed it
m_palette_open = false;
@@ -2724,14 +2844,14 @@ namespace Hsdbg
entries.push_back({ label, "command", Kind::Command, command, {}, 0 });
};
- add_command(has_target && !running, Run, "run");
- add_command(stopped, Continue, "continue");
- add_command(running, Pause, "pause");
- add_command(has_target, Stop, "stop");
+ add_command(has_target && !running, Run, "Run");
+ 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(true, ToggleProfiler, m_visible.profiler ? "hide profiler" : "show profiler");
+ 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
if (!m_palette_query.empty())
@@ -2825,7 +2945,7 @@ namespace Hsdbg
}
ImGui::SetNextItemWidth(-FLT_MIN);
- const bool submitted = ImGui::InputTextWithHint("##palette_query",
+ const bool submitted = Widgets::input_with_icon("##palette_query", ICON_PH_SEARCH,
"Jump to a file or symbol, or run a command",
&m_palette_query,
ImGuiInputTextFlags_EnterReturnsTrue);
@@ -2856,7 +2976,12 @@ namespace Hsdbg
ImGui::PushID(index);
- if (ImGui::Selectable("##row", selected, ImGuiSelectableFlags_SpanAllColumns))
+ const char* icon = entry.kind == Kind::File ? ICON_PH_FILE_CODE
+ : entry.kind == Kind::Symbol ? ICON_PH_FUNCTION
+ : ICON_PH_PLAY;
+ const std::string label = std::format("{} {}##row", icon, entry.label);
+
+ if (Widgets::selectable_row(label.c_str(), selected))
activate(entry);
// keep the keyboard-picked row in view as it moves
@@ -2864,11 +2989,8 @@ namespace Hsdbg
ImGui::IsKeyPressed(ImGuiKey_DownArrow) || ImGui::IsWindowAppearing()))
ImGui::SetScrollHereY(0.5f);
- ImGui::SameLine(0.0f, 0.0f);
- ImGui::TextUnformatted(entry.label.c_str());
-
const float hint_width = ImGui::CalcTextSize(entry.hint).x;
- ImGui::SameLine(ImGui::GetContentRegionAvail().x - hint_width);
+ ImGui::SameLine(ImGui::GetWindowContentRegionMax().x - hint_width - 8.0f);
ImGui::TextDisabled("%s", entry.hint);
ImGui::PopID();
@@ -3039,6 +3161,9 @@ namespace Hsdbg
style.ScrollbarSize = 11.0f;
style.GrabMinSize = 10.0f;
style.TabBarBorderSize = 0.0f;
+ style.TabBarOverlineSize = 2.0f;
+ style.TabCloseButtonMinWidthSelected = 0.0f; // the × only on hover
+ style.TabCloseButtonMinWidthUnselected = FLT_MAX; // and never on a background tab
style.DockingSeparatorSize = 7.0f; // widen the splitter into a visible gap
style.WindowTitleAlign = ImVec2(0.0f, 0.5f);
style.SeparatorTextBorderSize = 1.0f;
@@ -3122,6 +3247,7 @@ namespace Hsdbg
colors[ImGuiCol_TableRowBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f);
colors[ImGuiCol_TableRowBgAlt] = fade(text, 0.03f);
colors[ImGuiCol_TextSelectedBg] = fade(accent, 0.35f);
+ colors[ImGuiCol_TextLink] = accent;
colors[ImGuiCol_NavCursor] = accent;
}
}
diff --git a/src/ui/ui.h b/src/ui/ui.h
index a404fc1..8c221a7 100644
--- a/src/ui/ui.h
+++ b/src/ui/ui.h
@@ -12,6 +12,8 @@
#include <filesystem>
#include <string>
#include <string_view>
+#include <unordered_map>
+#include <unordered_set>
#include <vector>
namespace Hsdbg
@@ -85,6 +87,9 @@ namespace Hsdbg
auto draw_menu_bar(Debugger& debugger) -> void;
auto draw_toolbar(Debugger& debugger) -> void;
+
+ // f5 / f10 / f11 / shift+f11, the keys the run menu advertises
+ auto handle_shortcuts(Debugger& debugger) -> void;
auto draw_status_bar(const Debugger& debugger) -> void;
auto draw_load_target_popup(Debugger& debugger) -> void;
auto draw_preferences_window() -> void;
@@ -118,6 +123,10 @@ namespace Hsdbg
auto follow_stop(Debugger& debugger) -> void;
auto follow_target(Debugger& debugger) -> void;
+ // records the locals and registers of the selected frame at each stop so
+ // the panels can pick out what changed since the last one
+ auto snapshot_values(const Debugger& debugger) -> void;
+
// selects the innermost frame that has source and shows it, so a new
// thread or stop lands the whole ui on real code rather than a runtime
// internal frame with nothing to display
@@ -140,6 +149,7 @@ namespace Hsdbg
// opens so newly dropped-in files appear)
Theme m_theme;
std::vector<ThemeEntry> m_themes;
+ std::vector<Theme> m_theme_previews; // one palette per entry, for the picker's cards
bool m_prefs_open_prev = false;
// the bundled faces, all with Phosphor icons merged in: ui is Nunito (its
@@ -176,6 +186,7 @@ namespace Hsdbg
std::string expression;
std::string value;
bool ok = false;
+ bool changed = false; // moved since the previous evaluation
};
std::vector<Watch> m_watches;
@@ -206,6 +217,20 @@ namespace Hsdbg
uint64_t m_followed_stop = 0;
std::filesystem::path m_followed_target;
+ // the values seen at the previous and current stop, locals keyed by their
+ // dotted path; comparable only while the stop landed in the same thread,
+ // frame and function as the last one, else nothing is flagged
+ std::unordered_map<std::string, std::string> m_prev_locals;
+ std::unordered_map<std::string, std::string> m_curr_locals;
+ std::unordered_map<std::string, uint64_t> m_prev_registers;
+ std::unordered_map<std::string, uint64_t> m_curr_registers;
+ std::unordered_set<std::string> m_changed_locals; // top-level names that moved, for the source view
+ std::string m_snapshot_function;
+ uint64_t m_snapshot_stop = 0;
+ uint64_t m_snapshot_thread = 0;
+ uint32_t m_snapshot_frame = 0;
+ bool m_snapshot_comparable = false;
+
// the source file whose folder chain the tree last expanded; when the
// open file moves away from it the tree reveals the new path once
std::filesystem::path m_revealed_source;
diff --git a/src/ui/ui_preferences.cpp b/src/ui/ui_preferences.cpp
new file mode 100644
index 0000000..a1768a9
--- /dev/null
+++ b/src/ui/ui_preferences.cpp
@@ -0,0 +1,667 @@
+#include "ui/ui.h"
+
+#include "debugger/debugger.h"
+#include "ui/icons.h"
+#include "ui/widgets.h"
+
+#include <glad/gl.h>
+#include <imgui.h>
+#include <imgui_internal.h>
+
+#include <algorithm>
+#include <cfloat>
+#include <cmath>
+#include <format>
+#include <iterator>
+#include <string>
+
+#ifndef HSDBG_VERSION
+ #define HSDBG_VERSION "dev"
+#endif
+
+#ifndef HSDBG_ASSET_DIR
+ #define HSDBG_ASSET_DIR ""
+#endif
+
+// the preferences window: a category rail on the left and, on the right, that
+// category's settings grouped into raised cards of title / description / control
+// rows, with theme and accent pickers that show what they pick
+namespace Hsdbg
+{
+ namespace
+ {
+ constexpr const char* CATEGORIES[] = { "Appearance", "Editor", "Debugger", "About" };
+ constexpr const char* CATEGORY_ICONS[] = { ICON_PH_SLIDERS, ICON_PH_FILE_CODE, ICON_PH_BUG, ICON_PH_INFO };
+ constexpr const char* CATEGORY_BLURBS[] = {
+ "Theme, accent and the shape of the interface",
+ "How source is drawn and coloured",
+ "How targets launch and step, and the keys that drive them",
+ "What this build is made of",
+ };
+
+ struct AccentPreset
+ {
+ const char* name;
+ float rgb[3];
+ };
+
+ constexpr AccentPreset ACCENTS[] = {
+ { "Teal", { 0.302f, 0.714f, 0.769f } }, { "Sky", { 0.357f, 0.659f, 0.961f } },
+ { "Indigo", { 0.486f, 0.486f, 0.941f } }, { "Violet", { 0.690f, 0.518f, 0.902f } },
+ { "Rose", { 0.886f, 0.478f, 0.604f } }, { "Coral", { 0.941f, 0.541f, 0.365f } },
+ { "Amber", { 0.910f, 0.690f, 0.290f } }, { "Mint", { 0.373f, 0.788f, 0.561f } },
+ };
+
+ constexpr float CARD_PAD_X = 14.0f;
+ constexpr float CARD_PAD_Y = 4.0f;
+ constexpr float ROW_PAD_Y = 7.0f;
+
+ // the window, rail and controls grow with the ui scale, since imgui only
+ // scales the fonts and would otherwise leave them cramped at 1.5x
+ auto ui_scale() -> float
+ {
+ return ImGui::GetStyle().FontScaleMain;
+ }
+
+ auto slider_width() -> float
+ {
+ return 190.0f * ui_scale();
+ }
+
+ auto rgb(const float (&c)[3]) -> ImVec4
+ {
+ return ImVec4(c[0], c[1], c[2], 1.0f);
+ }
+
+ auto u32(const ImVec4& colour) -> ImU32
+ {
+ return ImGui::ColorConvertFloat4ToU32(colour);
+ }
+
+ auto card_rounding() -> float
+ {
+ return ImGui::GetStyle().FrameRounding + 3.0f;
+ }
+
+ // a card is drawn after its rows so it can wrap them: rows go to the upper
+ // channel, the card body to the lower one once the height is known
+ struct Card
+ {
+ ImVec2 origin;
+ float width = 0.0f;
+ int rows = 0;
+ ImDrawListSplitter splitter;
+ };
+
+ Card g_card;
+
+ auto begin_card() -> void
+ {
+ ImDrawList* draw = ImGui::GetWindowDrawList();
+ g_card.origin = ImGui::GetCursorScreenPos();
+ g_card.width = ImGui::GetContentRegionAvail().x;
+ g_card.rows = 0;
+ g_card.splitter.Split(draw, 2);
+ g_card.splitter.SetCurrentChannel(draw, 1);
+ ImGui::SetCursorScreenPos(ImVec2(g_card.origin.x + CARD_PAD_X, g_card.origin.y + CARD_PAD_Y));
+ }
+
+ auto end_card() -> void
+ {
+ ImDrawList* draw = ImGui::GetWindowDrawList();
+ const ImVec2 end(g_card.origin.x + g_card.width, ImGui::GetCursorScreenPos().y + CARD_PAD_Y);
+
+ g_card.splitter.SetCurrentChannel(draw, 0);
+ draw->AddRectFilled(g_card.origin, end, ImGui::GetColorU32(ImGuiCol_FrameBg), card_rounding());
+ draw->AddRect(g_card.origin, end, ImGui::GetColorU32(ImGuiCol_Border), card_rounding());
+ g_card.splitter.Merge(draw);
+
+ ImGui::SetCursorScreenPos(ImVec2(g_card.origin.x, end.y));
+ ImGui::Dummy(ImVec2(g_card.width, 0.0f));
+ ImGui::Spacing();
+ }
+
+ // one setting: title and dim description on the left, the control pinned
+ // to the right edge, both centred on the row; a hairline rules rows apart
+ template <typename Control>
+ auto setting_row(const char* title, const char* description, float control_width,
+ float control_height, Control&& control) -> void
+ {
+ const float base = ImGui::GetStyle().FontSizeBase;
+ const float title_h = ImGui::GetTextLineHeight();
+ const float desc_h = description != nullptr ? std::floor(base * 0.88f) + 3.0f : 0.0f;
+ const float row_h = std::max(title_h + desc_h, control_height) + ROW_PAD_Y * 2.0f;
+ const float inner = g_card.width - CARD_PAD_X * 2.0f;
+ const ImVec2 top(g_card.origin.x + CARD_PAD_X, ImGui::GetCursorScreenPos().y);
+ ImDrawList* draw = ImGui::GetWindowDrawList();
+
+ if (g_card.rows > 0)
+ draw->AddLine(ImVec2(top.x, top.y - 0.5f), ImVec2(top.x + inner, top.y - 0.5f),
+ ImGui::GetColorU32(ImGuiCol_Border, 0.6f));
+
+ ImGui::SetCursorScreenPos(ImVec2(top.x, top.y + (row_h - title_h - desc_h) * 0.5f));
+ ImGui::TextUnformatted(title);
+ if (description != nullptr)
+ {
+ ImGui::SetCursorScreenPos(ImVec2(top.x, ImGui::GetCursorScreenPos().y));
+ ImGui::PushFont(nullptr, base * 0.88f);
+ ImGui::TextDisabled("%s", description);
+ ImGui::PopFont();
+ }
+
+ ImGui::SetCursorScreenPos(ImVec2(top.x + inner - control_width, top.y + (row_h - control_height) * 0.5f));
+ ImGui::PushID(title);
+ control();
+ ImGui::PopID();
+
+ ImGui::SetCursorScreenPos(ImVec2(top.x, top.y + row_h));
+ ImGui::Dummy(ImVec2(inner, 0.0f));
+ ++g_card.rows;
+ }
+
+ auto toggle_row(const char* title, const char* description, bool* value) -> bool
+ {
+ bool changed = false;
+ const float h = ImGui::GetFrameHeight() * 0.78f;
+ setting_row(title, description, h * 1.8f, h, [&] { changed = Widgets::toggle("##toggle", value); });
+ return changed;
+ }
+
+ auto slider_row(const char* title, const char* description, float* value, float min, float max,
+ const char* fmt) -> bool
+ {
+ bool changed = false;
+ setting_row(title, description, slider_width(), ImGui::GetFrameHeight(), [&]
+ {
+ ImGui::SetNextItemWidth(slider_width());
+ changed = Widgets::slider_float("##slider", value, min, max, fmt);
+ });
+ return changed;
+ }
+
+ auto color_row(const char* title, const char* description, float (&value)[3]) -> bool
+ {
+ bool changed = false;
+ const float h = ImGui::GetFrameHeight();
+ setting_row(title, description, h, h, [&]
+ {
+ changed = ImGui::ColorEdit3("##colour", value, ImGuiColorEditFlags_NoInputs |
+ ImGuiColorEditFlags_NoAlpha |
+ ImGuiColorEditFlags_NoLabel);
+ });
+ return changed;
+ }
+
+ // a keycap: the key's name in a small sunken box, as on a keyboard legend
+ auto key_cap(const char* text) -> void
+ {
+ const ImGuiStyle& style = ImGui::GetStyle();
+ const ImVec2 size = ImGui::CalcTextSize(text);
+ const ImVec2 p = ImGui::GetCursorScreenPos();
+ const ImVec2 end(p.x + size.x + 16.0f, p.y + ImGui::GetFrameHeight());
+ ImDrawList* draw = ImGui::GetWindowDrawList();
+ draw->AddRectFilled(p, end, ImGui::GetColorU32(ImGuiCol_WindowBg), style.FrameRounding);
+ draw->AddRect(p, end, ImGui::GetColorU32(ImGuiCol_Border), style.FrameRounding);
+ draw->AddText(ImVec2(p.x + 8.0f, p.y + style.FramePadding.y), ImGui::GetColorU32(ImGuiCol_Text), text);
+ ImGui::Dummy(ImVec2(end.x - p.x, end.y - p.y));
+ }
+
+ auto key_row(const char* title, const char* description, const char* key) -> void
+ {
+ const float width = ImGui::CalcTextSize(key).x + 16.0f;
+ setting_row(title, description, width, ImGui::GetFrameHeight(), [&] { key_cap(key); });
+ }
+
+ // a read-only fact; a long value is cut from the left and shown whole on hover
+ auto value_row(const char* title, std::string value) -> void
+ {
+ const float inner = g_card.width - CARD_PAD_X * 2.0f;
+ const float limit = inner * 0.58f;
+ const std::string full = value;
+ while (value.size() > 4 && ImGui::CalcTextSize(value.c_str()).x > limit)
+ value = "…" + value.substr(4);
+
+ setting_row(title, nullptr, ImGui::CalcTextSize(value.c_str()).x, ImGui::GetTextLineHeight(), [&]
+ {
+ ImGui::TextDisabled("%s", value.c_str());
+ if (value != full && ImGui::IsItemHovered())
+ ImGui::SetTooltip("%s", full.c_str());
+ });
+ }
+
+ // a theme card: a tiny window in the theme's own palette over its name; the
+ // chosen one is ringed in the accent. returns true on click.
+ auto theme_card(const ThemeEntry& entry, const Theme& theme, bool selected, ImVec2 size) -> bool
+ {
+ const ImVec2 p = ImGui::GetCursorScreenPos();
+ ImGui::InvisibleButton(entry.id.c_str(), size);
+ const bool hovered = ImGui::IsItemHovered();
+ const bool clicked = ImGui::IsItemClicked();
+
+ ImDrawList* draw = ImGui::GetWindowDrawList();
+ const float r = card_rounding();
+ const ImVec2 end(p.x + size.x, p.y + size.y);
+ draw->AddRectFilled(p, end, ImGui::GetColorU32(ImGuiCol_FrameBg), r);
+
+ // the mock: title stripe, sidebar, a few lines of "code", an accent pill
+ const ImVec2 m0(p.x + 6.0f, p.y + 6.0f);
+ const ImVec2 m1(end.x - 6.0f, end.y - 24.0f);
+ draw->AddRectFilled(m0, m1, u32(theme.bg), 4.0f);
+ draw->AddRectFilled(m0, ImVec2(m1.x, m0.y + 7.0f), u32(theme.bg_low), 4.0f, ImDrawFlags_RoundCornersTop);
+ draw->AddRectFilled(ImVec2(m0.x, m0.y + 7.0f), ImVec2(m0.x + 26.0f, m1.y), u32(theme.bg_high), 4.0f,
+ ImDrawFlags_RoundCornersBottomLeft);
+ for (int i = 0; i < 3; ++i)
+ draw->AddRectFilled(ImVec2(m0.x + 5.0f, m0.y + 13.0f + static_cast<float>(i) * 6.0f),
+ ImVec2(m0.x + 17.0f + static_cast<float>(i % 2) * 4.0f,
+ m0.y + 15.0f + static_cast<float>(i) * 6.0f),
+ u32(theme.text_dim));
+
+ struct Line { float indent, width; ImVec4 colour; };
+ const Line lines[] = {
+ { 0.0f, 22.0f, theme.syntax_preprocessor }, { 0.0f, 40.0f, theme.syntax_comment },
+ { 0.0f, 18.0f, theme.syntax_keyword }, { 8.0f, 36.0f, theme.text },
+ { 8.0f, 26.0f, theme.syntax_string }, { 0.0f, 12.0f, theme.text },
+ };
+ float y = m0.y + 13.0f;
+ for (const Line& line : lines)
+ {
+ if (y + 2.0f > m1.y - 4.0f)
+ break;
+ draw->AddRectFilled(ImVec2(m0.x + 32.0f + line.indent, y),
+ ImVec2(m0.x + 32.0f + line.indent + line.width, y + 2.0f), u32(line.colour));
+ y += 6.0f;
+ }
+ draw->AddRectFilled(ImVec2(m1.x - 24.0f, m1.y - 10.0f), ImVec2(m1.x - 5.0f, m1.y - 4.0f),
+ u32(theme.accent), 3.0f);
+
+ const ImVec2 ts = ImGui::CalcTextSize(entry.name.c_str());
+ draw->PushClipRect(ImVec2(p.x + 4.0f, m1.y), ImVec2(end.x - 4.0f, end.y), true);
+ draw->AddText(ImVec2(p.x + std::max((size.x - ts.x) * 0.5f, 4.0f), m1.y + (24.0f - ts.y) * 0.5f),
+ ImGui::GetColorU32(selected ? ImGuiCol_Text : ImGuiCol_TextDisabled), entry.name.c_str());
+ draw->PopClipRect();
+
+ if (selected)
+ draw->AddRect(p, end, u32(Widgets::accent()), r, 0, 2.0f);
+ else
+ draw->AddRect(p, end, ImGui::GetColorU32(hovered ? ImGuiCol_TextDisabled : ImGuiCol_Border), r);
+
+ return clicked;
+ }
+
+ auto same_colour(const float (&a)[3], const float (&b)[3]) -> bool
+ {
+ return std::abs(a[0] - b[0]) < 0.02f && std::abs(a[1] - b[1]) < 0.02f && std::abs(a[2] - b[2]) < 0.02f;
+ }
+
+ auto gl_renderer() -> std::string
+ {
+ const GLubyte* text = glGetString(GL_RENDERER);
+ return text != nullptr ? reinterpret_cast<const char*>(text) : "unknown";
+ }
+ }
+
+ 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, and load each palette for its card
+ if (!m_prefs_open_prev)
+ {
+ m_themes = list_themes(themes_directory());
+ m_theme_previews.clear();
+ std::ranges::transform(m_themes, std::back_inserter(m_theme_previews),
+ [](const ThemeEntry& entry) { return load_theme(entry.path); });
+ }
+ m_prefs_open_prev = true;
+
+ const ImGuiViewport* viewport = ImGui::GetMainViewport();
+ const ImVec2 center(viewport->WorkPos.x + viewport->WorkSize.x * 0.5f,
+ viewport->WorkPos.y + viewport->WorkSize.y * 0.5f);
+
+ const float scale = ui_scale();
+ ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
+ ImGui::SetNextWindowSize(ImVec2(860.0f * scale, 600.0f * scale), ImGuiCond_Appearing);
+ ImGui::SetNextWindowSizeConstraints(ImVec2(660.0f * scale, 440.0f * scale), ImVec2(FLT_MAX, FLT_MAX));
+
+ if (!ImGui::Begin(ICON_PH_GEAR " Preferences###preferences", &m_show_preferences,
+ ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoCollapse))
+ {
+ ImGui::End();
+ return;
+ }
+
+ const float base = ImGui::GetStyle().FontSizeBase;
+ bool changed = false;
+ bool restyle = false;
+
+ const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y;
+
+ // the rail: one roomy row per category on the deepest ground
+ ImGui::PushStyleColor(ImGuiCol_ChildBg, m_theme.bg_low);
+ ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(7.0f, 12.0f));
+ ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 8.0f));
+ ImGui::BeginChild("##pref_categories", ImVec2(180.0f * scale, -footer), ImGuiChildFlags_AlwaysUseWindowPadding);
+ for (int index = 0; index < IM_ARRAYSIZE(CATEGORIES); ++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();
+ ImGui::PopStyleVar(2);
+ ImGui::PopStyleColor();
+
+ ImGui::SameLine();
+
+ ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(20.0f, 12.0f));
+ ImGui::BeginChild("##pref_content", ImVec2(0.0f, -footer), ImGuiChildFlags_AlwaysUseWindowPadding);
+ ImGui::PopStyleVar();
+
+ const int tab = std::clamp(m_preferences_tab, 0, IM_ARRAYSIZE(CATEGORIES) - 1);
+ ImGui::PushFont(m_font_strong, base * 1.4f);
+ ImGui::TextUnformatted(CATEGORIES[tab]);
+ ImGui::PopFont();
+ ImGui::TextDisabled("%s", CATEGORY_BLURBS[tab]);
+ ImGui::Spacing();
+
+ if (tab == 0)
+ {
+ Widgets::section_header("Theme", false);
+
+ // dark and light themes in their own rows, four cards across; picking
+ // one seeds the accent and syntax colours below and saves
+ constexpr int columns = 4;
+ const float gap = 8.0f;
+ const float card_w = std::floor((ImGui::GetContentRegionAvail().x - gap * (columns - 1)) / columns);
+ const ImVec2 card_size(card_w, 72.0f + 20.0f * scale);
+
+ const auto is_light = [](const Theme& theme)
+ {
+ return 0.299f * theme.bg.x + 0.587f * theme.bg.y + 0.114f * theme.bg.z > 0.5f;
+ };
+
+ for (const bool light : { false, true })
+ {
+ int shown = 0;
+ for (size_t index = 0; index < m_themes.size(); ++index)
+ {
+ if (is_light(m_theme_previews[index]) != light)
+ continue;
+
+ if (shown == 0)
+ {
+ ImGui::PushFont(nullptr, base * 0.85f);
+ ImGui::TextDisabled("%s", light ? "Light" : "Dark");
+ ImGui::PopFont();
+ }
+ else if (shown % columns != 0)
+ {
+ ImGui::SameLine(0.0f, gap);
+ }
+
+ const ThemeEntry& entry = m_themes[index];
+ const bool selected = entry.id == m_preferences.theme;
+ if (theme_card(entry, m_theme_previews[index], selected, card_size) && !selected)
+ select_theme(entry);
+ ++shown;
+ }
+
+ if (shown > 0)
+ ImGui::Spacing();
+ }
+ if (m_themes.empty())
+ ImGui::TextDisabled("No themes found in assets/themes");
+
+ ImGui::Spacing();
+ Widgets::section_header("Accent", false);
+
+ for (const AccentPreset& preset : ACCENTS)
+ {
+ const ImVec2 p = ImGui::GetCursorScreenPos();
+ ImGui::InvisibleButton(preset.name, ImVec2(26.0f, 26.0f));
+ const ImVec2 c(p.x + 13.0f, p.y + 13.0f);
+ ImDrawList* draw_list = ImGui::GetWindowDrawList();
+ draw_list->AddCircleFilled(c, ImGui::IsItemHovered() ? 11.0f : 10.0f, u32(rgb(preset.rgb)));
+ if (same_colour(preset.rgb, m_preferences.accent))
+ draw_list->AddCircle(c, 12.5f, ImGui::GetColorU32(ImGuiCol_Text), 0, 2.0f);
+ if (ImGui::IsItemHovered())
+ ImGui::SetTooltip("%s", preset.name);
+ if (ImGui::IsItemClicked())
+ {
+ std::copy(std::begin(preset.rgb), std::end(preset.rgb), std::begin(m_preferences.accent));
+ changed = true;
+ restyle = true;
+ }
+ ImGui::SameLine(0.0f, 8.0f);
+ }
+
+ if (ImGui::ColorEdit3("##accent_custom", m_preferences.accent,
+ ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoAlpha |
+ ImGuiColorEditFlags_NoLabel))
+ {
+ changed = true;
+ restyle = true;
+ }
+ ImGui::SameLine();
+ ImGui::TextDisabled("Custom");
+
+ ImGui::Spacing();
+ Widgets::section_header("Interface", false);
+ begin_card();
+ changed |= slider_row("UI scale", "Scales every font; text is re-rasterised, so it stays crisp",
+ &m_preferences.ui_scale, 0.75f, 2.0f, "%.2fx");
+ if (slider_row("Corner rounding", "Radius on frames, windows, tabs and buttons",
+ &m_preferences.rounding, 0.0f, 12.0f, "%.0f px"))
+ {
+ changed = true;
+ restyle = true;
+ }
+ changed |= toggle_row("Show FPS in the status bar", "hsdbg's own frame rate, bottom right",
+ &m_preferences.show_fps);
+ end_card();
+
+ Widgets::section_header("Layout", false);
+ begin_card();
+ {
+ const float w = ImGui::CalcTextSize("Reset layout").x + ImGui::GetStyle().FramePadding.x * 2.0f;
+ setting_row("Window layout", "Put every panel back where a fresh install has it", w,
+ ImGui::GetFrameHeight(), [&]
+ {
+ if (ImGui::Button("Reset layout"))
+ m_layout_built = false;
+ });
+ }
+ end_card();
+
+ Widgets::section_header("Mascot", false);
+ begin_card();
+ changed |= toggle_row("Show the crying pepe", "He lives in the toolbar and means well",
+ &m_preferences.show_mascot);
+ ImGui::BeginDisabled(!m_preferences.show_mascot);
+ changed |= slider_row("Pepe size", "As a multiple of a toolbar button", &m_preferences.mascot_scale,
+ 1.0f, 3.0f, "%.1fx");
+ ImGui::EndDisabled();
+ end_card();
+ }
+ else if (tab == 1)
+ {
+ Widgets::section_header("Font", false);
+ begin_card();
+ changed |= slider_row("Source font size", "Cmd/Ctrl + scroll over the source zooms it too; Cmd/Ctrl + 0 resets",
+ &m_preferences.source_font_size, 9.0f, 32.0f, "%.0f px");
+ end_card();
+
+ Widgets::section_header("Source view", false);
+ begin_card();
+ changed |= toggle_row("Syntax highlighting", "Colour keywords, types, strings and the rest",
+ &m_preferences.syntax_highlighting);
+ changed |= toggle_row("Show line numbers", nullptr, &m_preferences.show_line_numbers);
+ changed |= toggle_row("Highlight the current line", "Tint the line execution has stopped on",
+ &m_preferences.highlight_current_line);
+ end_card();
+
+ Widgets::section_header("Syntax colours", false);
+
+ // a live sample in the source face and size, coloured as the source
+ // view would colour it right now
+ {
+ struct Token { const char* text; int kind; };
+ const Token sample[][6] = {
+ { { "#include", 6 }, { " ", 0 }, { "<cstdio>", 3 }, { nullptr, 0 } },
+ { { "// the answer, as a function", 5 }, { nullptr, 0 } },
+ { { "auto", 2 }, { " answer(", 0 }, { "int", 2 }, { " base) -> ", 0 }, { "int", 2 }, { nullptr, 0 } },
+ { { "{", 0 }, { nullptr, 0 } },
+ { { " ", 0 }, { "const", 1 }, { " ", 0 }, { "char", 2 }, { "* text = ", 0 }, { "\"hello\";", 3 } },
+ { { " ", 0 }, { "return", 1 }, { " base * ", 0 }, { "42", 4 }, { ";", 0 }, { nullptr, 0 } },
+ { { "}", 0 }, { nullptr, 0 } },
+ };
+ const float (*colours[])[3] = {
+ nullptr, &m_preferences.color_keyword, &m_preferences.color_type, &m_preferences.color_string,
+ &m_preferences.color_number, &m_preferences.color_comment, &m_preferences.color_preprocessor,
+ };
+
+ ImGui::PushFont(m_font_mono, m_preferences.source_font_size);
+ const float line_h = ImGui::GetTextLineHeight() + 4.0f;
+ const float pad = 12.0f;
+ const ImVec2 p = ImGui::GetCursorScreenPos();
+ const ImVec2 size(ImGui::GetContentRegionAvail().x, line_h * IM_ARRAYSIZE(sample) + pad * 2.0f);
+ ImDrawList* draw_list = ImGui::GetWindowDrawList();
+ draw_list->AddRectFilled(p, ImVec2(p.x + size.x, p.y + size.y), u32(m_theme.bg_low), card_rounding());
+ draw_list->AddRect(p, ImVec2(p.x + size.x, p.y + size.y), ImGui::GetColorU32(ImGuiCol_Border), card_rounding());
+
+ const int current = 5;
+ if (m_preferences.highlight_current_line)
+ {
+ const float y = p.y + pad + line_h * current - 2.0f;
+ draw_list->AddRectFilled(ImVec2(p.x + 1.0f, y), ImVec2(p.x + size.x - 1.0f, y + line_h),
+ u32(rgb(m_preferences.color_current_line)));
+ draw_list->AddRectFilled(ImVec2(p.x + 1.0f, y), ImVec2(p.x + 4.0f, y + line_h), u32(Widgets::accent()));
+ }
+
+ for (int line = 0; line < IM_ARRAYSIZE(sample); ++line)
+ {
+ float x = p.x + pad + 8.0f;
+ const float y = p.y + pad + line_h * static_cast<float>(line);
+ for (const Token& token : sample[line])
+ {
+ if (token.text == nullptr)
+ break;
+ const bool plain = token.kind == 0 || !m_preferences.syntax_highlighting;
+ const ImU32 colour = plain ? ImGui::GetColorU32(ImGuiCol_Text) : u32(rgb(*colours[token.kind]));
+ draw_list->AddText(ImVec2(x, y), colour, token.text);
+ x += ImGui::CalcTextSize(token.text).x;
+ }
+ }
+ ImGui::PopFont();
+ ImGui::Dummy(size);
+ ImGui::Spacing();
+ }
+
+ begin_card();
+ ImGui::BeginDisabled(!m_preferences.syntax_highlighting);
+ changed |= color_row("Keyword", "const, return, if, while…", m_preferences.color_keyword);
+ changed |= color_row("Type", "int, auto, char, size_t…", m_preferences.color_type);
+ changed |= color_row("String", "String and character literals, includes", m_preferences.color_string);
+ changed |= color_row("Number", nullptr, m_preferences.color_number);
+ changed |= color_row("Comment", nullptr, m_preferences.color_comment);
+ changed |= color_row("Preprocessor", "#include, #define and friends", m_preferences.color_preprocessor);
+ ImGui::EndDisabled();
+ changed |= color_row("Current line", "Behind the line execution has stopped on", m_preferences.color_current_line);
+ end_card();
+ }
+ else if (tab == 2)
+ {
+ Widgets::section_header("Launching", false);
+ begin_card();
+ changed |= toggle_row("Break at entry point", "Stop on the very first instruction instead of running to your breakpoints",
+ &m_preferences.stop_at_entry);
+ end_card();
+
+ Widgets::section_header("Stepping", false);
+ begin_card();
+ {
+ static const char* const step_modes[] = { "Line", "Instruction" };
+ const int current_mode = m_preferences.step_by_instruction ? 1 : 0;
+ int next_mode = current_mode;
+ setting_row("Step granularity", "What Step Over and Step Into advance by",
+ Widgets::segmented_width(step_modes, 2), ImGui::GetFrameHeight(), [&]
+ {
+ next_mode = Widgets::segmented("##step_mode", step_modes, 2, current_mode);
+ });
+ if (next_mode != current_mode)
+ {
+ m_preferences.step_by_instruction = next_mode == 1;
+ changed = true;
+ }
+ }
+ end_card();
+
+ Widgets::section_header("Keys", false);
+ begin_card();
+ key_row("Continue, or run a loaded target", nullptr, "F5");
+ key_row("Step Over", nullptr, "F10");
+ key_row("Step Into", nullptr, "F11");
+ key_row("Step Out", nullptr, "Shift + F11");
+ key_row("Command palette", "Files, symbols and every command, fuzzy-matched", "Cmd/Ctrl + K");
+ key_row("Zoom the source", "Over the source view", "Cmd/Ctrl + scroll");
+ end_card();
+ }
+ else
+ {
+ ImGui::PushFont(m_font_strong, base * 2.0f);
+ ImGui::TextUnformatted("hsdbg");
+ ImGui::PopFont();
+ ImGui::SameLine(0.0f, 10.0f);
+ ImGui::AlignTextToFramePadding();
+ Widgets::chip(HSDBG_VERSION, u32(Widgets::accent()));
+ ImGui::TextDisabled("A GUI debugger for C and C++. LLDB does the debugging; this is the ui.");
+ ImGui::Spacing();
+
+ Widgets::section_header("Build", false);
+ begin_card();
+ value_row("LLDB", Debugger::backend_version());
+ value_row("Dear ImGui", IMGUI_VERSION);
+ value_row("OpenGL", gl_renderer());
+ value_row("Fonts", "Nunito, JetBrains Mono, Phosphor");
+ end_card();
+
+ Widgets::section_header("Paths", false);
+ begin_card();
+ value_row("Assets", HSDBG_ASSET_DIR);
+ value_row("Preferences", m_preferences_path.string());
+ value_row("Layout", ImGui::GetIO().IniFilename != nullptr ? ImGui::GetIO().IniFilename : "none");
+ end_card();
+ }
+
+ ImGui::EndChild();
+
+ ImGui::Separator();
+
+ 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::AlignTextToFramePadding();
+ ImGui::TextDisabled("Saved to %s as you change things", m_preferences_path.filename().string().c_str());
+
+ if (changed)
+ save_preferences(m_preferences_path, m_preferences);
+
+ if (restyle)
+ m_restyle_pending = true;
+
+ ImGui::End();
+ }
+}
diff --git a/src/ui/widgets.cpp b/src/ui/widgets.cpp
index 68f4875..7172f88 100644
--- a/src/ui/widgets.cpp
+++ b/src/ui/widgets.cpp
@@ -1,7 +1,9 @@
#include "ui/widgets.h"
#include <imgui_internal.h>
+#include <misc/cpp/imgui_stdlib.h>
+#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
@@ -15,16 +17,11 @@ namespace Hsdbg::Widgets
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 ImVec4 a = accent();
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);
}
@@ -34,6 +31,14 @@ namespace Hsdbg::Widgets
colour.w = alpha;
return ImGui::ColorConvertFloat4ToU32(colour);
}
+
+ // a track's resting fill: the frame colour plus a whisper of the text
+ // colour, so it still reads when drawn over a card that is frame-coloured
+ auto track_fill(ImDrawList* draw, ImVec2 min, ImVec2 max, float rounding) -> void
+ {
+ draw->AddRectFilled(min, max, ImGui::GetColorU32(ImGuiCol_FrameBg), rounding);
+ draw->AddRectFilled(min, max, with_alpha(ImGui::GetStyle().Colors[ImGuiCol_Text], 0.07f), rounding);
+ }
}
auto set_fonts(ImFont* ui, ImFont* strong, ImFont* mono) -> void
@@ -43,7 +48,12 @@ namespace Hsdbg::Widgets
g_mono = mono;
}
- auto section_header(const char* label) -> void
+ auto accent() -> ImVec4
+ {
+ return ImGui::GetStyle().Colors[ImGuiCol_CheckMark];
+ }
+
+ auto section_header(const char* label, bool rule) -> void
{
std::string caps;
caps.reserve(24);
@@ -65,7 +75,7 @@ namespace Hsdbg::Widgets
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)
+ if (rule && right > hi.x + 10.0f)
ImGui::GetWindowDrawList()->AddLine(ImVec2(hi.x + 8.0f, cy), ImVec2(right, cy),
ImGui::GetColorU32(ImGuiCol_Border), 1.0f);
}
@@ -74,7 +84,15 @@ namespace Hsdbg::Widgets
{
ImGui::PushID(label);
- const float width = ImGui::CalcItemWidth();
+ // the value column is sized for either end of the range, so the grab
+ // never sits on top of the number
+ char lo[64];
+ char hi[64];
+ std::snprintf(lo, sizeof(lo), fmt, static_cast<double>(min));
+ std::snprintf(hi, sizeof(hi), fmt, static_cast<double>(max));
+ const float value_w = std::max(ImGui::CalcTextSize(lo).x, ImGui::CalcTextSize(hi).x);
+ const float gap = 10.0f;
+ const float width = std::max(ImGui::CalcItemWidth() - value_w - gap, 40.0f);
const float height = ImGui::GetFrameHeight();
const ImVec2 p = ImGui::GetCursorScreenPos();
@@ -94,33 +112,38 @@ namespace Hsdbg::Widgets
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());
+ const ImU32 fill = ImGui::ColorConvertFloat4ToU32(accent());
- 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);
+ track_fill(draw, ImVec2(p.x, ty), ImVec2(p.x + width, ty + th), th * 0.5f);
+ draw->AddRectFilled(ImVec2(p.x, ty), ImVec2(gx, ty + th), fill, 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, fill);
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),
+ draw->AddText(ImVec2(p.x + width + gap + value_w - ts.x, p.y + (height - ts.y) * 0.5f),
ImGui::GetColorU32(ImGuiCol_Text), buf);
+ ImGui::SameLine(0.0f, 0.0f);
+ ImGui::Dummy(ImVec2(gap + value_w, height));
+
ImGui::PopID();
- ImGui::SameLine();
- ImGui::AlignTextToFramePadding();
- ImGui::TextUnformatted(label);
+ if (label[0] != '#' || label[1] != '#')
+ {
+ 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
+ bool disabled, float size, ImVec4 glyph) -> bool
{
if (disabled)
ImGui::BeginDisabled();
@@ -129,7 +152,7 @@ namespace Hsdbg::Widgets
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 10.0f);
if (active)
- ImGui::PushStyleColor(ImGuiCol_Button, accent_color());
+ ImGui::PushStyleColor(ImGuiCol_Button, accent());
// Button draws the bg + handles the click; the glyph is drawn by hand
const ImVec2 origin = ImGui::GetCursorScreenPos();
@@ -141,7 +164,9 @@ namespace Hsdbg::Widgets
// 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 bool tinted = glyph.w > 0.0f && !disabled && !active;
+ const ImU32 glyph_col = active ? accent_ink()
+ : ImGui::GetColorU32(tinted ? glyph : ImGui::GetStyleColorVec4(ImGuiCol_Text));
const float cx = origin.x + size * 0.5f;
const float cy = origin.y + size * 0.5f;
@@ -186,12 +211,15 @@ namespace Hsdbg::Widgets
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);
+ if (*value)
+ {
+ draw->AddRectFilled(pos, end, ImGui::ColorConvertFloat4ToU32(accent()), radius);
+ }
+ else
+ {
+ track_fill(draw, pos, end, radius);
+ draw->AddRect(pos, end, ImGui::GetColorU32(hovered ? ImGuiCol_TextDisabled : ImGuiCol_Border), radius);
+ }
const float knob = radius - 2.5f;
const float knob_x = *value ? (end.x - radius) : (pos.x + radius);
@@ -201,6 +229,14 @@ namespace Hsdbg::Widgets
return clicked;
}
+ auto segmented_width(const char* const* labels, int count) -> float
+ {
+ float total = 4.0f;
+ for (int i = 0; i < count; ++i)
+ total += ImGui::CalcTextSize(labels[i]).x + 8.0f * 2.0f + 2.0f;
+ return total;
+ }
+
auto segmented(const char* id, const char* const* labels, int count, int current) -> int
{
const ImGuiStyle& style = ImGui::GetStyle();
@@ -209,14 +245,9 @@ namespace Hsdbg::Widgets
const float height = ImGui::GetFrameHeight();
const float pad = 8.0f;
const ImVec2 origin = ImGui::GetCursorScreenPos();
+ const float total = segmented_width(labels, count);
- // 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);
+ track_fill(draw, origin, ImVec2(origin.x + total, origin.y + height), height * 0.5f);
int result = current;
ImGui::PushID(id);
@@ -276,12 +307,12 @@ namespace Hsdbg::Widgets
ImVec2 max = ImGui::GetItemRectMax();
min.x += 2.0f;
max.x -= 2.0f;
- const ImU32 fill = selected ? with_alpha(accent_color(), 0.18f)
+ const ImU32 fill = selected ? with_alpha(accent(), 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);
+ draw->AddRect(min, max, with_alpha(accent(), 0.40f), 7.0f);
}
split.Merge(draw);
@@ -358,4 +389,79 @@ namespace Hsdbg::Widgets
return clicked;
}
+
+ auto input_with_icon(const char* id, const char* icon, const char* hint, std::string* text,
+ ImGuiInputTextFlags flags) -> bool
+ {
+ const ImGuiStyle& style = ImGui::GetStyle();
+ const ImVec2 pos = ImGui::GetCursorScreenPos();
+ const float pad_x = style.FramePadding.x;
+ const float pad_y = style.FramePadding.y;
+
+ // widen the left padding to leave room for the glyph, then set it over
+ // the frame in the dim text colour
+ ImGui::PushStyleVar(ImGuiStyleVar_FramePadding,
+ ImVec2(pad_x * 2.0f + ImGui::CalcTextSize(icon).x, pad_y));
+ const bool changed = ImGui::InputTextWithHint(id, hint, text, flags);
+ ImGui::PopStyleVar();
+
+ ImGui::GetWindowDrawList()->AddText(ImVec2(pos.x + pad_x, pos.y + pad_y),
+ ImGui::GetColorU32(ImGuiCol_TextDisabled), icon);
+ return changed;
+ }
+
+ auto dot_toggle(const char* id, bool* value, ImU32 on, ImU32 off, bool pending) -> bool
+ {
+ const float s = ImGui::GetFrameHeight();
+ const ImVec2 p = ImGui::GetCursorScreenPos();
+
+ ImGui::InvisibleButton(id, ImVec2(s, s));
+ const bool clicked = ImGui::IsItemClicked();
+ if (clicked)
+ *value = !*value;
+
+ const ImVec2 centre(p.x + s * 0.5f, p.y + s * 0.5f);
+ const ImU32 colour = *value ? on : off;
+ ImDrawList* draw = ImGui::GetWindowDrawList();
+ if (pending)
+ draw->AddCircle(centre, 5.0f, colour, 0, 1.5f);
+ else
+ draw->AddCircleFilled(centre, 5.0f, colour);
+
+ if (ImGui::IsItemHovered())
+ ImGui::SetTooltip("%s", *value ? "Disable" : "Enable");
+
+ return clicked;
+ }
+
+ auto hex_value(unsigned long long value, int digits) -> void
+ {
+ char buf[32];
+ std::snprintf(buf, sizeof(buf), "%0*llx", digits, value);
+
+ int zeros = 0;
+ while (zeros < digits - 1 && buf[zeros] == '0')
+ ++zeros;
+
+ ImGui::TextDisabled("0x%.*s", zeros, buf);
+ ImGui::SameLine(0.0f, 0.0f);
+ ImGui::TextUnformatted(buf + zeros);
+ }
+
+ auto link_text(const char* text) -> bool
+ {
+ ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(ImGuiCol_TextLink));
+ ImGui::TextUnformatted(text);
+ ImGui::PopStyleColor();
+
+ if (!ImGui::IsItemHovered())
+ return false;
+
+ ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
+ const ImVec2 lo = ImGui::GetItemRectMin();
+ const ImVec2 hi = ImGui::GetItemRectMax();
+ ImGui::GetWindowDrawList()->AddLine(ImVec2(lo.x, hi.y - 1.0f), ImVec2(hi.x, hi.y - 1.0f),
+ ImGui::GetColorU32(ImGuiCol_TextLink));
+ return ImGui::IsMouseClicked(ImGuiMouseButton_Left);
+ }
}
diff --git a/src/ui/widgets.h b/src/ui/widgets.h
index b034357..418af8f 100644
--- a/src/ui/widgets.h
+++ b/src/ui/widgets.h
@@ -2,6 +2,8 @@
#include <imgui.h>
+#include <string>
+
// 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
@@ -13,28 +15,35 @@ namespace Hsdbg::Widgets
// 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;
+ // the theme accent (ImGuiCol_CheckMark carries it), for callers drawing
+ // their own accents
+ auto accent() -> ImVec4;
+
+ // a small, bold, upper-cased section label, with a hairline running off to
+ // the right unless `rule` is off — the strong equivalent of ImGui::SeparatorText
+ auto section_header(const char* label, bool rule = true) -> 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.
+ // round grab, the value in its own column right of the track, then the label
+ // (like ImGui::SliderFloat; a "##" label draws none). 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.
+ // fills it with the accent to mark a toggled-on tool; `glyph` (alpha > 0)
+ // colours the icon while the button is enabled. 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;
+ float size = 34.0f, ImVec4 glyph = ImVec4(0.0f, 0.0f, 0.0f, 0.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)
+ // when nothing was clicked this frame). segmented_width is the room it takes.
auto segmented(const char* id, const char* const* labels, int count, int current) -> int;
+ auto segmented_width(const char* const* labels, int count) -> float;
// 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
@@ -53,4 +62,20 @@ namespace Hsdbg::Widgets
// 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;
+
+ // a text field with a dim glyph set inside its left edge (a magnifier on a
+ // filter, a prompt on the console); otherwise ImGui::InputTextWithHint
+ auto input_with_icon(const char* id, const char* icon, const char* hint, std::string* text,
+ ImGuiInputTextFlags flags = 0) -> bool;
+
+ // a breakpoint-style dot that flips *value on click: filled in `on` when set,
+ // filled in `off` when not, hollow while `pending`. returns true on the flip.
+ auto dot_toggle(const char* id, bool* value, ImU32 on, ImU32 off, bool pending = false) -> bool;
+
+ // a zero-padded hex number with its leading zeros dimmed, so the digits that
+ // matter stand out of a column of 0x0000…
+ auto hex_value(unsigned long long value, int digits) -> void;
+
+ // text in the link colour that underlines on hover; returns true on click
+ auto link_text(const char* text) -> bool;
}
diff --git a/tools/capture.py b/tools/capture.py
new file mode 100755
index 0000000..f30e9cd
--- /dev/null
+++ b/tools/capture.py
@@ -0,0 +1,108 @@
+#!/usr/bin/env python3
+"""Capture a screenshot of hsdbg without touching the running app.
+
+Runs the binary with the dev hooks the app reads from the environment, lets it
+draw for a number of frames, and converts the framebuffer dump it writes into a
+png. Useful for eyeballing ui changes from a shell (or an agent) that cannot
+grab the screen.
+
+ tools/capture.py out.png [--build build] [--target test/hello]
+ [--break hello.cpp:13] [--step 150,200] [--open palette]
+ [--frame 240] [--window 1600x900] [--cwd dir]
+
+Hooks (all read once at startup):
+ HSDBG_CAPTURE=<file.ppm> dump the frame HSDBG_CAPTURE_FRAME (default 240) and quit
+ HSDBG_WINDOW=<w>x<h> initial window size
+ HSDBG_BREAK=<file>:<line> plant a breakpoint and launch the target right away
+ HSDBG_STEP_AT=<f>[,<f>...] step over a line on those frames while stopped
+ HSDBG_OPEN=palette|target|preferences[:tab] raise that overlay on the second frame
+
+--cwd picks where imgui.ini / hsdbg.ini are read and written (default: a fresh
+directory beside the output, so your own layout and preferences stay untouched).
+"""
+
+import argparse
+import os
+import pathlib
+import struct
+import subprocess
+import sys
+import zlib
+
+
+def ppm_to_png(src: pathlib.Path, dst: pathlib.Path) -> tuple[int, int]:
+ data = src.read_bytes()
+ magic, size, _depth, raw = data.split(b"\n", 3)
+ assert magic == b"P6", "expected a binary ppm"
+ width, height = map(int, size.split())
+ stride = width * 3
+ rows = b"".join(b"\x00" + raw[y * stride:(y + 1) * stride] for y in range(height))
+
+ def chunk(kind: bytes, body: bytes) -> bytes:
+ payload = kind + body
+ return struct.pack(">I", len(body)) + payload + struct.pack(">I", zlib.crc32(payload) & 0xFFFFFFFF)
+
+ png = (b"\x89PNG\r\n\x1a\n"
+ + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
+ + chunk(b"IDAT", zlib.compress(rows, 6))
+ + chunk(b"IEND", b""))
+ dst.write_bytes(png)
+ return width, height
+
+
+def main() -> int:
+ root = pathlib.Path(__file__).resolve().parent.parent
+
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("output", type=pathlib.Path, help="png to write")
+ parser.add_argument("--build", default="build", help="build directory holding the hsdbg binary")
+ parser.add_argument("--target", help="executable to load")
+ parser.add_argument("--break", dest="breakpoint", metavar="FILE:LINE", help="breakpoint to plant, then launch")
+ parser.add_argument("--step", metavar="FRAMES", help="comma-separated frames on which to step over")
+ parser.add_argument("--open", metavar="OVERLAY",
+ help="overlay to raise: palette, target, or preferences[:tab]")
+ parser.add_argument("--frame", type=int, default=240, help="frame to capture (default 240)")
+ parser.add_argument("--window", metavar="WxH", help="window size")
+ parser.add_argument("--cwd", type=pathlib.Path, help="directory to run in (imgui.ini / hsdbg.ini live there)")
+ args = parser.parse_args()
+
+ binary = root / args.build / "hsdbg"
+ if not binary.exists():
+ print(f"no binary at {binary}; build first", file=sys.stderr)
+ return 1
+
+ output = args.output.resolve()
+ cwd = (args.cwd or output.parent / f".{output.stem}").resolve()
+ cwd.mkdir(parents=True, exist_ok=True)
+ ppm = cwd / "capture.ppm"
+
+ env = dict(os.environ, HSDBG_CAPTURE=str(ppm), HSDBG_CAPTURE_FRAME=str(args.frame))
+ if args.breakpoint:
+ env["HSDBG_BREAK"] = args.breakpoint
+ if args.step:
+ env["HSDBG_STEP_AT"] = args.step
+ if args.open:
+ env["HSDBG_OPEN"] = args.open
+ if args.window:
+ env["HSDBG_WINDOW"] = args.window
+
+ command = [str(binary)]
+ if args.target:
+ command.append(str(pathlib.Path(args.target).resolve()))
+
+ log = cwd / "capture.log"
+ with log.open("w") as sink:
+ result = subprocess.run(command, cwd=cwd, env=env, stdout=sink, stderr=subprocess.STDOUT)
+
+ if not ppm.exists():
+ print(f"no capture written (exit {result.returncode}); see {log}", file=sys.stderr)
+ return 1
+
+ width, height = ppm_to_png(ppm, output)
+ ppm.unlink()
+ print(f"{output} ({width}x{height})")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())