Skip to content

Instantly share code, notes, and snippets.

@davelab6
Last active January 19, 2019 22:33
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save davelab6/0e5adcf4092bd778cd2b83f47cba6dd2 to your computer and use it in GitHub Desktop.
Save davelab6/0e5adcf4092bd778cd2b83f47cba6dd2 to your computer and use it in GitHub Desktop.

Adrien

I like Raph's idea of deltas as the only way to mutate the data; making the data as immutable and then having a single point to mutate them is a practical way to remove complexity in the code. This data can be split to 2 kinds; arrays and objects (as in JSON) and the interface to mutate, with paths into the model.

I made https://gist.github.com/adrientetar/aff333972a927c3d0a2c641c27ad605a and this appraoch will simplify tfont, as there is a Tracker for tracking insert/remove/etc; this delta model would replace that. Also every change is tracked only once.

But snapshot model, tracks everything twice. Asking users to list the changes they make once, and that allows the method to scale up to collab mode where changes are broadcast to other users; the deltas can be serialized and sent to a network.

Jeremie

So that is mode a tfont library level change?

Adrien

Right. It will be a big change to the trufont API.

Jeremie

Okay. Our current UndoManager is at the WX trufont app level. What you described is another model. But I am not sure where the engine happens; how to link it to the UI in WX?

Adrien

The view stores the changes; it can have a list of changes and present that as one ReUndo action. The deltas could be 1 or more in a ReUndo.

Questoin for Raph: How to most efficiently store the path for changes? In Fonte I have a list of ints and strings, but it sucks to instantiate strings for each change; but you also want it to be serializable

Raph

Deltas can be represented with JSON Patch; that is designed for Serialization. You can do it as a list of ints and strings; the Swift protocol is like that. Or you have objects with a builder, and internal its likely also int and string based. I don't think the cost is a major concern. Each delta is a small object compared to snapshots. A latin with a few 100 glyphs is OK, but a CJK font, then 10,000s of glyphs and smart components and stuff, then a Python snapshot of that will be slow.

Adrien

But in Python you have 'string interning' which helps, unlike cpp/csharp

Raph

But a delta is 1kb at most, and 1,000x string operations, you don't see that. A diff computation on a snapshot will be 100s of ms in a large font. So, backing up, my proposal was a little radical reachitecture of an app, and object/undo with UndoManager that ties directly to the UI view elements, is a tradiional way. That is how NSDocument Cocao macOS applications do it. I think both are viable ways to do it, and the snapshot approach is easier. But the data delta is more pain up front, but I see that as worth it. There are immediate practical advantage as you can do large documents without scaling problems. And later down the path, version control and collab mode, is infinitely easier than entire documents. That gets really hard.

So, Object Orientation is now a little old school; in the Rust community, its more common to take a data orientated approach with a loose coupling of Views and Models. Its a more modular appoach.

Adrien

The UI can store the chagnes, so an undo can store those changes, and the backend won't store changes, it just applies them

Just

That's also what my jundo does, its a simple data model, an object with a classical tree based structure, and if you can model that data in JSON like terms, we can track changes like Raph proposed. That is how I interpreted the proposal; not sure if that was intended. But that model exists now and we have a separate chain of Deltas that represent the past and future for Redo and Undo. As soon as the model changes, you deal with the deltas.

Raph

I propose ONE change of deltas. Update a glyph, drag a point; change a metadata string. Each delta IDs its path, but there is one chain of deltas. Its not that each node has its own delta chain.

Just

That needs me to rethink. A glyph or layer has its own undo stack. You change 'a' then 'b' and go back to 'a', so undo will only apply to your changes in 'a' when that is seen

Raph

Right. So in that case you have a different chain per UI content.

Just

But then you lose linear lookup. If you have a global chain, you would need to filter that, and if you can prove that undoing X delta in the past it doesn't fork the chain but can be rebased to the HEAD of the chain. It would be neat to have a couple of changes to several glyphs - change 10 glyphs advanceWidth by 10 units - and that is one delta. But even if 10 deltas, that should be presented to a user as 1 action.

UndoManager will work at the glyph level traditionally, but its

Adrien

Yes, if you prune changes, your logic of undo is more complex for users. It is a tradeoff.

Just

