Compare commits

...

12 Commits

Author SHA1 Message Date
David Capello 49b01c2565
Merge 2c9a2998df into 6c7544a132 2025-10-06 21:28:00 +00:00
David Capello 2c9a2998df Fix TextEdit::caretFromPosition() when lines have different heights 2025-10-06 18:27:34 -03:00
David Capello c089bb87ff Invalidate all blobs after changing theme
We've moved the InitTheme code from the InitTheme.connect() signal to
the onInitTheme() member function.
2025-10-06 16:40:50 -03:00
David Capello f9abfbe87b Implement "selection by words" after double-clicking
This is the same behavior as in ui::Entry widget.
2025-10-06 16:30:03 -03:00
David Capello 0068819580 Improve caret/char selection with the mouse 2025-10-06 15:46:44 -03:00
David Capello 73fbf16d31 Fix caret position when a line has multiple runs (e.g. there's an emoji present) 2025-10-06 15:32:45 -03:00
David Capello c1327bf798 Jump between words correctly with Ctrl+Left/Right 2025-10-06 14:42:49 -03:00
David Capello 1a70e8a289 Change size_t to int for position/line numbers 2025-10-06 14:39:51 -03:00
David Capello 31557a9e7b Fix End key to go to EOL and added Ctrl+Home/End to go to BOF/EOF 2025-10-06 12:37:31 -03:00
David Capello c7868641fb Enter from the numeric keypad can be used for new lines too 2025-10-06 12:28:46 -03:00
David Capello 7ca4cb8129 We cannot bypass onSetText() as ui::Entry requires this
Without this fix, "Run Command" will crash.
2025-10-06 12:26:05 -03:00
Christian Kaiser e24fabb38b User data with multiple lines of text (new TextEdit widget) (#3130, #3131, #4743, #4968) 2025-10-06 11:54:37 -03:00
16 changed files with 1370 additions and 18 deletions

View File

@ -1046,6 +1046,12 @@
<text color="slider_empty_text" align="center middle"/>
<text color="slider_empty_text" align="center middle" state="focus" y="1"/>
</style>
<style id="textedit" border="2">
<background color="textbox_face" />
<text color="textbox_text" align="left" />
<text color="selected" align="left" state="selected" />
<text color="selected_text" align="left" state="selected" />
</style>
<style id="mini_slider" extends="slider" font="mini">
<background part="mini_slider_empty"/>
<text color="slider_empty_text" align="center middle"/>

View File

@ -4,8 +4,8 @@
<gui>
<window id="layer_properties" text="@.title">
<vbox>
<grid id="properties_grid" columns="3">
<label text="@.name" for="name" />
<grid id="properties_grid" columns="3" expansive="true">
<label text="@.name" />
<entry text="" id="name" magnet="true" maxsize="256" minwidth="64" cell_align="horizontal" />
<button id="user_data" icon="icon_user_data" tooltip="@general.user_data" />

View File

@ -6,6 +6,8 @@
<label id="color_label" text="@.color" for="color" />
<label id="entry_label" text="@.user_data" for="entry" />
<colorpicker id="color" simple="true" expansive="true" />
<entry id="entry" maxsize="65535" minwidth="128" expansive="true" />
<view id="text_edit_view" height="30" expansive="true">
<textedit id="text_edit" />
</view>
</hbox>
</gui>

View File

@ -344,7 +344,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();

View File

@ -465,7 +465,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());

View File

@ -1233,6 +1233,8 @@ void SkinTheme::initWidget(Widget* widget)
widget->setStyle(styles.textboxText());
break;
case kTextEditWidget: widget->setStyle(styles.textedit()); break;
case kViewWidget:
widget->setChildSpacing(0);
widget->setBgColor(colors.windowFace());

View File

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

View File

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

View File

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

View File

@ -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"
@ -259,13 +260,16 @@ 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 bool buttononly = bool_attr(elem, "buttononly", false);
const app::FilenameField::Type type = (buttononly ? app::FilenameField::Type::ButtonOnly :
app::FilenameField::Type::EntryAndButton);
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);

View File

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

View File

