aseprite/src/app/ui/editor/writing_text_state.cpp

604 lines
16 KiB
C++

// Aseprite
// Copyright (C) 2022-2025 Igara Studio S.A.
//
// This program is distributed under the terms of
// the End-User License Agreement for Aseprite.
#ifdef HAVE_CONFIG_H
#include "config.h"
#endif
#include "app/ui/editor/writing_text_state.h"
#include "app/app.h"
#include "app/color_utils.h"
#include "app/commands/command.h"
#include "app/extra_cel.h"
#include "app/fonts/font_info.h"
#include "app/i18n/strings.h"
#include "app/pref/preferences.h"
#include "app/site.h"
#include "app/tx.h"
#include "app/ui/context_bar.h"
#include "app/ui/editor/editor.h"
#include "app/ui/skin/skin_theme.h"
#include "app/ui/status_bar.h"
#include "app/ui_context.h"
#include "app/util/expand_cel_canvas.h"
#include "app/util/render_text.h"
#include "doc/blend_image.h"
#include "doc/blend_internals.h"
#include "doc/layer.h"
#include "render/dithering.h"
#include "render/quantization.h"
#include "render/render.h"
#include "text/font_metrics.h"
#include "ui/entry.h"
#include "ui/message.h"
#include "ui/paint_event.h"
#include "ui/system.h"
#ifdef LAF_SKIA
#include "app/util/shader_helpers.h"
#include "os/skia/skia_helpers.h"
#include "os/skia/skia_surface.h"
#endif
#include <cmath>
namespace app {
using namespace ui;
// Get ui::Paint to render text from context bar options / preferences
static ui::Paint get_paint_for_text()
{
ui::Paint paint;
if (auto* app = App::instance()) {
if (auto* ctxBar = app->contextBar())
paint = ctxBar->fontEntry()->paint();
}
paint.color(color_utils::color_for_ui(Preferences::instance().colorBar.fgColor()));
return paint;
}
static gfx::RectF calc_blob_bounds(const text::TextBlobRef& blob)
{
gfx::RectF bounds = get_text_blob_required_bounds(blob);
ui::Paint paint = get_paint_for_text();
if (paint.style() == ui::Paint::Style::Stroke ||
paint.style() == ui::Paint::Style::StrokeAndFill) {
bounds.enlarge(std::ceil(paint.strokeWidth()));
}
return bounds;
}
class WritingTextState::TextEditor : public Entry {
public:
enum TextPreview {
Intermediate, // With selection preview / user interface
Final, // Final to be rendered in the cel
};
TextEditor(Editor* editor, const Site& site, const gfx::Rect& bounds)
: Entry(4096, "")
, m_editor(editor)
, m_doc(site.document())
, m_extraCel(new ExtraCel)
{
// We have to draw the editor as background of this ui::Entry.
setTransparent(true);
setPersistSelection(true);
createExtraCel(site, bounds);
renderExtraCel(TextPreview::Intermediate);
FontInfo fontInfo = App::instance()->contextBar()->fontInfo();
if (auto font = Fonts::instance()->fontFromInfo(fontInfo))
setFont(font);
}
~TextEditor()
{
m_doc->setExtraCel(ExtraCelRef(nullptr));
m_doc->generateMaskBoundaries();
}
// Returns the extra cel with the text rendered (but without the
// selected text highlighted).
ExtraCelRef extraCel(const TextPreview textPreview)
{
renderExtraCel(textPreview);
return m_extraCel;
}
void setExtraCelBounds(const gfx::RectF& bounds)
{
doc::Image* extraImg = m_extraCel->image();
if (!extraImg || std::ceil(bounds.w) != extraImg->width() ||
std::ceil(bounds.h) != extraImg->height()) {
createExtraCel(m_editor->getSite(), bounds);
}
else {
m_baseBounds = bounds;
m_extraCel->cel()->setBounds(bounds);
}
renderExtraCel(TextPreview::Intermediate);
}
obs::signal<void(const gfx::RectF&)> NewRequiredBounds;
private:
void createExtraCel(const Site& site, const gfx::Rect& bounds)
{
m_baseBounds = bounds;
m_extraCel->create(ExtraCel::Purpose::TextPreview,
site.tilemapMode(),
site.sprite(),
bounds,
gfx::Size(std::ceil(bounds.w), std::ceil(bounds.h)),
site.frame(),
255);
m_extraCel->setType(render::ExtraType::PATCH);
m_extraCel->setBlendMode(site.layer()->isImage() ?
static_cast<LayerImage*>(site.layer())->blendMode() :
doc::BlendMode::NORMAL);
}
bool onProcessMessage(Message* msg) override
{
switch (msg->type()) {
case kMouseDownMessage:
case kMouseMoveMessage: {
auto* mouseMsg = static_cast<MouseMessage*>(msg);
// Ignore middle mouse button so we can scroll with it.
if (mouseMsg->middle()) {
auto* parent = this->parent();
MouseMessage mouseMsg2(kMouseDownMessage, *mouseMsg, mouseMsg->position());
mouseMsg2.setRecipient(parent);
parent->sendMessage(&mouseMsg2);
return true;
}
break;
}
}
return Entry::onProcessMessage(msg);
}
float onGetTextBaseline() const override
{
text::FontMetrics metrics;
font()->metrics(&metrics);
return scale().y * -metrics.ascent;
}
void onInitTheme(InitThemeEvent& ev) override
{
Entry::onInitTheme(ev);
setBgColor(gfx::ColorNone);
}
void onSetText() override
{
Entry::onSetText();
onNewTextBlob();
}
void onSetFont() override
{
Entry::onSetFont();
onNewTextBlob();
}
text::ShaperFeatures onGetTextShaperFeatures() const override
{
const FontInfo fontInfo = App::instance()->contextBar()->fontInfo();
text::ShaperFeatures features;
features.ligatures = fontInfo.ligatures();
return features;
}
void onNewTextBlob()
{
text::TextBlobRef blob = textBlob();
if (!blob)
return;
// Notify that we could make the text editor bigger to show this
// text blob.
NewRequiredBounds(calc_blob_bounds(blob));
}
void onPaint(PaintEvent& ev) override
{
Graphics* g = ev.graphics();
// Don't paint the base Entry borders
// Entry::onPaint(ev);
if (!hasText())
return;
// Paint border
{
ui::Paint paint;
paint.style(ui::Paint::Stroke);
set_checkered_paint_mode(paint, 0, gfx::rgba(0, 0, 0, 255), gfx::rgba(255, 255, 255, 255));
g->drawRect(clientBounds(), paint);
}
try {
if (!textBlob()) {
m_doc->setExtraCel(nullptr);
m_editor->invalidate();
return;
}
// Render extra cel with text + selected text
renderExtraCel(TextPreview::Intermediate);
m_doc->setExtraCel(m_extraCel);
// Paint caret
if (isCaretVisible()) {
int scroll, caret;
getEntryThemeInfo(&scroll, &caret, nullptr, nullptr);
gfx::RectF caretBounds = getCharBoxBounds(caret);
caretBounds *= gfx::SizeF(scale());
caretBounds.w = 1;
g->fillRect(gfx::rgba(0, 0, 0), caretBounds);
}
m_editor->invalidate();
}
catch (const std::exception& e) {
StatusBar::instance()->showTip(500, std::string(e.what()));
}
}
void renderExtraCel(const TextPreview textPreview)
{
doc::Image* extraImg = m_extraCel->image();
ASSERT(extraImg);
if (!extraImg)
return;
extraImg->clear(extraImg->maskColor());
text::TextBlobRef blob = textBlob();
doc::ImageRef blobImage;
gfx::RectF bounds;
if (blob) {
const ui::Paint paint = get_paint_for_text();
bounds = calc_blob_bounds(blob);
blobImage = render_text_blob(blob, bounds, get_paint_for_text());
if (!blobImage)
return;
// Invert selected range in the image
if (textPreview == TextPreview::Intermediate) {
Range range;
getEntryThemeInfo(nullptr, nullptr, nullptr, &range);
if (!range.isEmpty()) {
gfx::RectF selectedBounds = getCharBoxBounds(range.from) | getCharBoxBounds(range.to - 1);
if (!selectedBounds.isEmpty()) {
selectedBounds.offset(-bounds.origin());
#ifdef LAF_SKIA
sk_sp<SkSurface> skSurface = wrap_docimage_in_sksurface(blobImage.get());
os::SurfaceRef surface = base::make_ref<os::SkiaSurface>(skSurface);
os::Paint paint2 = paint;
paint2.blendMode(os::BlendMode::Xor);
paint2.style(os::Paint::Style::Fill);
surface->drawRect(selectedBounds, paint2);
#endif // LAF_SKIA
}
}
}
}
doc::Cel* extraCel = m_extraCel->cel();
ASSERT(extraCel);
if (!extraCel)
return;
extraCel->setPosition(m_baseBounds.x + bounds.x, m_baseBounds.y + bounds.y);
render::Render().renderLayer(extraImg,
m_editor->layer(),
m_editor->frame(),
gfx::Clip(0, 0, extraCel->bounds()),
doc::BlendMode::SRC);
if (blobImage) {
doc::blend_image(extraImg,
blobImage.get(),
gfx::Clip(blobImage->bounds().size()),
m_doc->sprite()->palette(m_editor->frame()),
255,
doc::BlendMode::NORMAL);
}
}
Editor* m_editor;
Doc* m_doc;
ExtraCelRef m_extraCel;
// Initial bounds for the entry field. This can be modified later to
// render the text in case some initial letter/glyph needs some
// extra room at the left side.
gfx::Rect m_baseBounds;
};
WritingTextState::WritingTextState(Editor* editor, const gfx::Rect& bounds)
: m_delayedMouseMove(this, editor, 5)
, m_editor(editor)
, m_bounds(bounds)
, m_entry(new TextEditor(editor, editor->getSite(), bounds))
{
m_beforeCmdConn = UIContext::instance()->BeforeCommandExecution.connect(
&WritingTextState::onBeforeCommandExecution,
this);
m_fontChangeConn =
App::instance()->contextBar()->FontChange.connect(&WritingTextState::onFontChange, this);
m_entry->NewRequiredBounds.connect([this](const gfx::RectF& blobBounds) {
if (m_bounds.w < blobBounds.w || m_bounds.h < blobBounds.h) {
m_bounds.w = std::max(m_bounds.w, blobBounds.w);
m_bounds.h = std::max(m_bounds.h, blobBounds.h);
m_entry->setExtraCelBounds(m_bounds);
m_entry->setBounds(calcEntryBounds());
}
});
onEditorResize(editor);
}
WritingTextState::~WritingTextState()
{
}
bool WritingTextState::onMouseDown(Editor* editor, MouseMessage* msg)
{
if (!editor->hasCapture())
m_delayedMouseMove.reset();
m_delayedMouseMove.onMouseDown(msg);
m_hit = calcHit(editor, msg->position());
if (msg->left()) {
if (m_hit == Hit::Edges) {
m_movingBounds = true;
m_cursorStart = editor->screenToEditorF(msg->position());
m_boundsOrigin = m_bounds.origin();
editor->captureMouse();
return true;
}
// On mouse down with the left button, we just drop the text
// directly when we click outside the edges.
drop();
return true;
}
else if (msg->right()) {
cancel();
return true;
}
return StandbyState::onMouseDown(editor, msg);
}
bool WritingTextState::onMouseUp(Editor* editor, MouseMessage* msg)
{
m_delayedMouseMove.onMouseUp(msg);
const bool result = StandbyState::onMouseUp(editor, msg);
if (m_movingBounds)
m_movingBounds = false;
// Drop if the user just clicked (so other text box is created)
if (m_delayedMouseMove.canInterpretMouseMovementAsJustOneClick()) {
drop();
}
return result;
}
bool WritingTextState::onMouseMove(Editor* editor, ui::MouseMessage* msg)
{
m_delayedMouseMove.onMouseMove(msg);
// Use StandbyState implementation
return StandbyState::onMouseMove(editor, msg);
}
void WritingTextState::onCommitMouseMove(Editor* editor, const gfx::PointF& spritePos)
{
if (!m_movingBounds)
return;
gfx::PointF delta(spritePos - m_cursorStart);
if (delta.x == 0 && delta.y == 0)
return;
m_bounds.setOrigin(delta + m_boundsOrigin);
m_entry->setExtraCelBounds(m_bounds);
m_entry->setBounds(calcEntryBounds());
}
bool WritingTextState::onSetCursor(Editor* editor, const gfx::Point& mouseScreenPos)
{
if (calcHit(editor, mouseScreenPos) == Hit::Edges) {
editor->showMouseCursor(kMoveCursor);
return true;
}
editor->showMouseCursor(kArrowCursor);
return true;
}
bool WritingTextState::onKeyDown(Editor*, KeyMessage* msg)
{
// Cancel loop pressing Esc key
if (msg->scancode() == ui::kKeyEsc) {
cancel();
}
// Drop text pressing Enter key
else if (msg->scancode() == ui::kKeyEnter) {
drop();
return true;
}
return false;
}
bool WritingTextState::onKeyUp(Editor*, KeyMessage* msg)
{
// Note: We cannot process kKeyEnter key here to drop the text as it
// could be received after the Enter key is pressed in the IME
// dialog to accept the composition (not to accept the text). So we
// process kKeyEnter in onKeyDown().
return true;
}
void WritingTextState::onEditorGotFocus(Editor* editor)
{
// Focus the entry when we focus the editor, it happens when we
// change the font settings, so we keep the focus in the entry
// field.
if (m_entry)
m_entry->requestFocus();
}
void WritingTextState::onEditorResize(Editor* editor)
{
const gfx::PointF scale(editor->projection().scaleX(), editor->projection().scaleY());
m_entry->setScale(scale);
m_entry->setBounds(calcEntryBounds());
}
gfx::Rect WritingTextState::calcEntryBounds()
{
const View* view = View::getView(m_editor);
const gfx::Rect vp = view->viewportBounds();
const gfx::Point scroll = view->viewScroll();
const auto& padding = m_editor->padding();
const auto& proj = m_editor->projection();
gfx::Point pt1(m_bounds.origin());
gfx::Point pt2(m_bounds.point2());
pt1.x = vp.x - scroll.x + padding.x + proj.applyX(pt1.x);
pt1.y = vp.y - scroll.y + padding.y + proj.applyY(pt1.y);
pt2.x = vp.x - scroll.x + padding.x + proj.applyX(pt2.x);
pt2.y = vp.y - scroll.y + padding.y + proj.applyY(pt2.y);
return gfx::Rect(pt1, pt2);
}
void WritingTextState::onEnterState(Editor* editor)
{
StandbyState::onEnterState(editor);
editor->invalidate();
editor->addChild(m_entry.get());
m_entry->requestFocus();
}
EditorState::LeaveAction WritingTextState::onLeaveState(Editor* editor, EditorState* newState)
{
if (!newState || !newState->isTemporalState()) {
if (!m_discarded) {
// Paints the text in the active layer/sprite creating an
// undoable transaction.
Site site = m_editor->getSite();
ExtraCelRef extraCel = m_entry->extraCel(TextEditor::Final);
Tx tx(site.document(), Strings::tools_text());
ExpandCelCanvas expand(site, site.layer(), TiledMode::NONE, tx, ExpandCelCanvas::None);
expand.validateDestCanvas(gfx::Region(extraCel->cel()->bounds()));
expand.getDestCanvas()->copy(
extraCel->image(),
gfx::Clip(extraCel->cel()->position(), extraCel->image()->bounds()));
expand.commit();
tx.commit();
}
m_editor->releaseMouse();
m_editor->document()->notifyGeneralUpdate();
return DiscardState;
}
editor->releaseMouse();
return KeepState;
}
void WritingTextState::onBeforePopState(Editor* editor)
{
editor->removeChild(m_entry.get());
m_beforeCmdConn.disconnect();
m_fontChangeConn.disconnect();
StandbyState::onBeforePopState(editor);
}
void WritingTextState::onBeforeCommandExecution(CommandExecutionEvent& ev)
{
if ( // Undo/Redo/Cancel will cancel this state
ev.command()->id() == CommandId::Undo() || ev.command()->id() == CommandId::Redo() ||
ev.command()->id() == CommandId::Cancel()) {
cancel();
}
else {
drop();
}
}
void WritingTextState::onFontChange(const FontInfo& fontInfo, FontEntry::From fromField)
{
if (auto font = Fonts::instance()->fontFromInfo(fontInfo)) {
m_entry->setFont(font);
m_entry->invalidate();
m_editor->invalidate();
// This is useful to show changes to the anti-alias option
// immediately.
auto dummy = m_entry->extraCel(TextEditor::Intermediate);
if (fromField == FontEntry::From::Popup) {
if (m_entry)
m_entry->requestFocus();
}
}
}
void WritingTextState::cancel()
{
m_discarded = true;
m_editor->backToPreviousState();
m_editor->invalidate();
}
void WritingTextState::drop()
{
m_editor->backToPreviousState();
m_editor->invalidate();
}
WritingTextState::Hit WritingTextState::calcHit(Editor* editor, const gfx::Point& mouseScreenPos)
{
auto edges = editor->editorToScreen(m_bounds);
if (!edges.contains(mouseScreenPos) && edges.enlarge(32 * guiscale()).contains(mouseScreenPos)) {
return Hit::Edges;
}
return Hit::Normal;
}
} // namespace app