It is something to consider for what can happen if the gylph or layer is no longer the center of the undo actions.

Raph

Also nested components change things?

Jeremie

Not sure, the reference won't change, if the parent component changes

Just

Only the view needs to be updated

Raph

True. So, there's a mechanical 'data' aspect and a UI aspect. An Undo action isn't 'localized.'

Adrien

I looked at jundo, and it is easy to use for developers; they don't think about changes, but the proxy objects cache their position; if user makes changes, they could become invalid.

Just

Yes, they are volatile, but its a good point. Element 10 in your array, and you insert, then the proxy is no valid as its still index 10.

Adrien

So raph's idea is a reference to a path, construct the change, and then apply it, so the developer knows they work with a change path. The API is more explicit about constructing a path.

Just

Right, I hide the paths from the developer. But yes, I see that it can be important to know that. So there could be an additional level of indirection, so you don't deal with index 10 but the object, so if the position changes, your proxy is still valid

Adrien

Well, the developer must be aware of the caching. That is the real drawback of that method, otherwise it is very convenient.

Just

Right. It can sit between the object model if its simple enough to approach like JSON elements (dicts, list, numbers, strings) then it works. But if you do more complex operations, the data I have is dumb, python attributes are dicts with keys, but with complex operations the object model has methods that modify the model, and that means its harder to get a proxy that respects it. This jundo approach means these complex operations must take place in the proxy, not the data object, and then the proxy breaks down the operation into the individual deltas to be recorded.

Adrien

And raph, say I call a reverse path, the method wont mutate the object?

Raph

This is a pure data orientated approach; everything is immutable; the UI returns a delta object that is a list of changes. So to reverse multiple contours, get a list of changes, combine them, and apply that.

Jeremie

Very small changes are stored in this method, so you have to stack them into a larger operation?

Just

Right. You have a long contour, and reverse direction, you have 10 deltas?

Raph

You can say you replace a list of points with another list of points

Just

aha yes that is a more compact range delta; its liek a micro snapshot

Raph

Yeh

Just

So you could mix approaches at that level, specific to each operation

Raph

Right. So you could replace an entire glyph, before and after, and if you avoid snapshotting the entire set of glyphs, its fine

Jeremie

But today we storage changes per glyph; we have no undo at the font level, except to add/remove glyphs and deal with font-level metadata. Most actions are at the glyph level, and that is more simple. So I wonder, how accessible this is for scripting? What the API looks like, to define, say, a change to 10 things and have that as a single user action. In RoboFont, we say BeginUndoAction, then do things, and the EndUndoAction, and it becomes one action

Raph

2 things: I think a proxy object is a way to mimic that traditional API; the proxy object records the deltas and bunches them. So there is no change to scripting when you have a data model. Second, how to manage the granulatity. UndoGroup is a sequence of ints; you drag points and the mutations are in the same UndoGroup. That requires a little attention to the design of the API, and I think Begin/End is very reasonable; you allocate an UndoGroup and assign all deltas to that group until the EndUndo.

Adrien

Its a compound change, a list of changes

Raph

Yes

Just

So, you drag a long time, you have a start and end position. An UndoManager is the first and last state; the screen update cares about the middle states.

Raph

Right, this is internal to the view, and when mouse releases, the delta is recorded. In Xi Editor, you type many chars, and they are 1 UndoGroup, until you have a cursor position change.

Just

Yes, 1 coordinate change, you see a Delta, and another change, the next Delta will have the same path, so you can merge them

Raph

Yes, the semantics is the same. Question: Do you create a stream of deltas? Yes, no? And where you care for that is live collab; that is completely a choice.

Just

Or 1 views, a preview and a editing one. Many font editors do that, you see things move at the same time. So your notifications internally are there to refresh other views.

Raph

A good way - not the only - is to make the stream of events and propagate them, so references see it is changing and update their view

Jeremie

For 10 users on a 30k font, and there is undo streams at that level.

Raph

Should be fine; the deltas are VERY small. So if its 1kb and there are only view touches, you can scale to a lot of users. 10,000 users might get slow :)

Jeremie

Hmm, the observers checking for updates and keeping a watch for so many updates, will that be an issue?

Raph

Performance is always an issue, for sure, but if you are careful in the design to only touch a small amount of data, then the larger system should have good performance.

