diff options
| author | hachem <im@hachem.wtf> | 2026-09-18 12:25:58 +0200 |
|---|---|---|
| committer | hachem <im@hachem.wtf> | 2026-09-18 12:25:58 +0200 |
| commit | 4f74d1400180ca4ad7cfcd1903386fda8779818d (patch) | |
| tree | 258a332c3076045461b5d6f8592a52140225abd1 /src | |
| parent | 0846724177d823b632f2a4deffdcb5b6e226cd56 (diff) | |
feat: improve layout and themes
Diffstat (limited to 'src')
| -rw-r--r-- | src/core/application.cpp | 63 | ||||
| -rw-r--r-- | src/debugger/debugger.cpp | 42 | ||||
| -rw-r--r-- | src/debugger/debugger.h | 3 | ||||
| -rw-r--r-- | src/debugger/types.h | 9 | ||||
| -rw-r--r-- | src/main.cpp | 59 | ||||
| -rw-r--r-- | src/ui/icons.h | 75 | ||||
| -rw-r--r-- | src/ui/preferences.cpp | 3 | ||||
| -rw-r--r-- | src/ui/preferences.h | 1 | ||||
| -rw-r--r-- | src/ui/source_view.cpp | 288 | ||||
| -rw-r--r-- | src/ui/source_view.h | 29 | ||||
| -rw-r--r-- | src/ui/ui.cpp | 796 | ||||
| -rw-r--r-- | src/ui/ui.h | 25 | ||||
| -rw-r--r-- | src/ui/ui_preferences.cpp | 667 | ||||
| -rw-r--r-- | src/ui/widgets.cpp | 178 | ||||
| -rw-r--r-- | src/ui/widgets.h | 41 |
15 files changed, 1836 insertions, 443 deletions
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; } |
