From 4f74d1400180ca4ad7cfcd1903386fda8779818d Mon Sep 17 00:00:00 2001 From: hachem Date: Fri, 18 Sep 2026 12:25:58 +0200 Subject: feat: improve layout and themes --- src/ui/ui.cpp | 796 ++++++++++++++++++++++++++++++++++------------------------ 1 file changed, 461 insertions(+), 335 deletions(-) (limited to 'src/ui/ui.cpp') 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 #include #include +#include +#include #include #include #include @@ -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 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(c[0] * 255.0f), static_cast(c[1] * 255.0f), + static_cast(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; + }; + + 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()) }; + } + + auto value_color(std::string_view value, const ValueStyle& style) -> std::optional + { + if (value.empty()) + return std::nullopt; - ImGui::TableNextColumn(); - ImGui::TextUnformatted(variable.type.c_str()); - ImGui::TableNextColumn(); - ImGui::TextUnformatted(variable.value.c_str()); + const char first = value.front(); + if (first == '"' || first == '\'') + return style.string; - return; + if (value == "true" || value == "false" || value == "nullptr" || value == "NULL") + return style.keyword; + + const bool digit = std::isdigit(static_cast(first)) != 0; + const bool signed_digit = (first == '-' || first == '.') && value.size() > 1 && + std::isdigit(static_cast(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 colour = changed ? std::optional(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; + + auto flatten_variables(std::span 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 (!open) + if (changed && ImGui::IsItemHovered()) + ImGui::SetTooltip("Was %s", before->c_str()); + + 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(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 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(ImGuiDockNodeFlags_NoWindowMenuButton)); + static_cast(ImGuiDockNodeFlags_NoWindowMenuButton) | + static_cast(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 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(debugger.process_id())); + } + } - ImGui::Text("%zu breakpoints", debugger.breakpoints().size()); + // where execution sits: the selected frame's function and file:line + if (debugger.is_stopped()) + { + const std::span 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); + + 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(c[0] * 255.0f), static_cast(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(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(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 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,9 +2409,11 @@ 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,12 +2525,15 @@ 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(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(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; } } -- cgit v1.3