Jeremie

Also, maybe we do not need many designers on the same glyph. We are thinking to do collab by locking a glyph, and you only get read only access until it is unlocked

Raph

Yep, it is a deep product design question. Locking. GSuite does diffing. And then version control does branching. I see that as useful as users can compare diffs; and for a font, its not just contours, kerning or other data... So you have a git like commit and tools to compare that. You can accept the update to merge it, it doesn't need feel like git. But those 3 approaches have UX pros and cons for people to collaborate.

Just

I like the idea of looking at glyphs live around the globe, but in a distributed studio, you don't have real time collab on the same glyphs, rather the project is split to roles. So a font specific front end to git would already be helpful

Raph

Right, the link to librePCB, that is what they do. They are designing a text format to make it VCS friendly, so they CAN use git itself. That requires care again that you dont have noisy; UIBuilder makes XML and its a nightmare to merge changes. This is something that works but you have to be sure your textfile can be merged by Git's merge functions.

Just

Yes, a large JSON file is less amenable than the UFO like file per glyph model.

Raph

Also librePCB looked at what serialized part of the data model is permanent and needs to be saved, and what is transitory and belongs to the UI?

Adrien

You don't want to seralize window positions?

Just

I began using SublimeText recently and you can store no project info, but if you do, you have 1 file, that is likely for version control, and another file, with the metadata and ignore that in your .gitignore file. Its very explicit, 2 project files, persist both, but one is for you and one for the team and committed

Raph

Yes, so the mechanics take care of themsevles in that case

Adrien

With UFO, its fopen() for all files, and that can be slow to open 10,000 GLIF files

Just

Yes. UFO is now a little old, and "a pile of files" :) But when you open a UFO, you don't look at 10,000 of glyphs at once often

Adrien

Except on the glyph table which is the first thing you see ;)

Just

Sure. But there are tradeoffs. I see the benefit of a single JSON for the font. In a collab environment, its cool with GSuite, its not a file on disk, you know its saved, but its always saved state. Its harder to imagine when you think of traditional font editors. For a font editor where its more p2p, than server-client, I wonder if a server is needed.

Adrien

And then you need Google level infrastructure. With CDRT you can do collab that is easy to handle.

Just

This data approach is the only way to make it work reliable. That is now clear to me.

Jeremie

All very interesting :) So, how much effort to make it happen to make this real for TruFont?

Just

I am not sure, but the collab aspect is interesting but for now TruFont needs undo that is useful, and I would like it to leave a path open to a more collaborative system

Jeremie

What we have is nearly ready to work. Can we push it upstream, and have that available to users, and later patch and improve a smarter way to do undo? We want to use the software and add more features so it is really useful. And for collaboration, we have a lock way to get teams working, that is productive for us, and I would like to add that to TruFont, and then replace it with Raph's method in the future

Just

Yes, I would like to be pragmatic and make something like you did. I like your approach in the UI, and if that is flexible, we can change the internals. That would be ideal. It is hard to answer without going through the code.

Jeremie

Yes, with decorators, it is not intrusive into the code.

Just

The delta approach is a pure and clean model, beautiful really. The traditional model is practical though. In scripting, you have a lot of choice over what is undoable, and its explicitly something you do. Your scripted changes are not undoable; but since your prior user action is snapshot, you can skip the scripted change and get back to where you were before.

Adrien

With Deltas, you can merge your change, so its a similar effect.

Just

But you can not bypass the deltas, as that is how to make any change. No matter the UndoGroup size, you make changes via deltas. With jundo then scripting interfaces will have to go through a proxy, and that might be slow. In RoboFont, a larger change, I will not do Undo, I open a font with no UI, save it under another name, tahts fast.

Adrien

That isn't undo, its the observer stuff. I have a sync for UI, so you make a change, and so trufont invalidates the path, and I refreshUI on all windows, so they pull again form the model. This is memoized. Then I don't need notifcations; there is just 1 global signal to redraw the UI. That avoids the complexity of observers. Often I found the simple things in Python are faster; its not always true.

Just

Yes, a good design in Python can be very fast. But with 100s of extensions with observers it can become sluggish as hell. They are broadcast and acted on immediately, and not queued and batched. Its a problem

Adrien

