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>
This commit is contained in:
Sam Atkins 2024-01-10 19:49:40 +00:00 committed by Sam Atkins
parent 1168e46c1d
commit cbd28c9110
13 changed files with 378 additions and 4 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

View file

@ -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

View file

@ -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);
};
}

View file

@ -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;
};

View file

@ -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"
}
}
}

View file

@ -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;
};
}

View file

@ -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))
{

View file

@ -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 {

View file

@ -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();
}
}
}

View file

@ -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;

View file

@ -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);