@ -50,6 +50,7 @@ add_library(ui-lib
style.cpp
system.cpp
textbox.cpp
textedit.cpp
theme.cpp
timer.cpp
tooltips.cpp

1125
src/ui/textedit.cpp Normal file

File diff suppressed because it is too large Load Diff

208
src/ui/textedit.h Normal file
View File

@ -0,0 +1,208 @@
// Aseprite
// Copyright (C) 2024-2025 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"
#include <algorithm>
namespace ui {
using namespace text;
class TextEdit : public Widget,
public ViewableWidget {
public:
TextEdit();
void cut();
void copy();
void paste();
void selectAll();
obs::signal<void()> Change;
protected:
bool onProcessMessage(Message* msg) override;
void onPaint(PaintEvent& ev) override;
void onInitTheme(InitThemeEvent& ev) override;
void onSizeHint(SizeHintEvent& ev) override;
void onScrollRegion(ScrollRegionEvent& ev) override;
void onSetText() override;
void onSetFont() override;
bool onKeyDown(const KeyMessage* keyMessage);
bool onMouseMove(const MouseMessage* mouseMessage);
private:
struct Line {
std::string text;
std::vector<TextBlob::Utf8Range> utfSize;
int glyphCount = 0;
text::TextBlobRef blob;
int width = 0;
int height = 0;
// Line index for more convenient loops
int i = 0;
void buildBlob(const Widget* forWidget);
// Insert text into this line based on a caret position, taking into account utf8 size.
void insertText(int pos, const std::string& str);
gfx::Rect getBounds(int glyph) const;
// Get the screen size between the start and end glyph positions.
gfx::Rect getBounds(int startGlyph, int endGlyph) const;
};
struct Caret {
explicit Caret(std::vector<Line>* lines = nullptr) : m_lines(lines) {}
explicit Caret(std::vector<Line>* lines, int line, int 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) {}
int line() const { return m_line; }
int pos() const { return m_pos; }
void setPos(int pos);
void setLine(int line) { m_line = line; }
void set(int line, int pos);
bool left(bool byWord = false);
// Moves the position to the next word on the left, doesn't wrap around lines.
bool leftWord();
bool right(bool byWord = false);
// Moves the position to the next word on the right, doesn't wrap around lines.
bool rightWord();
void up();
void down();
bool isLastInLine() const { return m_pos == lineObj().glyphCount; }
bool isLastLine() const { return m_line == m_lines->size() - 1; }
// Go to the end of line.
void eol();
// Returns the absolute position of the caret, aka the position in the main string that has all
// the newlines.
int absolutePos() const;
bool isWordPart() const;
void advanceBy(int characters);
bool isValid() const;
void clear();
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
{
if (m_line < other.m_line)
return true;
if (m_line > other.m_line)
return false;
return m_pos < other.m_pos;
}
private:
int m_line = 0;
int m_pos = 0;
std::string_view text() const { return (*m_lines)[m_line].text; }
Line& lineObj() const { return (*m_lines)[m_line]; }
std::vector<Line>* m_lines;
};
struct Selection {
Selection() = default;
Selection(const Caret& startCaret, const Caret& endCaret) { set(startCaret, endCaret); }
static Selection SelectWords(const Caret& from);
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);
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();
Selection& operator|=(const Selection& other)
{
m_start = std::min(m_start, other.start());
m_end = std::max(m_end, other.end());
return *this;
}
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();
int maxHeight() const;
void startTimer();
void stopTimer();
Selection m_selection;
Selection m_selectionWords;
Caret m_caret;
Caret m_lockedSelectionStart;
std::vector<Line> 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;
// Color cache
gfx::Color m_colorBG;
gfx::Color m_colorSelected;
os::Paint m_textPaint;
os::Paint m_selectedTextPaint;
};
} // namespace ui
#endif

View File

@ -77,6 +77,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) {}

View File

@ -38,6 +38,7 @@ enum WidgetType : int {
kSliderWidget,
kSplitterWidget,
kTextBoxWidget,
kTextEditWidget,
kViewScrollbarWidget,
kViewViewportWidget,
kViewWidget,