mirror of https://github.com/aseprite/aseprite.git
604 lines
16 KiB
C++
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
|