Просмотр исходного кода

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>
Sam Atkins 1 год назад
Родитель
Сommit
cbd28c9110

BIN
Base/res/icons/16x16/annotation-add.png


BIN
Base/res/icons/16x16/annotation-remove.png


BIN
Base/res/icons/16x16/annotation.png


+ 5 - 2
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

+ 109 - 0
Userland/Applications/HexEditor/EditAnnotationDialog.cpp

@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2024, Sam Atkins <atkinssj@serenityos.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#include "EditAnnotationDialog.h"
+#include <LibGUI/MessageBox.h>
+
+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<NonnullRefPtr<EditAnnotationDialog>> EditAnnotationDialog::try_create(GUI::Window* parent_window, HexDocument& hex_document, Variant<Annotation*, Selection> 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<HexEditor::EditAnnotationWidget> widget, HexDocument& hex_document, Variant<Annotation*, Selection> 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<GUI::NumericInput>("start_offset");
+    m_end_offset = find_descendant_of_type_named<GUI::NumericInput>("end_offset");
+    m_background_color = find_descendant_of_type_named<GUI::ColorInput>("background_color");
+    m_save_button = find_descendant_of_type_named<GUI::DialogButton>("save_button");
+    m_cancel_button = find_descendant_of_type_named<GUI::DialogButton>("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<i64>::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<i64>::max());
+            VERIFY(m_annotation->end_offset <= NumericLimits<i64>::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<size_t>(m_start_offset->value());
+        auto end_offset = static_cast<size_t>(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);
+    };
+}

+ 37 - 0
Userland/Applications/HexEditor/EditAnnotationDialog.h

@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2024, Sam Atkins <atkinssj@serenityos.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#pragma once
+
+#include "EditAnnotationWidget.h"
+#include "HexDocument.h"
+#include "Selection.h"
+#include <LibGUI/Button.h>
+#include <LibGUI/ColorInput.h>
+#include <LibGUI/Dialog.h>
+#include <LibGUI/NumericInput.h>
+
+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<NonnullRefPtr<EditAnnotationDialog>> try_create(GUI::Window* parent_window, HexDocument&, Variant<Annotation*, Selection>);
+
+private:
+    EditAnnotationDialog(GUI::Window* parent_window, NonnullRefPtr<HexEditor::EditAnnotationWidget>, HexDocument&, Variant<Annotation*, Selection>);
+    virtual ~EditAnnotationDialog() override = default;
+
+    WeakPtr<HexDocument> m_document;
+    Optional<Annotation&> m_annotation;
+
+    RefPtr<GUI::NumericInput> m_start_offset;
+    RefPtr<GUI::NumericInput> m_end_offset;
+    RefPtr<GUI::ColorInput> m_background_color;
+    RefPtr<GUI::Button> m_save_button;
+    RefPtr<GUI::Button> m_cancel_button;
+};

+ 75 - 0
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"
+        }
+    }
+}

+ 23 - 0
Userland/Applications/HexEditor/EditAnnotationWidget.h

@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2024, Sam Atkins <atkinssj@serenityos.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#pragma once
+
+#include <LibGUI/Widget.h>
+
+namespace HexEditor {
+
+class EditAnnotationWidget : public GUI::Widget {
+    C_OBJECT_ABSTRACT(GoToOffsetWidget)
+public:
+    static ErrorOr<NonnullRefPtr<EditAnnotationWidget>> try_create();
+    virtual ~EditAnnotationWidget() override = default;
+
+private:
+    EditAnnotationWidget() = default;
+};
+
+}

+ 29 - 0
Userland/Applications/HexEditor/HexDocument.cpp

@@ -1,5 +1,6 @@
 /*
  * Copyright (c) 2021, Arne Elster <arne@elster.li>
+ * Copyright (c) 2024, Sam Atkins <atkinssj@serenityos.org>
  *
  * 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<Annotation&> 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<Annotation&> 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))
 {

+ 16 - 0
Userland/Applications/HexEditor/HexDocument.h

@@ -1,5 +1,6 @@
 /*
  * Copyright (c) 2021, Arne Elster <arne@elster.li>
+ * Copyright (c) 2024, Sam Atkins <atkinssj@serenityos.org>
  *
  * SPDX-License-Identifier: BSD-2-Clause
  */
@@ -14,9 +15,18 @@
 #include <AK/WeakPtr.h>
 #include <LibCore/Forward.h>
 #include <LibGUI/Command.h>
+#include <LibGfx/Color.h>
 
 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<HexDocument> {
 public:
     enum class Type {
@@ -38,8 +48,14 @@ public:
     virtual bool is_dirty() const;
     virtual void clear_changes() = 0;
 
+    ReadonlySpan<Annotation> annotations() const { return m_annotations; }
+    void add_annotation(Annotation);
+    void delete_annotation(Annotation const&);
+    Optional<Annotation&> closest_annotation_at(size_t position);
+
 protected:
     HashMap<size_t, u8> m_changes;
+    Vector<Annotation> m_annotations;
 };
 
 class HexDocumentMemory final : public HexDocument {

+ 67 - 2
Userland/Applications/HexEditor/HexEditor.cpp

@@ -9,6 +9,7 @@
  */
 
 #include "HexEditor.h"
+#include "EditAnnotationDialog.h"
 #include "SearchResultsModel.h"
 #include <AK/ByteString.h>
 #include <AK/Debug.h>
@@ -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<void> 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<void> 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();
+    }
+}
+
 }

+ 11 - 0
Userland/Applications/HexEditor/HexEditor.h

@@ -68,6 +68,10 @@ public:
     Function<void(size_t position, EditMode, Selection)> on_status_change;
     Function<void(bool is_document_dirty)> 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<HexDocument> m_document;
     GUI::UndoStack m_undo_stack;
+    Optional<Annotation&> m_hovered_annotation;
+
+    RefPtr<GUI::Menu> m_context_menu;
+    RefPtr<GUI::Action> m_add_annotation_action;
+    RefPtr<GUI::Action> m_edit_annotation_action;
+    RefPtr<GUI::Action> m_delete_annotation_action;
 
     static constexpr int m_address_bar_width = 90;
     static constexpr int m_padding = 5;

+ 6 - 0
Userland/Applications/HexEditor/HexEditorWidget.cpp

@@ -451,6 +451,12 @@ ErrorOr<void> 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);