From cbd28c911038463092f228d2524603083c0a6d14 Mon Sep 17 00:00:00 2001 From: Sam Atkins Date: Wed, 10 Jan 2024 19:49:40 +0000 Subject: [PATCH] HexEditor: Add annotations system Allow the user to highlight sections of the edited document, giving them arbitrary background colors. These annotations can be created from a selection, or by manually specifying the start and end offsets. Annotations can be edited or deleted by right-clicking them. Any color can be used for the background. Dark colors automatically make the text white for easier readability. When creating a new annotation, we use whatever color the user last picked as this is slightly more likely to be the one they want. Icons contributed by Cubic Love. Co-authored-by: Cubic Love <7754483+cubiclove@users.noreply.github.com> --- Base/res/icons/16x16/annotation-add.png | Bin 0 -> 300 bytes Base/res/icons/16x16/annotation-remove.png | Bin 0 -> 321 bytes Base/res/icons/16x16/annotation.png | Bin 0 -> 319 bytes .../Applications/HexEditor/CMakeLists.txt | 7 +- .../HexEditor/EditAnnotationDialog.cpp | 109 ++++++++++++++++++ .../HexEditor/EditAnnotationDialog.h | 37 ++++++ .../HexEditor/EditAnnotationWidget.gml | 75 ++++++++++++ .../HexEditor/EditAnnotationWidget.h | 23 ++++ .../Applications/HexEditor/HexDocument.cpp | 29 +++++ Userland/Applications/HexEditor/HexDocument.h | 16 +++ Userland/Applications/HexEditor/HexEditor.cpp | 69 ++++++++++- Userland/Applications/HexEditor/HexEditor.h | 11 ++ .../HexEditor/HexEditorWidget.cpp | 6 + 13 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 Base/res/icons/16x16/annotation-add.png create mode 100644 Base/res/icons/16x16/annotation-remove.png create mode 100644 Base/res/icons/16x16/annotation.png create mode 100644 Userland/Applications/HexEditor/EditAnnotationDialog.cpp create mode 100644 Userland/Applications/HexEditor/EditAnnotationDialog.h create mode 100644 Userland/Applications/HexEditor/EditAnnotationWidget.gml create mode 100644 Userland/Applications/HexEditor/EditAnnotationWidget.h diff --git a/Base/res/icons/16x16/annotation-add.png b/Base/res/icons/16x16/annotation-add.png new file mode 100644 index 0000000000000000000000000000000000000000..a57ac1b032405797ee7649416f5047be3784f94c GIT binary patch literal 300 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbK}V1Q4EE0F&GGms<%)S_NsqNk^4 zy&}NP%`Gr6Ff=rD?@rNMmj!_4T#8kl0aPJa666=mAY$3qKWp})#Y|&P6d=HIF-(M!bxqm?`~UygOH-TPti0>(b&K^^ca){Y5NqO^yn|!y0Znq!WT<+o$5pu1y@I^g)WP)7#x?GukaV&eDXI9zXnw|TS r`=b?i@Va$VegLiFOO5bM^YvxW0Oy>k literal 0 HcmV?d00001 diff --git a/Base/res/icons/16x16/annotation-remove.png b/Base/res/icons/16x16/annotation-remove.png new file mode 100644 index 0000000000000000000000000000000000000000..e997451e7a7316d636a7ccae000b6e584c1b8e02 GIT binary patch literal 321 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`?>t=`Lo9mdUf%1)>?m^dvX z#G89==eYIyh2lwGHs;J(ulUuIX2!16J)7d*)L=2Y>{Y+&w40{;v}+#?px@!B}xq z?mVT~pxeXVQ%C=9RbV8*e!g;PLzZl7|bX?$;@di+iZO*1zTf@A`^k%5nx- zoBn7D?ePm1TUi!ooAOz{57^Ap9zPLrl4^aK6Pmm1-j=IhI#1!QvoF$i1=o(!Tq MUHx3vIVCg!0R7p6KmY&$ literal 0 HcmV?d00001 diff --git a/Userland/Applications/HexEditor/CMakeLists.txt b/Userland/Applications/HexEditor/CMakeLists.txt index 154f980add..e5b5b51b04 100644 --- a/Userland/Applications/HexEditor/CMakeLists.txt +++ b/Userland/Applications/HexEditor/CMakeLists.txt @@ -4,11 +4,14 @@ serenity_component( TARGETS HexEditor ) -compile_gml(HexEditorWidget.gml HexEditorWidgetGML.cpp) -compile_gml(GoToOffsetWidget.gml GoToOffsetWidgetGML.cpp) +compile_gml(EditAnnotationWidget.gml EditAnnotationWidgetGML.cpp) compile_gml(FindWidget.gml FindWidgetGML.cpp) +compile_gml(GoToOffsetWidget.gml GoToOffsetWidgetGML.cpp) +compile_gml(HexEditorWidget.gml HexEditorWidgetGML.cpp) set(SOURCES + EditAnnotationDialog.cpp + EditAnnotationWidgetGML.cpp FindDialog.cpp FindWidgetGML.cpp GoToOffsetDialog.cpp diff --git a/Userland/Applications/HexEditor/EditAnnotationDialog.cpp b/Userland/Applications/HexEditor/EditAnnotationDialog.cpp new file mode 100644 index 0000000000..f991133483 --- /dev/null +++ b/Userland/Applications/HexEditor/EditAnnotationDialog.cpp @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2024, Sam Atkins + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "EditAnnotationDialog.h" +#include + +static Gfx::Color s_most_recent_color { Color::from_argb(0xfffce94f) }; + +GUI::Dialog::ExecResult EditAnnotationDialog::show_create_dialog(GUI::Window* parent_window, HexDocument& document, Selection selection) +{ + auto dialog_or_error = EditAnnotationDialog::try_create(parent_window, document, selection); + if (dialog_or_error.is_error()) { + GUI::MessageBox::show(parent_window, MUST(String::formatted("{}", dialog_or_error.error())), "Error while opening Create Annotation dialog"sv, GUI::MessageBox::Type::Error); + return ExecResult::Aborted; + } + + auto dialog = dialog_or_error.release_value(); + return dialog->exec(); +} + +GUI::Dialog::ExecResult EditAnnotationDialog::show_edit_dialog(GUI::Window* parent_window, HexDocument& document, Annotation& annotation) +{ + auto dialog_or_error = EditAnnotationDialog::try_create(parent_window, document, &annotation); + if (dialog_or_error.is_error()) { + GUI::MessageBox::show(parent_window, MUST(String::formatted("{}", dialog_or_error.error())), "Error while opening Edit Annotation dialog"sv, GUI::MessageBox::Type::Error); + return ExecResult::Aborted; + } + + auto dialog = dialog_or_error.release_value(); + return dialog->exec(); +} + +ErrorOr> EditAnnotationDialog::try_create(GUI::Window* parent_window, HexDocument& hex_document, Variant selection_or_annotation) +{ + auto widget = TRY(HexEditor::EditAnnotationWidget::try_create()); + return adopt_nonnull_ref_or_enomem(new (nothrow) EditAnnotationDialog(parent_window, move(widget), hex_document, move(selection_or_annotation))); +} + +EditAnnotationDialog::EditAnnotationDialog(GUI::Window* parent_window, NonnullRefPtr widget, HexDocument& hex_document, Variant selection_or_annotation) + : GUI::Dialog(parent_window) + , m_document(hex_document.make_weak_ptr()) +{ + resize(260, 140); + set_resizable(false); + set_main_widget(widget); + + m_start_offset = find_descendant_of_type_named("start_offset"); + m_end_offset = find_descendant_of_type_named("end_offset"); + m_background_color = find_descendant_of_type_named("background_color"); + m_save_button = find_descendant_of_type_named("save_button"); + m_cancel_button = find_descendant_of_type_named("cancel_button"); + + // FIXME: This could be specified in GML, but the GML doesn't like property setters that aren't `set_FOO()`. + m_background_color->set_color_has_alpha_channel(false); + + // NOTE: The NumericInput stores an i64, so not all size_t values can fit. But I don't think we'll be + // hex-editing files larger than 9000 petabytes for the foreseeable future! + VERIFY(hex_document.size() <= NumericLimits::max()); + m_start_offset->set_min(0); + m_start_offset->set_max(hex_document.size() - 1); + m_end_offset->set_min(0); + m_end_offset->set_max(hex_document.size() - 1); + + selection_or_annotation.visit( + [this](Annotation*& annotation) { + m_annotation = *annotation; + set_title("Edit Annotation"sv); + set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/annotation.png"sv).release_value_but_fixme_should_propagate_errors()); + VERIFY(m_annotation->start_offset <= NumericLimits::max()); + VERIFY(m_annotation->end_offset <= NumericLimits::max()); + m_start_offset->set_value(m_annotation->start_offset); + m_end_offset->set_value(m_annotation->end_offset); + m_background_color->set_color(m_annotation->background_color); + }, + [this](Selection& selection) { + set_title("Add Annotation"sv); + set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/annotation-add.png"sv).release_value_but_fixme_should_propagate_errors()); + // Selection start is inclusive, and end is exclusive. + // Therefore, if the selection isn't empty, we need to subtract 1 from the end offset. + m_start_offset->set_value(selection.start); + m_end_offset->set_value(selection.is_empty() ? selection.end : selection.end - 1); + // Default to the most recently used annotation color. + m_background_color->set_color(s_most_recent_color); + }); + + m_save_button->on_click = [this](auto) { + auto start_offset = static_cast(m_start_offset->value()); + auto end_offset = static_cast(m_end_offset->value()); + Annotation result { + .start_offset = min(start_offset, end_offset), + .end_offset = max(start_offset, end_offset), + .background_color = m_background_color->color(), + }; + if (m_annotation.has_value()) { + *m_annotation = move(result); + } else { + if (m_document) + m_document->add_annotation(result); + } + s_most_recent_color = m_background_color->color(); + done(ExecResult::OK); + }; + m_cancel_button->on_click = [this](auto) { + done(ExecResult::Cancel); + }; +} diff --git a/Userland/Applications/HexEditor/EditAnnotationDialog.h b/Userland/Applications/HexEditor/EditAnnotationDialog.h new file mode 100644 index 0000000000..03e8c973f3 --- /dev/null +++ b/Userland/Applications/HexEditor/EditAnnotationDialog.h @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024, Sam Atkins + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "EditAnnotationWidget.h" +#include "HexDocument.h" +#include "Selection.h" +#include +#include +#include +#include + +class EditAnnotationDialog : public GUI::Dialog { + C_OBJECT_ABSTRACT(EditAnnotationDialog) + +public: + static ExecResult show_create_dialog(GUI::Window* parent_window, HexDocument&, Selection); + static ExecResult show_edit_dialog(GUI::Window* parent_window, HexDocument&, Annotation&); + static ErrorOr> try_create(GUI::Window* parent_window, HexDocument&, Variant); + +private: + EditAnnotationDialog(GUI::Window* parent_window, NonnullRefPtr, HexDocument&, Variant); + virtual ~EditAnnotationDialog() override = default; + + WeakPtr m_document; + Optional m_annotation; + + RefPtr m_start_offset; + RefPtr m_end_offset; + RefPtr m_background_color; + RefPtr m_save_button; + RefPtr m_cancel_button; +}; diff --git a/Userland/Applications/HexEditor/EditAnnotationWidget.gml b/Userland/Applications/HexEditor/EditAnnotationWidget.gml new file mode 100644 index 0000000000..3c8632bd07 --- /dev/null +++ b/Userland/Applications/HexEditor/EditAnnotationWidget.gml @@ -0,0 +1,75 @@ +@HexEditor::EditAnnotationWidget { + layout: @GUI::VerticalBoxLayout { + margins: [4] + } + fill_with_background_color: true + + @GUI::Widget { + layout: @GUI::HorizontalBoxLayout { + margins: [4] + } + preferred_height: "fit" + + @GUI::Label { + text: "Start offset:" + text_alignment: "CenterLeft" + } + + @GUI::NumericInput { + name: "start_offset" + min: 0 + } + } + + @GUI::Widget { + layout: @GUI::HorizontalBoxLayout { + margins: [4] + } + preferred_height: "fit" + + @GUI::Label { + text: "End offset:" + text_alignment: "CenterLeft" + } + + @GUI::NumericInput { + name: "end_offset" + min: 0 + } + } + + @GUI::Widget { + layout: @GUI::HorizontalBoxLayout { + margins: [4] + } + preferred_height: "fit" + + @GUI::Label { + text: "Color:" + text_alignment: "CenterLeft" + } + + @GUI::ColorInput { + name: "background_color" + } + } + + @GUI::Widget { + layout: @GUI::HorizontalBoxLayout { + margins: [4] + } + preferred_height: "fit" + + @GUI::Layout::Spacer {} + + @GUI::DialogButton { + name: "save_button" + text: "Save" + } + + @GUI::DialogButton { + name: "cancel_button" + text: "Cancel" + } + } +} diff --git a/Userland/Applications/HexEditor/EditAnnotationWidget.h b/Userland/Applications/HexEditor/EditAnnotationWidget.h new file mode 100644 index 0000000000..26634ef0b5 --- /dev/null +++ b/Userland/Applications/HexEditor/EditAnnotationWidget.h @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024, Sam Atkins + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include + +namespace HexEditor { + +class EditAnnotationWidget : public GUI::Widget { + C_OBJECT_ABSTRACT(GoToOffsetWidget) +public: + static ErrorOr> try_create(); + virtual ~EditAnnotationWidget() override = default; + +private: + EditAnnotationWidget() = default; +}; + +} diff --git a/Userland/Applications/HexEditor/HexDocument.cpp b/Userland/Applications/HexEditor/HexDocument.cpp index 3c69f1b554..a8d10d8ab0 100644 --- a/Userland/Applications/HexEditor/HexDocument.cpp +++ b/Userland/Applications/HexEditor/HexDocument.cpp @@ -1,5 +1,6 @@ /* * Copyright (c) 2021, Arne Elster + * Copyright (c) 2024, Sam Atkins * * SPDX-License-Identifier: BSD-2-Clause */ @@ -24,6 +25,34 @@ bool HexDocument::is_dirty() const return m_changes.size() > 0; } +void HexDocument::add_annotation(Annotation annotation) +{ + m_annotations.append(move(annotation)); +} + +void HexDocument::delete_annotation(Annotation const& annotation) +{ + m_annotations.remove_first_matching([&](auto& other) { + return other == annotation; + }); +} + +Optional HexDocument::closest_annotation_at(size_t position) +{ + // FIXME: If we end up with a lot of annotations, we'll need to store them and query them in a smarter way. + Optional result; + for (auto& annotation : m_annotations) { + if (annotation.start_offset <= position && position <= annotation.end_offset) { + // If multiple annotations cover this position, use whichever starts latest. This would be the innermost one + // if they overlap fully rather than partially. + if (!result.has_value() || result->start_offset < annotation.start_offset) + result = annotation; + } + } + + return result; +} + HexDocumentMemory::HexDocumentMemory(ByteBuffer&& buffer) : m_buffer(move(buffer)) { diff --git a/Userland/Applications/HexEditor/HexDocument.h b/Userland/Applications/HexEditor/HexDocument.h index f575feff39..f6034a0122 100644 --- a/Userland/Applications/HexEditor/HexDocument.h +++ b/Userland/Applications/HexEditor/HexDocument.h @@ -1,5 +1,6 @@ /* * Copyright (c) 2021, Arne Elster + * Copyright (c) 2024, Sam Atkins * * SPDX-License-Identifier: BSD-2-Clause */ @@ -14,9 +15,18 @@ #include #include #include +#include constexpr Duration COMMAND_COMMIT_TIME = Duration::from_milliseconds(400); +struct Annotation { + size_t start_offset { 0 }; + size_t end_offset { 0 }; + Gfx::Color background_color { Color::from_argb(0xfffce94f) }; + + bool operator==(Annotation const& other) const = default; +}; + class HexDocument : public Weakable { public: enum class Type { @@ -38,8 +48,14 @@ public: virtual bool is_dirty() const; virtual void clear_changes() = 0; + ReadonlySpan annotations() const { return m_annotations; } + void add_annotation(Annotation); + void delete_annotation(Annotation const&); + Optional closest_annotation_at(size_t position); + protected: HashMap m_changes; + Vector m_annotations; }; class HexDocumentMemory final : public HexDocument { diff --git a/Userland/Applications/HexEditor/HexEditor.cpp b/Userland/Applications/HexEditor/HexEditor.cpp index ff9ff8500d..d3884f2f2d 100644 --- a/Userland/Applications/HexEditor/HexEditor.cpp +++ b/Userland/Applications/HexEditor/HexEditor.cpp @@ -9,6 +9,7 @@ */ #include "HexEditor.h" +#include "EditAnnotationDialog.h" #include "SearchResultsModel.h" #include #include @@ -43,6 +44,32 @@ HexEditor::HexEditor() set_background_role(ColorRole::Base); set_foreground_role(ColorRole::BaseText); vertical_scrollbar().set_step(line_height()); + + m_context_menu = GUI::Menu::construct(); + m_add_annotation_action = GUI::Action::create( + "&Add Annotation", + Gfx::Bitmap::load_from_file("/res/icons/16x16/annotation-add.png"sv).release_value_but_fixme_should_propagate_errors(), + [this](GUI::Action&) { show_create_annotation_dialog(); }, + this); + m_context_menu->add_action(*m_add_annotation_action); + m_edit_annotation_action = GUI::Action::create( + "&Edit Annotation", + Gfx::Bitmap::load_from_file("/res/icons/16x16/annotation.png"sv).release_value_but_fixme_should_propagate_errors(), + [this](GUI::Action&) { + VERIFY(m_hovered_annotation.has_value()); + show_edit_annotation_dialog(*m_hovered_annotation); + }, + this); + m_context_menu->add_action(*m_edit_annotation_action); + m_delete_annotation_action = GUI::Action::create( + "&Delete Annotation", + Gfx::Bitmap::load_from_file("/res/icons/16x16/annotation-remove.png"sv).release_value_but_fixme_should_propagate_errors(), + [this](GUI::Action&) { + VERIFY(m_hovered_annotation.has_value()); + show_delete_annotation_dialog(*m_hovered_annotation); + }, + this); + m_context_menu->add_action(*m_delete_annotation_action); } ErrorOr HexEditor::open_new_file(size_t size) @@ -320,8 +347,10 @@ void HexEditor::mousemove_event(GUI::MouseEvent& event) if (maybe_offset_data.has_value()) { set_override_cursor(Gfx::StandardCursor::IBeam); + m_hovered_annotation = m_document->closest_annotation_at(maybe_offset_data->offset); } else { set_override_cursor(Gfx::StandardCursor::None); + m_hovered_annotation.clear(); } if (m_in_drag_select) { @@ -520,6 +549,13 @@ ErrorOr HexEditor::text_mode_keydown_event(GUI::KeyEvent& event) return {}; } +void HexEditor::context_menu_event(GUI::ContextMenuEvent& event) +{ + m_edit_annotation_action->set_visible(m_hovered_annotation.has_value()); + m_delete_annotation_action->set_visible(m_hovered_annotation.has_value()); + m_context_menu->popup(event.screen_position()); +} + void HexEditor::update_status() { if (on_status_change) @@ -591,6 +627,7 @@ void HexEditor::paint_event(GUI::PaintEvent& event) return; auto const cell = m_document->get(byte_position); + auto const annotation = m_document->closest_annotation_at(byte_position); Gfx::IntRect hex_display_rect_high_nibble { frame_thickness() + offset_margin_width() + j * cell_width() + 2 * m_padding, @@ -623,13 +660,16 @@ void HexEditor::paint_event(GUI::PaintEvent& event) // 1. Modified bytes // 2. The cursor position // 3. The selection - // 4. Null bytes - // 5. Regular formatting + // 4. Annotations + // 5. Null bytes + // 6. Regular formatting auto determine_background_color = [&](EditMode edit_mode) -> Gfx::Color { if (selected) return cell.modified ? palette().selection().inverted() : palette().selection(); if (byte_position == m_position && m_edit_mode != edit_mode) return palette().inactive_selection(); + if (annotation.has_value()) + return annotation->background_color; return palette().color(background_role()); }; auto determine_text_color = [&](EditMode edit_mode) -> Gfx::Color { @@ -639,6 +679,8 @@ void HexEditor::paint_event(GUI::PaintEvent& event) return palette().selection_text(); if (byte_position == m_position) return (m_edit_mode == edit_mode) ? palette().color(foreground_role()) : palette().inactive_selection_text(); + if (annotation.has_value()) + return annotation->background_color.suggested_foreground_color(); if (cell.value == 0x00) return palette().color(ColorRole::PlaceholderText); return palette().color(foreground_role()); @@ -869,4 +911,27 @@ GUI::UndoStack& HexEditor::undo_stack() return m_undo_stack; } +void HexEditor::show_create_annotation_dialog() +{ + auto result = EditAnnotationDialog::show_create_dialog(window(), *m_document, selection()); + if (result == GUI::Dialog::ExecResult::OK) + update(); +} + +void HexEditor::show_edit_annotation_dialog(Annotation& annotation) +{ + auto result = EditAnnotationDialog::show_edit_dialog(window(), *m_document, annotation); + if (result == GUI::Dialog::ExecResult::OK) + update(); +} + +void HexEditor::show_delete_annotation_dialog(Annotation& annotation) +{ + auto result = GUI::MessageBox::show(window(), "Delete this annotation?"sv, "Delete annotation?"sv, GUI::MessageBox::Type::Question, GUI::MessageBox::InputType::YesNo); + if (result == GUI::Dialog::ExecResult::Yes) { + m_document->delete_annotation(annotation); + update(); + } +} + } diff --git a/Userland/Applications/HexEditor/HexEditor.h b/Userland/Applications/HexEditor/HexEditor.h index 0ddd60f0b4..33c4d89b3b 100644 --- a/Userland/Applications/HexEditor/HexEditor.h +++ b/Userland/Applications/HexEditor/HexEditor.h @@ -68,6 +68,10 @@ public: Function on_status_change; Function on_change; + void show_create_annotation_dialog(); + void show_edit_annotation_dialog(Annotation&); + void show_delete_annotation_dialog(Annotation&); + protected: HexEditor(); @@ -76,6 +80,7 @@ protected: virtual void mouseup_event(GUI::MouseEvent&) override; virtual void mousemove_event(GUI::MouseEvent&) override; virtual void keydown_event(GUI::KeyEvent&) override; + virtual void context_menu_event(GUI::ContextMenuEvent&) override; private: size_t m_line_spacing { 4 }; @@ -88,6 +93,12 @@ private: EditMode m_edit_mode { Hex }; NonnullOwnPtr m_document; GUI::UndoStack m_undo_stack; + Optional m_hovered_annotation; + + RefPtr m_context_menu; + RefPtr m_add_annotation_action; + RefPtr m_edit_annotation_action; + RefPtr m_delete_annotation_action; static constexpr int m_address_bar_width = 90; static constexpr int m_padding = 5; diff --git a/Userland/Applications/HexEditor/HexEditorWidget.cpp b/Userland/Applications/HexEditor/HexEditorWidget.cpp index 77bd48fcd6..1e24d4a946 100644 --- a/Userland/Applications/HexEditor/HexEditorWidget.cpp +++ b/Userland/Applications/HexEditor/HexEditorWidget.cpp @@ -451,6 +451,12 @@ ErrorOr HexEditorWidget::initialize_menubar(GUI::Window& window) })); edit_menu->add_action(*m_fill_selection_action); edit_menu->add_separator(); + edit_menu->add_action(GUI::Action::create( + "Add Annotation", + Gfx::Bitmap::load_from_file("/res/icons/16x16/annotation-add.png"sv).release_value_but_fixme_should_propagate_errors(), + [this](GUI::Action&) { m_editor->show_create_annotation_dialog(); }, + this)); + edit_menu->add_separator(); edit_menu->add_action(*m_copy_hex_action); edit_menu->add_action(*m_copy_text_action); edit_menu->add_action(*m_copy_as_c_code_action);