slint icon indicating copy to clipboard operation
slint copied to clipboard

Text/TextInput needs proper text editing data structure to support undo/redo, rich text formatting, etc.

Open jacquetc opened this issue 2 years ago • 11 comments

Hello,

What do you think about a TextDocument model for text items ? It would separate the view from the model so we can manipulate complex texts outside a UI item.

As always, while writing that, I'm glancing at the QTextDocument from Qt. Is it good enough of an exemple for you ? I wish to talk about it and I ask for your input before trying to implement it. Maybe you already have plans for it or other ideas :-)

For reference: QTextDocument and other rich text classes

It can begin very lightly with a TextDocument listing TextBlocks (think paragraphs), each containing text. Later, we can add TextBlockFormat and TextCharFormat. Then, it can have its own virtual cursor.

It implies a modification of TextInput/TextEdit to write on the TextDocument and display any change.

Any thought ?

jacquetc avatar Jun 07 '22 13:06 jacquetc

I agree with principle.

I think we should have a data structure for editable text that supports undo/redo and formatting.

It would be great if we can re-use an existing crate and contribute any fixes back. From what I can tell from crates.io, that rules out a piece table and brings in various rope implementations. Perhaps there's one that suits or purpose or can be modified accordingly.

TextInput and Text can be built on top of that and take care of per-paragraph rendering, for example.

My feeling is that this could go into a richtext or text document Rust module, where the implementation as well as thorough unit tests can live.

For Text it would be nice to keep it "simple", stay with Property<SharedString> and render that on-the-fly. So that might need to use rich text only if it's in a special rich text mode perhaps, which might even map to a different element.

But for TextInput it might then start to make sense to replace the text: Property<SharedString> with a text document.

I think a path of integration could look like this:

  1. Teach the compiler to call a setter and getter function for a property and make it so that while builtins.slint declares a property<string> text; for TextInput, the any call site that reads or writes that in generated code as well as the interpreter invokes a function on the TextInput instead.
  2. After say TextInput's text property in text.rs is say a private_text: Property<SharedString> then next step would be to replace it with a TextInputDataBoxor so, similar to Flickable's heap allocated private data. That way we can use regular Rust data structures and don't have to worry about repr(C) and other peculiarities of binding to C++.
  3. Finally TextInput then could be changed to use a TextDocument from a richtext module and provide undo/redo.
  4. ...
  5. Profit and add richtext support :-)

@ogoffart, what do you think?

tronical avatar Jun 07 '22 13:06 tronical

Thank you for your very interesting input. It makes sense to create a separate crate for a text model. I'll begin this week some preliminary work on it. I'll post here the progress.

jacquetc avatar Jun 07 '22 14:06 jacquetc

Hello,

I just published the first version of a new crate: text-document

For now, I focused on plain text support, documentation and testing. The groundwork is also here for rich text support.

You are welcome to offer advice or pull request on it :-)

jacquetc avatar Jun 30 '22 23:06 jacquetc

I just fixed a few details, like code coverage in the CI, and the readme, so it's a bit more welcoming. Enjoy !

jacquetc avatar Jul 01 '22 15:07 jacquetc

Hi @jacquetc ! That looks really interesting! Can you explain a little bit how your data structure works? How would a document containing say three paragraphs of text look like in memory, roughly?

tronical avatar Jul 07 '22 09:07 tronical

Hello tronical,

For the way a user would use this crate, I'm sure that you already read the documentation.

To sum up the internal working:

  • TextDocument is essentially a wrapper around TextManager
  • TextManager is the actual core of the crate, a pointer to it is shared with all elements
  • The more interesting part for you : TreeModel, which is a field of TextManager

TreeModel manages the Element enum. New elements, as tables, can be added later.

pub enum Element {
    FrameElement(Rc<Frame>),
    BlockElement(Rc<Block>),
    TextElement(Rc<Text>),
    ImageElement(Rc<Image>),
}

This tree model structure is composed with this fields:

    id_with_element_hash: HashMap<usize, Element>,
    order_with_id_map: BTreeMap<usize, usize>,
    child_id_with_parent_id_hash: HashMap<usize, usize>,
    id_counter: usize,

It's a lightweight hierarchical and ordered model. Each new Element has a unique id thanks to a dumb id_counter. Hierarchy is set in child_id_with_parent_id_hash. Order is set in order_with_id_map with steps of 1000. id_with_element_hash is only here to link Elements to his id.

So, applying your example, three paragraphs (blocks) would be represented like this:

Visual

Frame
|- Block
   |- Text
|- Block
   |- Text
|- Block
   |- Text

id_with_element_hash: 0: Element::FrameElement 1: Element::BlockElement 2: Element::TextElement 3: Element::BlockElement 4: Element::TextElement 5: Element::BlockElement 6: Element::TextElement

order_with_id_map: 0 : 0 1000 : 1 2000 : 2 3000 : 3 4000 : 4 5000 : 5 6000 : 6

child_id_with_parent_id_hash 0 : 0 -> useless, but easier to maintain when all collections have the same count 1: 0 2: 1 3: 0 4: 3 5: 0 6: 5

Elements: As you can see, I used extensively Rc internally. Only modules in the same crate are able to modify directly Elements, using interior mutability with Cell or RefCell. I try to avoid direct Rc's "leaking" outside of the crate, using Weak, so the user will intentionally upgrade it to Rc. This last idea is maybe useless with the drops in Rust. Fixable if so.

Overall TextDocument is read-only, but for set_plain_text(). The only way to manipulate the internals is using TextCursor. This is enforced by the use of pub(crate) in all setters in Element implementations.

For now, the only way to use this crate with an UI representing a text would be:

  • add the id of each text-document element as metadata to their respective representations in the UI
  • use TextCursor to apply modification
  • use the callbacks to update the UI, using ids to target directly the changes

I would be delighted to have your input, so as to make this crate fit better in Slint.

jacquetc avatar Jul 07 '22 10:07 jacquetc

This issue is similar to https://github.com/slint-ui/slint/issues/2723 ; possibly duplicated?

There was an interesting post on y-combinator about the use of gap buffers for text editors. Link to blog post.

It's pretty interesting, but it was showing that the gap buffer can be quite efficient due to their use of a simple contiguous array based data structure. There's some latency on inserts into huge buffers (to grow the array), which seems to be the main downside. I'm not sure if such a thing is suitable for rich text -- emacs uses it and supports syntax highlighting at least, though perhaps not block based text. I'm not a rust expert, but from what I understand this type of structure would be easier to model in safe rust than a tree based structure.

Blquinn avatar Oct 13 '23 19:10 Blquinn

Thank you Blquinn for your input,

I agree that the subject is similar to #2723, but I thinks #2723 actually depends on this current issue. A rich text editor needs a good backend rich text model, separating the visual editor from the model.

Using a contiguous array-based data structure is an interesting approach. I plan to overhaul my crate text-document and this structure is one other possibility to be explored. By the way, your article pointed me to ropey too, so thanks.

I agree completely that a tree structure seems at first glance very OOP, but there are well-established ways to do that in Rust. I did it with weak pointers. Yet, like you pointed out, I'm sure that there is a more "rusty" approach with enums, hence my researches. Rich text uses more information to be stored, like font styles, paragraph styles, etc... and all these informations must be accessed to be read or modified. Maybe too much information for an array based data structure ?

IMHO, the chosen approach must be :

  • easily understandable, to be maintainable and usable
  • at least, offer an easy API. The way Qt does it with QtextDocument, QTextBlock, QSyntaxHighLighter, QTextCursor, (and others) is easy (at least for me, but I'm biased). I'm actually trying to detach myself from this thinking.
  • like you know, the model must be modifiable, text inserted, styles modified. Internal mutability is a way, but using it too much is a sign of a problem in the architecture.
  • about efficiency, I am more a partisan of a first working but dirty prototype and make it more efficient later. Even dirty Rust code is safe Rust and have a C-like speed. In short, I would take a maintainable ugly tank over a complex but beautiful Ferrari.
  • it must offers callbacks or other ways to allow any visual text editor to be plugged in and receive updates from the. I don't know what is the preferred way.

I digress and I'm sorry about that

jacquetc avatar Dec 20 '23 11:12 jacquetc

Actually, I am discovering that there is an interest to cosmic-text in #2723 . So this whole conversation may become irrelevant soon if cosmic-text is integrated.

jacquetc avatar Dec 20 '23 12:12 jacquetc

I don't think that cosmic text will solve all of the uses of a typical rich-text document / editor. It really just seems to do advanced text rendering. It wouldn't, for example, handle block level formatting, tables, images and whatever other fancy stuff you may want to embed into a text doc.

Also, I agree with your assessment that the gap buffer might not be the best for storing block formatted stuff. You would probably need to somehow re-create a tree structure anyway because blocks can be nested, which is logically a tree. I imagine that would be more complicated than just using a tree in the first place.

Blquinn avatar Dec 20 '23 21:12 Blquinn

Whichever solution is adopted, this model have to be separate from the actual visual representation (the UI component). I think we could add an intermediary model, to filter what is needed from the bigger model and store/calculate some information like the displayed text, the cursor position, the geometries of the text based on the font family and on the styles of each text fragment.

So, trying to think of a maintainable architecture, we could have 3 parts:

  • Text-Document Model::

    • Hold the actual text abstraction in (maybe in an AST),
    • import and export from/to several formats
    • have a way to modify the text and to signal theses updates (possibly through callbacks or a list of manual change requests )
    • The goal is to achieve functionality akin to QTextDocument, with potential expansion towards a Pandoc-like utility, especially since Rust excels in CLI applications.
  • The intermediary model: consider this the text editor's 'backend'. It interacts with the Text-Document Model, performing tasks like:

    • Abstracting the text editor's current view.
    • Receiving and processing updates from the Text-Document Model, ensuring only relevant changes are utilized.
    • calculate characters and paragraph geometries, based on font and style
    • Managing cursor positioning and its movement and its geometries
  • The text editor widget

    • Will only display pixels that the in-between model tells it to show
    • handle all the GUI side (key strokes, mouse inputs) and calls the model's API as needed

These three parts can be independently maintained. The intermediary model is stuck with the text editor. The text editor widget handles all integrations with the Slint framework.

Other notes:

  • Like QTextDocument, the text-document could be shared between multiple editors.
  • The text-document and intermediary models can modify in-situ internal models (following the clean architecture) or can use the MVU (Model-View-Update) pattern (each change creates a new model)
  • The glyph part in the intermediary model needs some specific knowledge about computer calligraphy. No need to reinvent the wheel, we can reuse Slint's way of drawinf glyphs or see what other crates exist ?
  • the text-editor would be a canvas for pixels, accepting mouse and keyboard inputs.
  • A canvas can also be its own widget, and we just reuse it.

@tronical @Blquinn What do you think about splitting the difficulties this way ? Only the text editor part would really need Slint-specific knowledge. Maybe also a bit with the glyph drawing in the intermediary model if we want to reuse Slint's way of drawing them.

jacquetc avatar Dec 22 '23 20:12 jacquetc