diff --git a/Base/res/icons/pixelpaint/text.png b/Base/res/icons/pixelpaint/text.png new file mode 100644 index 0000000000..406e0ba015 Binary files /dev/null and b/Base/res/icons/pixelpaint/text.png differ diff --git a/Userland/Applications/PixelPaint/CMakeLists.txt b/Userland/Applications/PixelPaint/CMakeLists.txt index a423b52e37..125dcf45f9 100644 --- a/Userland/Applications/PixelPaint/CMakeLists.txt +++ b/Userland/Applications/PixelPaint/CMakeLists.txt @@ -67,6 +67,7 @@ set(SOURCES Tools/RectangleSelectTool.cpp Tools/RectangleTool.cpp Tools/SprayTool.cpp + Tools/TextTool.cpp Tools/Tool.cpp Tools/WandSelectTool.cpp Tools/ZoomTool.cpp diff --git a/Userland/Applications/PixelPaint/ToolboxWidget.cpp b/Userland/Applications/PixelPaint/ToolboxWidget.cpp index c6c186dfc0..5d81725346 100644 --- a/Userland/Applications/PixelPaint/ToolboxWidget.cpp +++ b/Userland/Applications/PixelPaint/ToolboxWidget.cpp @@ -21,6 +21,7 @@ #include "Tools/RectangleSelectTool.h" #include "Tools/RectangleTool.h" #include "Tools/SprayTool.h" +#include "Tools/TextTool.h" #include "Tools/WandSelectTool.h" #include "Tools/ZoomTool.h" #include @@ -87,6 +88,7 @@ void ToolboxWidget::setup_tools() add_tool("line"sv, { Mod_Ctrl | Mod_Shift, Key_L }, make()); add_tool("rectangle"sv, { Mod_Ctrl | Mod_Shift, Key_R }, make()); add_tool("circle"sv, { Mod_Ctrl | Mod_Shift, Key_E }, make()); + add_tool("text"sv, { Mod_Ctrl | Mod_Shift, Key_T }, make()); add_tool("zoom"sv, { 0, Key_Z }, make()); add_tool("rectangle-select"sv, { 0, Key_R }, make()); add_tool("wand-select"sv, { 0, Key_W }, make()); diff --git a/Userland/Applications/PixelPaint/Tools/TextTool.cpp b/Userland/Applications/PixelPaint/Tools/TextTool.cpp new file mode 100644 index 0000000000..617f4e8782 --- /dev/null +++ b/Userland/Applications/PixelPaint/Tools/TextTool.cpp @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2022, Timothy Slater + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "TextTool.h" +#include "../ImageEditor.h" +#include "../Layer.h" +#include "LibGUI/FontPicker.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace PixelPaint { + +TextToolEditor::TextToolEditor() + : TextEditor(TextEditor::MultiLine) +{ +} + +void TextToolEditor::handle_keyevent(Badge, GUI::KeyEvent& event) +{ + TextEditor::keydown_event(event); +} + +TextTool::TextTool() +{ + m_text_editor = TextToolEditor::construct(); + m_text_editor->set_wrapping_mode(GUI::TextEditor::WrappingMode::NoWrap); + m_selected_font = Gfx::FontDatabase::default_font(); + m_text_editor->set_font(m_selected_font); + m_cursor_blink_timer = Core::Timer::construct(); + m_cursor_blink_timer->on_timeout = [&]() { + m_cursor_blink_state = !m_cursor_blink_state; + }; + m_cursor_blink_timer->set_interval(500); +} + +void TextTool::on_tool_deactivation() +{ + reset_tool(); +} +void TextTool::on_mousemove(Layer*, MouseEvent& event) +{ + if (m_text_input_is_active) { + auto mouse_position = editor_stroke_position(event.layer_event().position(), 1); + m_mouse_is_over_text = m_ants_rect.contains(mouse_position); + m_editor->update_tool_cursor(); + } + + if (m_is_dragging) { + auto new_position = event.layer_event().position(); + m_add_text_position = m_add_text_position + (new_position - m_drag_start_point); + m_drag_start_point = new_position; + } +} +void TextTool::on_mouseup(Layer*, MouseEvent&) +{ + m_is_dragging = false; +} +void TextTool::on_mousedown(Layer*, MouseEvent& event) +{ + auto start_text_region = [&] { + m_text_color = m_editor->color_for(event.layer_event()); + m_text_input_is_active = true; + m_text_editor->set_text(""sv); + m_add_text_position = event.layer_event().position(); + m_editor->image().selection().begin_interactive_selection(); + m_cursor_blink_timer->start(); + m_editor->update(); + }; + + if (!m_text_input_is_active) { + start_text_region(); + return; + } + + if (m_mouse_is_over_text) { + m_is_dragging = true; + m_drag_start_point = event.layer_event().position(); + } else { + // User clicked somewhere outside the currently edited text region + // apply the current text and then start a new one where they clicked. + apply_text_to_layer(); + reset_tool(); + start_text_region(); + } +} + +GUI::Widget* TextTool::get_properties_widget() +{ + if (m_properties_widget) + return m_properties_widget.ptr(); + + m_properties_widget = GUI::Widget::construct(); + m_properties_widget->set_layout(); + + auto& font_header = m_properties_widget->add("Current Font:"); + font_header.set_text_alignment(Gfx::TextAlignment::CenterLeft); + + m_font_label = m_properties_widget->add(m_selected_font->human_readable_name()); + + auto& change_font_button = m_properties_widget->add("Change Font..."); + change_font_button.on_click = [&](auto) { + auto picker = GUI::FontPicker::construct(nullptr, m_selected_font, false); + if (picker->exec() == GUI::Dialog::ExecResult::OK) { + m_font_label->set_text(picker->font()->human_readable_name()); + m_selected_font = picker->font(); + m_text_editor->set_font(m_selected_font); + m_editor->set_focus(true); + } + }; + return m_properties_widget.ptr(); +} + +void TextTool::on_second_paint(Layer const* layer, GUI::PaintEvent& event) +{ + if (!m_text_input_is_active) + return; + + GUI::Painter painter(*m_editor); + painter.add_clip_rect(event.rect()); + painter.translate(editor_layer_location(*layer)); + auto typed_text = m_text_editor->text(); + auto text_width = max(m_selected_font->width(typed_text), m_selected_font->width(" "sv)); + auto text_height = m_selected_font->preferred_line_height() * max(static_cast(m_text_editor->line_count()), 1); + auto text_location = editor_stroke_position(m_add_text_position, 1); + + // Since ImageEditor can be zoomed in/out, we need to be able to render the preview properly scaled + // GUI::Painter doesn't have a way to draw a font scaled directly, so we draw the text to a bitmap + // and then scale the bitmap and blit the result to the ImageEditor. + auto text_bitmap_result = Gfx::Bitmap::try_create(Gfx::BitmapFormat::BGRA8888, { text_width, text_height }); + if (text_bitmap_result.is_error()) + return; + auto text_bitmap = text_bitmap_result.release_value(); + auto text_painter = GUI::Painter(text_bitmap); + text_painter.set_font(*m_selected_font); + text_painter.draw_text({ 0, 0, text_width, text_height }, typed_text, Gfx::TextAlignment::TopLeft, m_text_color); + + m_text_editor->update(); + + // Draw selected text (if any) + if (m_text_editor->has_selection()) { + auto selection = m_text_editor->selection(); + + // Draw selected text for each line... + auto selection_start_line = selection.normalized().start().line(); + auto selection_end_line = selection.normalized().end().line(); + + for (auto i = selection_start_line; i <= selection_end_line; ++i) { + + auto start_col = 0; + auto end_col = 0; + + // First line of selection. + if (i == selection_start_line) { + + if (i < selection_end_line) { + // multiple lines selected. we select from selection start to the end of the line + start_col = m_text_editor->selection().normalized().start().column(); + end_col = m_text_editor->line(i).length(); + } else { + // only a single line in the selection + start_col = m_text_editor->selection().normalized().start().column(); + end_col = m_text_editor->selection().normalized().end().column(); + } + } else if (i == selection_end_line) { + // We are highlighting the final line of the selection. + // Start from first char and continue to selection end. + start_col = 0; + end_col = m_text_editor->selection().normalized().end().column(); + } else { + // We are between the start and end lines, highlight the whole thing. + start_col = 0; + end_col = m_text_editor->line(i).length(); + } + auto line_selection_length = end_col - start_col; + auto selected_string = m_text_editor->line(i).view().substring_view(start_col, line_selection_length); + auto text_before_selection = m_text_editor->line(i).view().substring_view(0, start_col); + auto selected_width = m_selected_font->width(selected_string); + auto selection_x_offset = m_selected_font->width(text_before_selection); + + // the + 4 here is because that's how Painter::do_draw_text calculates line height, instead of asking + // the font it's preferred line height. If we don't replicate that here, the letters jump around when they + // get selected. + auto selection_y_offset = static_cast((m_selected_font->pixel_size() + 4) * i); + + auto selection_rect = Gfx::IntRect(selection_x_offset, selection_y_offset, selected_width, m_selected_font->preferred_line_height()); + text_painter.fill_rect(selection_rect, m_text_editor->palette().selection()); + text_painter.draw_text(selection_rect, selected_string, Gfx::TextAlignment::TopLeft, m_text_editor->palette().selection_text()); + } + } + + auto scaled_width = static_cast(ceilf(m_editor->scale() * static_cast(text_bitmap->width()))); + auto scaled_height = static_cast(ceilf(m_editor->scale() * static_cast(text_bitmap->height()))); + auto scaled_rect = Gfx::IntRect(text_location.x(), text_location.y(), scaled_width, scaled_height); + scaled_rect.set_location({ text_location.x(), text_location.y() }); + painter.draw_scaled_bitmap(scaled_rect, text_bitmap, text_bitmap->rect(), 1.0); + + // marching ants box + auto right_padding = m_selected_font->width(" "sv); + m_ants_rect = Gfx::IntRect(text_location.translated(-4, -2), { scaled_rect.width() + 4 + right_padding, scaled_rect.height() + 4 }); + m_editor->draw_marching_ants(painter, m_ants_rect); + + // Draw the blinking cursor. + if (m_cursor_blink_state) { + auto editor_cursor_rect = m_text_editor->cursor_content_rect(); + + // TextEditor starts left most at 3, for TextTool this ends up putting the cursor in the middle of the letter. + // Looks better if we treat 0 as left most here, so we just translate it to the left. + editor_cursor_rect.translate_by(-3, 0); + + // ImageEditor scale is a float, but we are working with int and IntRects. + auto scaled_cursor_x = static_cast(ceilf(m_editor->scale() * static_cast(editor_cursor_rect.x()))); + auto scaled_cursor_y = static_cast(ceilf(m_editor->scale() * static_cast(editor_cursor_rect.y()))); + auto scaled_cursor_width = static_cast(ceilf(m_editor->scale() * static_cast(editor_cursor_rect.width()))); + auto scaled_cursor_height = static_cast(ceilf(m_editor->scale() * static_cast(editor_cursor_rect.height()))); + + auto scaled_cursor_rect = Gfx::IntRect { scaled_cursor_x + text_location.x(), + scaled_cursor_y + text_location.y(), + scaled_cursor_width, + scaled_cursor_height }; + painter.fill_rect(scaled_cursor_rect, m_text_color); + } +} + +void TextTool::apply_text_to_layer() +{ + auto layer = m_editor->active_layer(); + GUI::Painter painter(layer->get_scratch_edited_bitmap()); + + auto demo_text = m_text_editor->text(); + auto text_width = m_selected_font->width(demo_text); + auto text_height = m_selected_font->preferred_line_height() * static_cast(m_text_editor->line_count()); + + painter.set_font(*m_selected_font); + auto text_rect = Gfx::Rect(m_add_text_position, { text_width, text_height }); + painter.draw_text(text_rect, demo_text, Gfx::TextAlignment::TopLeft, m_text_color); + m_editor->did_complete_action(tool_name()); + layer->did_modify_bitmap(text_rect); +} +void TextTool::reset_tool() +{ + // This puts the tool back into initial state between text additions (except for selected font/color) + m_text_input_is_active = false; + m_is_dragging = false; + m_mouse_is_over_text = false; + m_text_editor->set_text(""sv); + m_editor->image().selection().end_interactive_selection(); + m_cursor_blink_timer->stop(); + m_editor->update(); + m_editor->update_tool_cursor(); +} +bool TextTool::on_keydown(GUI::KeyEvent& event) +{ + if (!m_text_input_is_active) + return false; + + // Cancels current text entry + if (event.key() == Key_Escape) { + reset_tool(); + return true; + } + + // A plain Return is treated as accepting the current state and rasterizing to the layer. + // For multi-line text Shift + Enter will add new lines. + if (event.modifiers() == Mod_None && event.key() == Key_Return) { + apply_text_to_layer(); + reset_tool(); + return true; + } + + // Pass the key event off to our TextEditor subclass which handles all text entry features like + // caret navigation, backspace/delete, etc. + m_text_editor->handle_keyevent({}, event); + m_editor->update(); + return true; +} + +Variant> TextTool::cursor() +{ + if (m_mouse_is_over_text) + return Gfx::StandardCursor::Move; + + return Gfx::StandardCursor::Arrow; +} + +} diff --git a/Userland/Applications/PixelPaint/Tools/TextTool.h b/Userland/Applications/PixelPaint/Tools/TextTool.h new file mode 100644 index 0000000000..5127db494c --- /dev/null +++ b/Userland/Applications/PixelPaint/Tools/TextTool.h @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2022, Timothy Slater + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "Tool.h" +#include +#include +#include +#include +#include +#include + +namespace PixelPaint { +class TextTool; + +class TextToolEditor : public GUI::TextEditor { + C_OBJECT(TextToolEditor) +public: + virtual ~TextToolEditor() override = default; + virtual void handle_keyevent(Badge, GUI::KeyEvent&); + +protected: + TextToolEditor(); +}; + +class TextTool final : public Tool { +public: + TextTool(); + virtual ~TextTool() override = default; + + virtual void on_mousemove(Layer*, MouseEvent&) override; + virtual void on_mouseup(Layer*, MouseEvent&) override; + virtual void on_mousedown(Layer*, MouseEvent&) override; + virtual bool on_keydown(GUI::KeyEvent&) override; + virtual void on_second_paint(Layer const*, GUI::PaintEvent&) override; + virtual void on_tool_deactivation() override; + virtual Variant> cursor() override; + virtual GUI::Widget* get_properties_widget() override; + +private: + virtual StringView tool_name() const override { return "Text Tool"sv; } + void apply_text_to_layer(); + void reset_tool(); + + RefPtr m_properties_widget; + RefPtr m_font_label; + RefPtr m_cursor_blink_timer; + RefPtr m_text_editor; + Gfx::IntPoint m_add_text_position { 0, 0 }; + RefPtr m_selected_font; + bool m_text_input_is_active { false }; + bool m_cursor_blink_state { false }; + bool m_mouse_is_over_text { false }; + bool m_is_dragging { false }; + Gfx::IntPoint m_drag_start_point; + Gfx::IntRect m_ants_rect; + Color m_text_color; +}; + +}