diff --git a/data/widgets/user_data.xml b/data/widgets/user_data.xml
index 35996cd71..e52676be5 100644
--- a/data/widgets/user_data.xml
+++ b/data/widgets/user_data.xml
@@ -6,6 +6,8 @@
-
+
+
+
diff --git a/src/app/commands/cmd_cel_properties.cpp b/src/app/commands/cmd_cel_properties.cpp
index 140f6ec02..4c2f7b46a 100644
--- a/src/app/commands/cmd_cel_properties.cpp
+++ b/src/app/commands/cmd_cel_properties.cpp
@@ -345,7 +345,7 @@ private:
color_t c = m_cel->data()->userData().color();
m_userDataView.color()->setColor(
Color::fromRgb(rgba_getr(c), rgba_getg(c), rgba_getb(c), rgba_geta(c)));
- m_userDataView.entry()->setText(m_cel->data()->userData().text());
+ m_userDataView.textEdit()->setText(m_cel->data()->userData().text());
// Set last filled values in CelPropertiesWindow
m_lastValues.opacity = m_cel->opacity();
m_lastValues.zIndex = m_cel->zIndex();
diff --git a/src/app/commands/cmd_layer_properties.cpp b/src/app/commands/cmd_layer_properties.cpp
index 3a57e8260..3b01dbab5 100644
--- a/src/app/commands/cmd_layer_properties.cpp
+++ b/src/app/commands/cmd_layer_properties.cpp
@@ -454,7 +454,7 @@ private:
color_t c = m_layer->userData().color();
m_userDataView.color()->setColor(
Color::fromRgb(rgba_getr(c), rgba_getg(c), rgba_getb(c), rgba_geta(c)));
- m_userDataView.entry()->setText(m_layer->userData().text());
+ m_userDataView.textEdit()->setText(m_layer->userData().text());
}
else {
name()->setText(Strings::layer_properties_no_layer());
diff --git a/src/app/ui/skin/skin_theme.h b/src/app/ui/skin/skin_theme.h
index 3b9a901a6..251f7595b 100644
--- a/src/app/ui/skin/skin_theme.h
+++ b/src/app/ui/skin/skin_theme.h
@@ -163,7 +163,7 @@ public:
return 0;
}
- gfx::Color getColorById(const std::string& id) const
+ gfx::Color getColorById(const std::string& id) const override
{
auto it = m_colors_by_id.find(id);
if (it != m_colors_by_id.end())
diff --git a/src/app/ui/slice_window.cpp b/src/app/ui/slice_window.cpp
index 137c4a5c8..def11c665 100644
--- a/src/app/ui/slice_window.cpp
+++ b/src/app/ui/slice_window.cpp
@@ -90,10 +90,9 @@ SliceWindow::SliceWindow(const doc::Sprite* sprite,
entry->Change.connect([this, entry, mod] { onModifyField(entry, mod); });
}
- ui::Entry* userDataEntry = m_userDataView.entry();
- userDataEntry->setSuffix("*");
- userDataEntry->Change.connect(
- [this, userDataEntry] { onModifyField(userDataEntry, kUserData); });
+ ui::TextEdit* userDataEntry = m_userDataView.textEdit();
+ // userDataEntry->setSuffix("*");
+ userDataEntry->Change.connect([this, userDataEntry] { onModifyField(nullptr, kUserData); });
ColorButton* colorButton = m_userDataView.color();
colorButton->Click.connect([this] { onPossibleColorChange(); });
diff --git a/src/app/ui/user_data_view.cpp b/src/app/ui/user_data_view.cpp
index b76cc6d83..198fbe726 100644
--- a/src/app/ui/user_data_view.cpp
+++ b/src/app/ui/user_data_view.cpp
@@ -58,14 +58,14 @@ void UserDataView::configureAndSet(const doc::UserData& userData, ui::Grid* pare
parent->addChildInCell(colorLabel(), hspan1, vspan, ui::LEFT);
parent->addChildInCell(color(), hspan2, vspan, ui::HORIZONTAL);
parent->addChildInCell(entryLabel(), hspan1, vspan, ui::LEFT);
- parent->addChildInCell(entry(), hspan2, vspan, ui::HORIZONTAL);
+ parent->addChildInCell(textEditView(), hspan2, vspan, ui::HORIZONTAL);
color()->Change.connect([this] { onColorChange(); });
- entry()->Change.connect([this] { onEntryChange(); });
+ textEdit()->Change.connect([this] { onEntryChange(); });
m_isConfigured = true;
}
m_userData = userData;
color()->setColor(Color::fromImage(doc::IMAGE_RGB, userData.color()));
- entry()->setText(m_userData.text());
+ textEdit()->setText(m_userData.text());
setVisible(isVisible());
}
@@ -79,15 +79,15 @@ void UserDataView::setVisible(bool state, bool saveAsDefault)
colorLabel()->setVisible(state);
color()->setVisible(state);
entryLabel()->setVisible(state);
- entry()->setVisible(state);
+ textEditView()->setVisible(state);
if (saveAsDefault)
m_visibility.setValue(state);
}
void UserDataView::onEntryChange()
{
- if (entry()->text() != m_userData.text()) {
- m_userData.setText(entry()->text());
+ if (textEdit()->text() != m_userData.text()) {
+ m_userData.setText(textEdit()->text());
if (!m_selfUpdate)
UserDataChange();
}
diff --git a/src/app/ui/user_data_view.h b/src/app/ui/user_data_view.h
index 4aae0888b..4fdb839ba 100644
--- a/src/app/ui/user_data_view.h
+++ b/src/app/ui/user_data_view.h
@@ -13,9 +13,9 @@
#include "doc/user_data.h"
#include "obs/signal.h"
#include "ui/base.h"
-#include "ui/entry.h"
#include "ui/grid.h"
#include "ui/label.h"
+#include "ui/textedit.h"
#include "user_data.xml.h"
@@ -31,7 +31,8 @@ public:
const doc::UserData& userData() const { return m_userData; }
ColorButton* color() { return m_container.color(); }
- ui::Entry* entry() { return m_container.entry(); }
+ ui::TextEdit* textEdit() { return m_container.textEdit(); }
+ ui::View* textEditView() { return m_container.textEditView(); }
ui::Label* colorLabel() { return m_container.colorLabel(); }
ui::Label* entryLabel() { return m_container.entryLabel(); }
diff --git a/src/app/widget_loader.cpp b/src/app/widget_loader.cpp
index 6d2e3b0d1..8bdcf8c7e 100644
--- a/src/app/widget_loader.cpp
+++ b/src/app/widget_loader.cpp
@@ -35,6 +35,7 @@
#include "base/fs.h"
#include "base/memory.h"
#include "os/system.h"
+#include "ui/textedit.h"
#include "ui/ui.h"
#include "tinyxml2.h"
@@ -256,7 +257,7 @@ Widget* WidgetLoader::convertXmlElementToWidget(const XMLElement* elem,
if (elem_name == "expr" && decimals)
((ExprEntry*)widget)->setDecimals(strtol(decimals, nullptr, 10));
}
- if (elem_name == "filename") {
+ else if (elem_name == "filename") {
const char* button_only = elem->Attribute("button_only");
const app::FilenameField::Type type = ((button_only != nullptr &&
strtol(button_only, nullptr, 10) == 1) ?
@@ -265,6 +266,9 @@ Widget* WidgetLoader::convertXmlElementToWidget(const XMLElement* elem,
widget = new app::FilenameField(type, "");
}
+ else if (elem_name == "textedit") {
+ widget = new TextEdit();
+ }
else if (elem_name == "grid") {
const char* columns = elem->Attribute("columns");
bool same_width_columns = bool_attr(elem, "same_width_columns", false);
diff --git a/src/gen/ui_class.cpp b/src/gen/ui_class.cpp
index 891c10447..b2fe700fc 100644
--- a/src/gen/ui_class.cpp
+++ b/src/gen/ui_class.cpp
@@ -102,6 +102,8 @@ static Item convert_to_item(XMLElement* elem)
return item.typeIncl("app::DropDownButton", "app/ui/drop_down_button.h");
if (name == "entry")
return item.typeIncl("ui::Entry", "ui/entry.h");
+ if (name == "textedit")
+ return item.typeIncl("ui::TextEdit", "ui/textedit.h");
if (name == "expr")
return item.typeIncl("app::ExprEntry", "app/ui/expr_entry.h");
if (name == "filename")
diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt
index 6322e3547..eca04ef3b 100644
--- a/src/ui/CMakeLists.txt
+++ b/src/ui/CMakeLists.txt
@@ -50,6 +50,7 @@ add_library(ui-lib
style.cpp
system.cpp
textbox.cpp
+ textedit.cpp
theme.cpp
timer.cpp
tooltips.cpp
diff --git a/src/ui/textedit.cpp b/src/ui/textedit.cpp
new file mode 100644
index 000000000..bb0745ea0
--- /dev/null
+++ b/src/ui/textedit.cpp
@@ -0,0 +1,761 @@
+// Aseprite
+// Copyright (C) 2024 Igara Studio S.A.
+// Copyright (C) 2001-2018 David Capello
+//
+// This program is distributed under the terms of
+// the End-User License Agreement for Aseprite.
+
+#ifdef HAVE_CONFIG_H
+ #include "config.h"
+#endif
+
+#include "ui/textedit.h"
+
+#include "base/replace_string.h"
+#include "base/split_string.h"
+#include "os/system.h"
+#include "text/font_metrics.h"
+#include "text/font_mgr.h"
+#include "text/text_blob.h"
+#include "ui/menu.h"
+#include "ui/message.h"
+#include "ui/paint_event.h"
+#include "ui/resize_event.h"
+#include "ui/scroll_helper.h"
+#include "ui/scroll_region_event.h"
+#include "ui/size_hint_event.h"
+#include "ui/system.h"
+#include "ui/theme.h"
+#include "ui/timer.h"
+#include "ui/view.h"
+#include
+
+namespace ui {
+
+// Shared timer between all editors.
+static std::unique_ptr s_timer;
+
+TextEdit::TextEdit() : Widget(kGenericWidget), m_caret(&m_lines)
+{
+ enableFlags(CTRL_RIGHT_CLICK);
+ setFocusStop(true);
+ InitTheme.connect([this] {
+ setBorder(gfx::Border(2) * guiscale()); // TODO: Move to theme
+ });
+ initTheme();
+}
+
+void TextEdit::cut()
+{
+ if (m_selection.isEmpty())
+ return;
+
+ copy();
+
+ deleteSelection();
+}
+
+void TextEdit::copy()
+{
+ if (m_selection.isEmpty())
+ return;
+
+ const size_t startPos = m_selection.start().absolutePos();
+ set_clipboard_text(text().substr(startPos, m_selection.end().absolutePos() - startPos));
+}
+
+void TextEdit::paste()
+{
+ if (!m_caret.isValid())
+ m_caret = Caret(&m_lines, 0, 0); // TODO: Can we just ensure this doesn't happen?
+
+ std::string clipboard;
+ if (!get_clipboard_text(clipboard) || clipboard.empty())
+ return;
+
+ deleteSelection();
+
+#if LAF_WINDOWS
+ base::replace_string(clipboard, "\r\n", "\n");
+#endif
+
+ std::string newText = text();
+ newText.insert(m_caret.absolutePos(), clipboard);
+
+ if (!m_lines.empty() && clipboard.find('\n') == std::string::npos) {
+ auto& line = m_lines[m_caret.line()];
+ line.insertText(m_caret.pos(), clipboard);
+ line.buildBlob(this);
+ setTextQuiet(newText);
+ Change();
+ }
+ else {
+ setText(newText);
+ }
+
+ m_caret.advanceBy(clipboard.size());
+}
+
+void TextEdit::selectAll()
+{
+ if (text().empty() || m_lines.empty())
+ return;
+
+ const Caret startCaret(&m_lines);
+ Caret endCaret(startCaret);
+ endCaret.set(m_lines.size() - 1, m_lines.back().glyphCount);
+
+ m_selection.set(startCaret, endCaret);
+}
+
+bool TextEdit::onProcessMessage(Message* msg)
+{
+ switch (msg->type()) {
+ case kTimerMessage: {
+ if (hasFocus() && static_cast(msg)->timer() == s_timer.get()) {
+ m_drawCaret = !m_drawCaret;
+ invalidateRect(m_caretRect);
+ }
+ break;
+ }
+ case kFocusEnterMessage: {
+ startTimer();
+ m_drawCaret = true; // Immediately draw the caret for fast UI feedback.
+ invalidate();
+ os::System::instance()->setTranslateDeadKeys(true);
+ break;
+ }
+ case kFocusLeaveMessage: {
+ stopTimer();
+ m_drawCaret = false;
+ invalidateRect(m_caretRect);
+ os::System::instance()->setTranslateDeadKeys(false);
+ break;
+ }
+ case kKeyDownMessage: {
+ if (hasFocus() && onKeyDown(static_cast(msg))) {
+ m_drawCaret = true;
+ invalidate();
+ ensureCaretVisible();
+ return true;
+ }
+ break;
+ }
+ case kDoubleClickMessage: {
+ if (!hasFocus())
+ requestFocus();
+
+ const auto* mouseMessage = static_cast(msg);
+ Caret leftCaret = caretFromPosition(mouseMessage->position());
+ if (!leftCaret.isValid())
+ return false;
+
+ Caret rightCaret = leftCaret;
+ leftCaret.leftWord();
+ rightCaret.rightWord();
+
+ if (leftCaret != rightCaret) {
+ m_selection.set(leftCaret, rightCaret);
+ m_caret = rightCaret;
+ invalidate();
+ captureMouse();
+ return true;
+ }
+ break;
+ }
+ case kMouseDownMessage:
+ if (msg->shiftPressed())
+ m_lockedSelectionStart = m_selection.isEmpty() ? m_caret : m_selection.start();
+ else if (!hasCapture() && static_cast(msg)->left()) {
+ // Only clear the selection when we don't have a capture, to avoid stepping on double click
+ // selection.
+ m_selection.clear();
+ }
+
+ captureMouse();
+
+ stopTimer();
+ m_drawCaret = true;
+
+ [[fallthrough]]; // onMouseMove sets our caret position when we click.
+ case kMouseMoveMessage:
+ if (hasCapture() && onMouseMove(static_cast(msg))) {
+ invalidate();
+ ensureCaretVisible();
+ return true;
+ }
+ break;
+ case kMouseUpMessage: {
+ if (hasCapture()) {
+ releaseMouse();
+ startTimer();
+
+ const auto* mouseMsg = static_cast(msg);
+ if (mouseMsg->right()) {
+ showEditPopupMenu(mouseMsg->position());
+ requestFocus();
+ return true;
+ }
+
+ if (msg->shiftPressed()) {
+ m_selection.set(m_lockedSelectionStart, m_caret);
+ }
+ m_lockedSelectionStart.clear();
+ }
+ break;
+ }
+ case kMouseWheelMessage: {
+ const auto* mouseMsg = static_cast(msg);
+ auto* view = View::getView(this);
+ gfx::Point scroll = view->viewScroll();
+
+ if (mouseMsg->preciseWheel())
+ scroll += mouseMsg->wheelDelta();
+ else
+ scroll += mouseMsg->wheelDelta() * font()->height();
+
+ view->setViewScroll(scroll);
+ break;
+ }
+ }
+
+ return Widget::onProcessMessage(msg);
+}
+
+bool TextEdit::onKeyDown(const KeyMessage* keyMessage)
+{
+ const KeyScancode scancode = keyMessage->scancode();
+ const bool byWord = keyMessage->ctrlPressed();
+ const Caret prevCaret(m_caret);
+
+ switch (scancode) {
+ case kKeyLeft: m_caret.left(byWord); break;
+ case kKeyRight: m_caret.right(byWord); break;
+ case kKeyHome: m_caret.setPos(0); break;
+ case kKeyEnd: m_caret.set(m_lines.back().i, m_lines.back().glyphCount); break;
+ case kKeyUp: m_caret.up(); break;
+ case kKeyDown: m_caret.down(); break;
+ case kKeyEnter: {
+ deleteSelection();
+
+ std::string newText = text();
+ newText.insert(m_caret.absolutePos(), "\n");
+ setText(newText);
+
+ m_caret.set(m_caret.line() + 1, 0);
+ return true;
+ }
+ case kKeyBackspace: [[fallthrough]];
+ case kKeyDel: {
+ if (m_selection.isEmpty() || !m_selection.isValid()) {
+ Caret startCaret = m_caret;
+ Caret endCaret = startCaret;
+
+ if (scancode == kKeyBackspace) {
+ startCaret.left(byWord);
+ }
+ else {
+ endCaret.right(byWord);
+ }
+
+ m_selection.set(startCaret, endCaret);
+ }
+
+ deleteSelection();
+ return true;
+ }
+ default:
+ if (keyMessage->unicodeChar() >= 32) {
+ deleteSelection();
+ if (keyMessage->isDeadKey()) {
+ return true;
+ }
+
+ insertCharacter(keyMessage->unicodeChar());
+ return true;
+ }
+ if (scancode >= kKeyFirstModifierScancode) {
+ return true;
+ }
+#if defined __APPLE__
+ if (keyMessage->onlyCmdPressed())
+#else
+ if (keyMessage->onlyCtrlPressed())
+#endif
+ {
+ switch (scancode) {
+ case kKeyX: {
+ cut();
+ return true;
+ }
+ case kKeyC: {
+ copy();
+ return true;
+ }
+ case kKeyV: {
+ paste();
+ return true;
+ }
+ case kKeyA: {
+ selectAll();
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ // Selection modification
+ if (keyMessage->shiftPressed()) {
+ if (!m_selection.isValid() || m_selection.isEmpty()) {
+ m_lockedSelectionStart = prevCaret;
+ }
+
+ m_selection.set(m_lockedSelectionStart, m_caret);
+ }
+ else
+ m_selection.clear();
+
+ return true;
+}
+
+bool TextEdit::onMouseMove(const MouseMessage* mouseMessage)
+{
+ const Caret mouseCaret = caretFromPosition(mouseMessage->position());
+ if (!mouseCaret.isValid() || mouseMessage->right())
+ return false;
+
+ m_caret = mouseCaret;
+
+ if (!m_lockedSelectionStart.isValid()) {
+ m_lockedSelectionStart = m_caret;
+ return true;
+ }
+
+ m_selection.set(m_lockedSelectionStart, m_caret);
+ return true;
+}
+
+void TextEdit::onPaint(PaintEvent& ev)
+{
+ Graphics* g = ev.graphics();
+ const auto* view = View::getView(this);
+ ASSERT(view);
+ if (!view)
+ return;
+
+ const gfx::Rect rect = view->viewportBounds().offset(-bounds().origin());
+ g->fillRect(theme()->getColorById("textbox_face"), rect);
+
+ const auto& scroll = view->viewScroll();
+ gfx::PointF point(border().left(), border().top());
+ point -= scroll;
+
+ m_caretRect =
+ gfx::Rect(border().left() - scroll.x, border().top() - scroll.y, 2, font()->height());
+
+ os::Paint textPaint;
+ textPaint.color(theme()->getColorById("text"));
+ textPaint.style(os::Paint::Fill);
+
+ os::Paint selectedTextPaint;
+ selectedTextPaint.color(theme()->getColorById("selected_text"));
+ selectedTextPaint.style(os::Paint::Fill);
+
+ const gfx::Rect clipBounds = g->getClipBounds();
+
+ for (const auto& line : m_lines) {
+ const bool caretLine = (line.i == m_caret.line());
+
+ // Skip drawing lines when they're out of scroll bounds or they're outside the clip bounds,
+ // unless we're in the caret line, in which case we need to draw the text to avoid blank
+ // characters.
+ const bool skip =
+ (point.y + line.height < scroll.y || point.y > scroll.y + rect.h) ||
+ (!clipBounds.intersects(gfx::Rect(point.x, point.y, line.width, line.height)) && !caretLine);
+
+ if (!skip) {
+ g->drawTextBlob(line.blob, point, textPaint);
+
+ // Drawing the selection rect and any selected text.
+ // We're technically drawing over the old text, so ideally we want to clip that off as well?
+ const gfx::RectF selectionRect = getSelectionRect(line, point);
+ if (!selectionRect.isEmpty()) {
+ g->fillRect(theme()->getColorById("selected"), selectionRect);
+
+ const IntersectClip clip(g, selectionRect);
+ if (clip)
+ g->drawTextBlob(line.blob, point, selectedTextPaint);
+ }
+ }
+
+ // If we're in the caret's line, run this blob to grab where we should position it.
+ if (caretLine) {
+ if (m_caret.isLastInLine()) {
+ m_caretRect.x += line.width;
+ }
+ else if (m_caret.pos() > 0) {
+ m_caretRect.x += line.getBounds(m_caret.pos()).x;
+ }
+
+ m_caretRect.y = point.y;
+ m_caretRect.h = line.height; // Ensure the caret height corresponds with the tallest glyph
+ }
+
+ point.y += line.height;
+ }
+
+ if (m_drawCaret)
+ g->drawRect(theme()->getColorById("text"), m_caretRect);
+
+ m_caretRect.offset(gfx::Point(g->getInternalDeltaX(), g->getInternalDeltaY()));
+}
+
+void TextEdit::onSizeHint(SizeHintEvent& ev)
+{
+ ev.setSizeHint(m_textSize);
+}
+
+void TextEdit::onScrollRegion(ScrollRegionEvent& ev)
+{
+ invalidateRegion(ev.region());
+}
+
+gfx::RectF TextEdit::getSelectionRect(const Line& line, const gfx::PointF& offset) const
+{
+ if (m_selection.isEmpty() || !m_selection.isValid())
+ return gfx::RectF();
+
+ if (m_selection.start().line() > line.i || m_selection.end().line() < line.i)
+ return gfx::RectF();
+
+ gfx::RectF selectionRect(offset, gfx::SizeF{});
+
+ if (!line.blob) {
+ // No blob so this must be an empty line in the middle of a selection, just give it a marginal
+ // width so it's noticeable.
+ selectionRect.w = line.height / 2.0;
+ }
+ else if (
+ // Detect when this entire line is selected, to avoid doing any runs and just painting it all
+ // Case 1: Start and end line is this line, and the firstPos and endPos is 0 and the line's
+ // length.
+ (m_selection.start().line() == line.i && m_selection.end().line() == line.i &&
+ m_selection.start().pos() == 0 && m_selection.end().pos() == line.glyphCount)
+ // Case 2: We start at this line and position zero, we end in a higher line.
+ || (m_selection.start().line() == line.i && m_selection.start().pos() == 0 &&
+ m_selection.end().line() > line.i)
+ // Case 3: We started on a previous line, and we continue on another.
+ || (m_selection.start().line() < line.i && m_selection.end().line() > line.i)) {
+ selectionRect.w = line.width;
+ }
+ else if (m_selection.start().line() < line.i && m_selection.end().line() == line.i) {
+ // The selection ends in this line, starts from the leftmost side
+ const auto& lineBounds = line.getBounds(0, m_selection.end().pos());
+ selectionRect.x += lineBounds.x;
+ selectionRect.w = lineBounds.w;
+ }
+ else if (m_selection.start().line() == line.i && m_selection.end().line() == line.i) {
+ // Selection is contained within this line
+ const auto& lineBounds = line.getBounds(m_selection.start().pos(), m_selection.end().pos());
+ selectionRect.x += lineBounds.x;
+ selectionRect.w = lineBounds.w;
+ }
+ else if (m_selection.start().line() == line.i) {
+ // The selection starts in this line at an offset position, and ends at the end of the run
+ const auto& lineBounds = line.getBounds(m_selection.start().pos(),
+ m_lines[m_selection.start().line()].glyphCount);
+ selectionRect.x += lineBounds.x;
+ selectionRect.w = lineBounds.w;
+ }
+
+ selectionRect.h = line.height; // Normalize the height of the rect so it doesn't vary.
+ return selectionRect;
+}
+
+TextEdit::Caret TextEdit::caretFromPosition(const gfx::Point& position)
+{
+ const auto* view = View::getView(this);
+ if (!view)
+ return Caret();
+
+ if (m_lines.empty())
+ return Caret(&m_lines, 0, 0);
+
+ // Deduce the position the user wants to go when clicking outside of bounds
+ if (!view->viewportBounds().contains(position)) {
+ if (position.y < view->viewportBounds().y) {
+ return Caret(&m_lines, 0, 0);
+ }
+
+ if (position.y > view->viewportBounds().y2()) {
+ return Caret(&m_lines, m_lines.size() - 1, m_lines.back().glyphCount);
+ }
+
+ if (position.x > view->viewportBounds().x2()) {
+ Caret caret = m_caret;
+ caret.right();
+ return caret;
+ }
+
+ return Caret();
+ }
+
+ // Normalize the mouse position to the internal coordinates of the widget
+ gfx::PointF offsetPosition(position.x - (bounds().x + border().left()),
+ position.y - (bounds().y + border().top()));
+
+ offsetPosition += View::getView(this)->viewScroll();
+
+ Caret caret(&m_lines);
+ const int lineHeight = font()->height();
+
+ // First check if the offset position is blank (below all the lines)
+ if (offsetPosition.y > m_lines.size() * lineHeight) {
+ // Get the last character in the last line.
+ caret.setLine(m_lines.size() - 1);
+
+ // Check the line width and if we're more than halfway past the line, we can set the caret to
+ // the full line.
+ caret.setPos(
+ (offsetPosition.x > m_lines[caret.line()].width / 2) ? m_lines[caret.line()].glyphCount : 0);
+ return caret;
+ }
+
+ for (const Line& line : m_lines) {
+ const size_t lineStartY = line.i * lineHeight;
+ const size_t lineEndY = (line.i + 1) * lineHeight;
+
+ if (offsetPosition.y > lineEndY || offsetPosition.y < lineStartY)
+ continue; // We're not in this line
+
+ caret.setLine(line.i);
+
+ if (!line.blob)
+ break; // Line has no text, we can end it here.
+
+ if (offsetPosition.x > line.width) {
+ // Clicking on the blank space next to a line should put our caret at the end of it.
+ caret.setPos(line.glyphCount);
+ break;
+ }
+
+ // Find the exact character we're standing on, with a slight bias to the left or right
+ // depending on where we click wrt the glyph bounds
+ size_t advance = 0;
+ bool found = false;
+
+ line.blob->visitRuns([&](const text::TextBlob::RunInfo& run) {
+ if (found) {
+ return;
+ }
+
+ for (int i = 0; i < run.glyphCount; ++i) {
+ gfx::RectF glyphBounds = run.getGlyphBounds(i).offset(gfx::PointF(0, lineStartY));
+
+ if (glyphBounds.contains(offsetPosition)) {
+ found = true;
+
+ if (offsetPosition.x > glyphBounds.center().x && advance != line.glyphCount)
+ ++advance; // If the mouse is to the right of the glyph, prefer the next position.
+
+ return;
+ }
+
+ ++advance;
+ }
+ });
+
+ if (found) {
+ caret.setPos(advance);
+ }
+ }
+
+ return caret;
+}
+
+void TextEdit::showEditPopupMenu(const gfx::Point& position)
+{
+ auto* translate = UISystem::instance()->translationDelegate();
+ ASSERT(translate); // We provide UISystem as default translation delegate
+ if (!translate)
+ return;
+
+ Menu menu;
+ MenuItem cut(translate->cut());
+ MenuItem copy(translate->copy());
+ MenuItem paste(translate->paste());
+ MenuItem selectAll(translate->selectAll());
+
+ cut.processMnemonicFromText();
+ copy.processMnemonicFromText();
+ paste.processMnemonicFromText();
+ selectAll.processMnemonicFromText();
+
+ menu.addChild(&cut);
+ menu.addChild(©);
+ menu.addChild(&paste);
+ menu.addChild(new MenuSeparator);
+ menu.addChild(&selectAll);
+
+ cut.setEnabled(!m_selection.isEmpty());
+ copy.setEnabled(!m_selection.isEmpty());
+
+ cut.Click.connect(&TextEdit::cut, this);
+ copy.Click.connect(&TextEdit::copy, this);
+ paste.Click.connect(&TextEdit::paste, this);
+ selectAll.Click.connect(&TextEdit::selectAll, this);
+
+ menu.showPopup(position, display());
+}
+
+void TextEdit::insertCharacter(base::codepoint_t character)
+{
+ const std::string unicodeStr = base::codepoint_to_utf8(character);
+
+ if (m_lines.empty()) {
+ // Fast path for the first char.
+ setText(unicodeStr);
+ m_caret.setPos(m_caret.pos() + 1);
+ return;
+ }
+
+ auto& line = m_lines[m_caret.line()];
+ line.insertText(m_caret.pos(), unicodeStr);
+ line.buildBlob(this);
+
+ std::string newText = text();
+ newText.insert(m_caret.absolutePos(), unicodeStr);
+ setTextQuiet(newText);
+ Change();
+
+ m_caret.setPos(m_caret.pos() + 1);
+}
+
+void TextEdit::deleteSelection()
+{
+ if (m_selection.isEmpty() || !m_selection.isValid())
+ return;
+
+ std::string newText = text();
+ newText.erase(newText.begin() + m_selection.start().absolutePos(),
+ newText.begin() + m_selection.end().absolutePos());
+
+ if (m_selection.start().line() == m_selection.end().line()) {
+ auto& line = m_lines[m_selection.start().line()];
+ size_t end;
+ if (m_selection.end().isLastInLine())
+ end = line.utfSize.back().end;
+ else
+ end = line.utfSize[m_selection.end().pos()].begin;
+
+ line.text.erase(line.text.begin() + line.utfSize[m_selection.start().pos()].begin,
+ line.text.begin() + end);
+ line.buildBlob(this);
+
+ // Only rebuilds the one line
+ setTextQuiet(newText);
+ Change();
+ }
+ else {
+ setText(newText);
+ }
+
+ m_caret = m_selection.start();
+ m_selection.clear();
+}
+
+void TextEdit::ensureCaretVisible()
+{
+ auto* view = View::getView(this);
+ if (!view || !view->hasScrollBars() || !m_caret.isValid())
+ return;
+
+ const int scrollBarWidth = theme()->getScrollbarSize();
+
+ if (view->viewportBounds().shrink(scrollBarWidth).intersects(m_caretRect))
+ return; // We are visible and don't need to do anything.
+
+ const int lineHeight = font()->height();
+ gfx::Point scroll = view->viewScroll();
+ const gfx::Size visibleBounds = view->viewportBounds().size();
+
+ if (view->verticalBar()->isVisible()) {
+ const int heightLimit = (visibleBounds.h + scroll.y - lineHeight) / 2;
+ const size_t currentLine = (m_caret.line() * lineHeight) / 2;
+
+ if (currentLine <= scroll.y)
+ scroll.y = currentLine;
+ else if (currentLine >= heightLimit) // TODO: I do not like this
+ scroll.y = currentLine - ((visibleBounds.h - (lineHeight * 2)) / 2);
+ }
+
+ const auto& line = m_lines[m_caret.line()];
+ if (view->horizontalBar()->isVisible() && line.blob && line.width > visibleBounds.w) {
+ const int caretX = line.getBounds(0, m_caret.pos()).w;
+ const int horizontalLimit = scroll.x + visibleBounds.w - view->horizontalBar()->getBarWidth();
+
+ if (m_caret.pos() == 0)
+ scroll.x = 0;
+ else if (caretX > horizontalLimit)
+ scroll.x = caretX - horizontalLimit;
+ else if (scroll.x > caretX / 2) // TODO: Something's a bit bouncy here (in a bad way)
+ scroll.x = caretX / 2;
+ }
+
+ view->setViewScroll(scroll);
+}
+
+void TextEdit::onSetText()
+{
+ std::vector newLines;
+ newLines.reserve(m_lines.size()); // Assume lines will be around the same size as before, if any
+
+ // Recalculate all the lines based on the widget's text
+ m_lines.clear();
+
+ base::split_string(text(), newLines, "\n");
+ m_lines.reserve(newLines.size());
+
+ int longestWidth = 0;
+ int totalHeight = 0;
+
+ for (const auto& lineString : newLines) {
+ Line newLine;
+ newLine.text = lineString;
+ newLine.buildBlob(this);
+
+ longestWidth = std::max(newLine.width, longestWidth);
+ totalHeight += newLine.height;
+
+ newLine.i = m_lines.size();
+ m_lines.push_back(newLine);
+ }
+
+ m_textSize.w = longestWidth;
+ m_textSize.h = totalHeight;
+
+ if (auto* view = View::getView(this))
+ view->updateView();
+
+ Change();
+ Widget::onSetText();
+}
+
+void TextEdit::startTimer()
+{
+ if (s_timer)
+ s_timer->stop();
+ s_timer = std::make_unique(500, this);
+ s_timer->start();
+}
+
+void TextEdit::stopTimer()
+{
+ if (s_timer) {
+ s_timer->stop();
+ s_timer.reset();
+ }
+}
+
+} // namespace ui
diff --git a/src/ui/textedit.h b/src/ui/textedit.h
new file mode 100644
index 000000000..6687c200e
--- /dev/null
+++ b/src/ui/textedit.h
@@ -0,0 +1,456 @@
+// Aseprite
+// Copyright (C) 2024 Igara Studio S.A.
+// Copyright (C) 2001-2018 David Capello
+//
+// This program is distributed under the terms of
+// the End-User License Agreement for Aseprite.
+
+#ifndef UI_TEXT_EDIT_H_INCLUDED
+#define UI_TEXT_EDIT_H_INCLUDED
+#pragma once
+
+#include "text/font_mgr.h"
+#include "text/text_blob.h"
+#include "ui/box.h"
+#include "ui/theme.h"
+#include "ui/view.h"
+
+namespace ui {
+using namespace text;
+
+class TextEdit : public Widget,
+ public ViewableWidget {
+public:
+ TextEdit();
+
+ void cut();
+ void copy();
+ void paste();
+ void selectAll();
+
+ obs::signal Change;
+
+protected:
+ bool onProcessMessage(Message* msg) override;
+ void onPaint(PaintEvent& ev) override;
+ void onSizeHint(SizeHintEvent& ev) override;
+ void onScrollRegion(ScrollRegionEvent& ev) override;
+ void onSetText() override;
+
+ bool onKeyDown(const KeyMessage* keyMessage);
+ bool onMouseMove(const MouseMessage* mouseMessage);
+
+private:
+ struct Utf8RangeBuilder : public text::TextBlob::RunHandler {
+ explicit Utf8RangeBuilder(size_t minSize) { ranges.reserve(minSize); }
+
+ void commitRunBuffer(TextBlob::RunInfo& info) override
+ {
+ ASSERT(info.clusters == nullptr || *info.clusters == 0);
+ for (size_t i = 0; i < info.glyphCount; ++i) {
+ ranges.push_back(info.getGlyphUtf8Range(i));
+ }
+ }
+
+ std::vector ranges;
+ };
+
+ struct Line {
+ std::string text;
+ std::vector utfSize;
+ size_t glyphCount = 0;
+ text::TextBlobRef blob;
+
+ int width = 0;
+ int height = 0;
+
+ // Line index for more convenient loops
+ size_t i = 0;
+
+ void buildBlob(const Widget* forWidget)
+ {
+ utfSize.clear();
+
+ if (text.empty()) {
+ blob = nullptr;
+ width = 0;
+ glyphCount = 0;
+ height = forWidget->font()->metrics(nullptr);
+ return;
+ }
+
+ Utf8RangeBuilder rangeBuilder(text.size());
+ blob = text::TextBlob::MakeWithShaper(forWidget->theme()->fontMgr(),
+ forWidget->font(),
+ text,
+ &rangeBuilder);
+
+ utfSize = std::move(rangeBuilder.ranges);
+ glyphCount = utfSize.size();
+
+ width = blob->bounds().w;
+ height = std::max(blob->bounds().h, forWidget->font()->metrics(nullptr));
+ }
+
+ // Insert text into this line based on a caret position, taking into account utf8 size.
+ void insertText(size_t pos, const std::string& str)
+ {
+ if (pos == 0)
+ text.insert(0, str);
+ else if (pos == glyphCount)
+ text.append(str);
+ else
+ text.insert(utfSize[pos - 1].end, str);
+ }
+
+ gfx::Rect getBounds(size_t glyph) const
+ {
+ size_t advance = 0;
+ gfx::Rect result;
+ blob->visitRuns([&](const text::TextBlob::RunInfo& run) {
+ for (size_t i = 0; i < run.glyphCount; ++i) {
+ if (advance == glyph) {
+ result = run.getGlyphBounds(i);
+ return;
+ }
+ ++advance;
+ }
+ });
+ return result;
+ }
+
+ // Get the screen size between the start and end glyph positions.
+ gfx::Rect getBounds(size_t startGlyph, size_t endGlyph) const
+ {
+ if (startGlyph == endGlyph)
+ return getBounds(startGlyph);
+
+ ASSERT(endGlyph > startGlyph);
+
+ size_t advance = 0; // The amount of glyphs we've advanced through.
+ gfx::Rect resultBounds;
+
+ blob->visitRuns([&](text::TextBlob::RunInfo& run) {
+ if (advance >= endGlyph)
+ return;
+
+ if (startGlyph > (advance + run.glyphCount)) {
+ advance += run.glyphCount;
+ return; // Skip this run
+ }
+
+ size_t j = 0;
+ if (advance < startGlyph) {
+ j = startGlyph - advance;
+ advance += j;
+ }
+
+ for (; j < run.glyphCount; ++j) {
+ ++advance;
+ resultBounds |= run.getGlyphBounds(j);
+
+ if (advance >= endGlyph)
+ return;
+ }
+ });
+
+ ASSERT(advance == endGlyph);
+
+ return resultBounds;
+ }
+ };
+
+ struct Caret {
+ explicit Caret(std::vector* lines = nullptr) : m_lines(lines) {}
+ explicit Caret(std::vector* lines, size_t line, size_t pos)
+ : m_line(line)
+ , m_pos(pos)
+ , m_lines(lines)
+ {
+ }
+ Caret(const Caret& caret) : m_line(caret.m_line), m_pos(caret.m_pos), m_lines(caret.m_lines) {}
+
+ size_t line() const { return m_line; }
+
+ size_t pos() const { return m_pos; }
+
+ void setPos(size_t pos)
+ {
+ ASSERT(pos >= 0 && pos <= lineObj().glyphCount);
+ m_pos = pos;
+ }
+
+ void setLine(size_t line) { m_line = line; }
+
+ void set(size_t line, size_t pos)
+ {
+ m_line = line;
+ m_pos = pos;
+ }
+
+ bool left(bool byWord = false)
+ {
+ if (byWord)
+ return leftWord();
+
+ m_pos -= 1;
+
+ if (((int64_t)(m_pos)-1) < 0) {
+ if (m_line == 0) {
+ m_pos = 0;
+ return false;
+ }
+
+ m_line -= 1;
+ m_pos = lineObj().glyphCount;
+ }
+
+ return true;
+ }
+
+ // Moves the position to the next word on the left, doesn't wrap around lines.
+ bool leftWord()
+ {
+ if (m_pos == 0)
+ return false;
+
+ auto startPos = m_pos;
+ while (isWordPart(m_pos)) {
+ if (!left())
+ return m_pos != startPos;
+ }
+ return true;
+ }
+
+ bool right(bool byWord = false)
+ {
+ if (byWord)
+ return rightWord();
+
+ m_pos += 1;
+
+ if (m_pos > lineObj().glyphCount) {
+ if (m_line == m_lines->size() - 1) {
+ m_pos -= 1; // Undo movement, we've reached the end of the text.
+ return false;
+ }
+
+ m_line += 1;
+ m_pos = 0;
+ }
+
+ return true;
+ }
+
+ // Moves the position to the next word on the right, doesn't wrap around lines.
+ bool rightWord()
+ {
+ if (m_pos == lineObj().glyphCount)
+ return false;
+
+ auto startPos = m_pos;
+ while (isWordPart(m_pos)) {
+ if (!right())
+ return m_pos != startPos;
+ }
+ return true;
+ }
+
+ void up()
+ {
+ m_line = std::clamp(m_line - 1, size_t(0), m_lines->size() - 1);
+ m_pos = std::clamp(m_pos, size_t(0), lineObj().glyphCount);
+ }
+
+ void down()
+ {
+ m_line = std::clamp(m_line + 1, size_t(0), m_lines->size() - 1);
+ m_pos = std::clamp(m_pos, size_t(0), lineObj().glyphCount);
+ }
+
+ bool isLastInLine() const { return m_pos == lineObj().glyphCount; }
+
+ bool isLastLine() const { return m_line == m_lines->size() - 1; }
+
+ // Returns the absolute position of the caret, aka the position in the main string that has all
+ // the newlines.
+ size_t absolutePos() const
+ {
+ if (m_pos == 0 && m_line == 0)
+ return 0;
+
+ size_t apos = 0;
+ for (const auto& l : *m_lines) {
+ const bool hasNextLine = l.i < (m_lines->size() - 1);
+
+ if (l.i == m_line) {
+ if (l.text.empty() || m_pos == 0)
+ return apos;
+
+ if (m_pos >= l.utfSize.size())
+ apos += l.utfSize.back().end;
+ else if (m_pos > l.utfSize.size())
+ apos += l.utfSize.back().end + (hasNextLine ? 1 : 0);
+ else
+ apos += l.utfSize[m_pos].begin;
+ return apos;
+ }
+
+ if (!l.text.empty() && !l.utfSize.empty())
+ apos += l.utfSize.back().end;
+
+ if (hasNextLine)
+ apos += 1; // Newline glyph.
+ }
+ return apos;
+ }
+
+ bool isWordPart(size_t pos) const
+ {
+ if (!lineObj().glyphCount || lineObj().utfSize.size() <= pos)
+ return false;
+
+ const auto& utfPos = lineObj().utfSize[pos];
+ const std::string_view word = text().substr(utfPos.begin, utfPos.end - utfPos.begin);
+ return (!word.empty() && std::isspace(word[0]) == 0 && std::ispunct(word[0]) == 0);
+ }
+
+ void advanceBy(size_t characters)
+ {
+ size_t remaining = characters;
+ size_t activeLine = m_line;
+ for (size_t i = m_line; i < m_lines->size(); ++i) {
+ const auto& line = (*m_lines)[i];
+ for (size_t j = m_pos; j < line.glyphCount; ++j) {
+ remaining -= line.utfSize[j].end - line.utfSize[j].begin;
+ right();
+
+ if (remaining <= 0)
+ return;
+
+ if (m_line != activeLine) {
+ activeLine = m_line;
+ break;
+ }
+ }
+ }
+ }
+
+ bool isValid() const
+ {
+ if (m_lines == nullptr)
+ return false;
+
+ if (m_line >= m_lines->size())
+ return false;
+
+ if (m_pos > lineObj().glyphCount)
+ return false;
+
+ return true;
+ }
+
+ void clear()
+ {
+ m_lines = nullptr;
+ m_line = 0;
+ m_pos = 0;
+ }
+
+ bool operator==(const Caret& other) const
+ {
+ return m_line == other.m_line && m_pos == other.m_pos;
+ }
+
+ bool operator!=(const Caret& other) const
+ {
+ return m_line != other.m_line || m_pos != other.m_pos;
+ }
+
+ bool operator>(const Caret& other) const
+ {
+ return (m_line == other.m_line) ? m_pos > other.m_pos :
+ (m_line + m_pos) > (other.m_line + m_pos);
+ }
+
+ private:
+ size_t m_line = 0;
+ size_t m_pos = 0;
+ std::string_view text() const { return (*m_lines)[m_line].text; }
+ Line& lineObj() const { return (*m_lines)[m_line]; }
+ std::vector* m_lines;
+ };
+
+ struct Selection {
+ Selection() = default;
+ Selection(const Caret& startCaret, const Caret& endCaret) { set(startCaret, endCaret); }
+
+ bool isEmpty() const
+ {
+ return (m_start.line() == m_end.line() && m_start.pos() == m_end.pos());
+ }
+
+ void setStart(const Caret& caret) { m_start = caret; }
+
+ void setEnd(const Caret& caret) { m_end = caret; }
+
+ void set(const Caret& startCaret, const Caret& endCaret)
+ {
+ if (startCaret > endCaret) {
+ m_start = endCaret;
+ m_end = startCaret;
+ }
+ else {
+ m_start = startCaret;
+ m_end = endCaret;
+ }
+ }
+
+ const Caret& start() const { return m_start; }
+
+ const Caret& end() const { return m_end; }
+
+ bool isValid() const { return m_start.isValid() && m_end.isValid(); }
+
+ void clear()
+ {
+ m_start.clear();
+ m_end.clear();
+ }
+
+ private:
+ Caret m_start;
+ Caret m_end;
+ };
+
+ // Get the selection rect for the given line, if any
+ gfx::RectF getSelectionRect(const Line& line, const gfx::PointF& offset) const;
+ Caret caretFromPosition(const gfx::Point& position);
+ void showEditPopupMenu(const gfx::Point& position);
+ void insertCharacter(base::codepoint_t character);
+ void deleteSelection();
+ void ensureCaretVisible();
+
+ void startTimer();
+ void stopTimer();
+
+ Selection m_selection;
+ Caret m_caret;
+ Caret m_lockedSelectionStart;
+
+ std::vector m_lines;
+
+ // Whether or not we're currently drawing the caret, driven by a timer.
+ bool m_drawCaret = false;
+
+ // The last position the caret was drawn, to invalidate that region when repainting.
+ gfx::Rect m_caretRect;
+
+ // The total size of the complete text, calculated as the longest single line width and the sum of
+ // the total line heights
+ gfx::Size m_textSize;
+};
+
+} // namespace ui
+
+#endif
diff --git a/src/ui/theme.h b/src/ui/theme.h
index 18eff1e16..00ee076d9 100644
--- a/src/ui/theme.h
+++ b/src/ui/theme.h
@@ -75,6 +75,7 @@ public:
virtual gfx::Size getEntryCaretSize(Widget* widget) { return gfx::Size(kDefaultFontHeight, 1); }
virtual void paintEntry(PaintEvent& ev) {}
+ virtual void paintTextEdit(PaintEvent& ev) {}
virtual void paintListBox(PaintEvent& ev);
virtual void paintMenu(PaintEvent& ev) {}
virtual void paintMenuItem(PaintEvent& ev) {}
@@ -126,6 +127,7 @@ public:
virtual gfx::Color calcBgColor(const Widget* widget, const Style* style);
virtual gfx::Size calcMinSize(const Widget* widget, const Style* style);
virtual gfx::Size calcMaxSize(const Widget* widget, const Style* style);
+ virtual gfx::Color getColorById(const std::string& id) const { return gfx::ColorNone; };
static void drawSlices(Graphics* g,
os::Surface* sheet,
diff --git a/src/ui/widget.cpp b/src/ui/widget.cpp
index 70ca0c6cd..f9b7553aa 100644
--- a/src/ui/widget.cpp
+++ b/src/ui/widget.cpp
@@ -141,6 +141,9 @@ double Widget::textDouble() const
void Widget::setText(const std::string& text)
{
+ if (text == this->text())
+ return;
+
setTextQuiet(text);
onSetText();
}