Defcon objects send notifications too; that slows down opening. I have a constructor and validate it later.

Just

You can redraw changes

Raph

If you have a glyph grid, and refresh only what is on screen, its OK

Just

If 'a' changes by user edit, there's a notification, and the view will not know which glyph changed. But sure.

Adrien

Text view is a entire text run so you redraw the whole thing

Dave

So, Adrien, what do you think about upstreaming Yves work so far?

Adrien

If we all agree, I will accept it :)

Just

I like the decorators, its nice. I wonder if its general enough; some of it is glyph specific, but I only had a superficial look at the code. I played with it a little.

Jeremie

We also have Begin and End Undo, for scripting.

Raph

I like this plan. We can move from snapshot to deltas; and start with glyphs before/after states, and operations across many glyphs, can be ignored as deltas and only as snapshots. There is a big architectural change, but since this is font data, we can use glyphs as a stepping stone.

Just

Yes, we have expectations from users that font editors work a certain way. That UX should be retained. Its a practical thing.

Raph

Yes, you want to be able to undo a glyph deletion

Jeremie

But there are not too many actions like that, compared to what can happen in a glyph

Just

In one editor, you can not select several glyphs and rotate them all, in the font view. That seemed primitive for UX, and I think it would be ideal that undo can only do that operation; you can not go into each glyph and undo its rotation. You can not undo part of a git commit; the commit is atomic. This is purely a UI POV.

Adrien

A font editor is a complex hierachy. In Xi Editor, a text document is a single stream. It is much more simple.

Just

Yes, that was my conclusion too; we have a tree of data, if not objects, and how we as users navigate and operate on that tree is complex. Metrics and kerning can be separate from outlines; fitting could be 1 undoManager.

But I am happy that Adrien agrees in the general direction, and we should leave the door open to a new data orientated approach. I would like to evaluated the BF UndoManager, how the decorators and such are structured, so we can do a more delta based approach. Maybe a little vague :)

Adrien

Yes, for me, moving that way without API changes will need what you did in jundo with proxies.

Just

Hmm. What Yves did is a quick glyph snapshot, or only the components, there are a few selectors. So I want to read a bit more, but yes, how that works, there is snapshot ability, and then how a glyph and a UI have undoManagers. You need both to work. They are not big things. And we can replace the snapshot ability with deltas. Especially as Yves worked in an unobtrusive way. Serialization can be a method in teh app, not on the glyph

Adrien

Or the API is even private, not for scripting. And I am OK with API breakage at this stage

Just

Sure, but the more clean we are now, the more easy new component and the long term maintenance will be :) But overall I am very happy that we have an undo system at all, as we have a starting point and we can compare the alternatives, but we have a start. I think the work Yves did was pretty awesome :)

Adrien

Yes :) Raph, I read some CRDT paper, and we need a way to merge deltas for collab

Raph

Yes, UUIDs are needed, and librePCB does that. a CRDT is a fairly ambitious thing to take on. It is hard. I will not recommend it unless as a research project. I think it is too much for a practical font editor. Use Git. But if you do want to do collab, then Firebase is actually not hard. And then you have other problems. Maybe arrays edited concurrently might not work well. There is no perfect merge. If you get a weird result, let uses fix it. GSuite does that, and Firebase takes care of even broadcast and persistence. A prototype could likely be done with deltas in a few weeks.

Adrien

Collab is not critical to me, but I see how deltas scales cleanly from undo to collab

...

Raph

Selection changes merge with an undoGropu, so undoing will restore selection to where it was in the before state

Just

Yes! I tried to think of that. I have changes I want to see as immutable. so tacking a selection seemed odd. But yes, I see that is a good possibility. "Currently we have the undo group open, and we are collecting changes, once that is done, we close it off." If selection is added, then can I attach changes... I thought hmm better to keep them immutable :)

Raph

Xi Editor does it by a sequence of changes, and if in the same Undo Group, then adding a new change changes the undo group, but its made of immutable changes

Just

Got it. Makes sense.

Dave

Great! So, I hope Yves and Jeremie can make a PR soon, and Adrien, please merge [this PR] :)

Just

Also rebase to upstream changes from TruFont org. Overall it looks really clean! I liked reading the compare changes view :)

And thanks to all!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment