How would you approach modeling a workspace with buffers and editors?

Thanks for writing this framework, I'm having fun learning.

This is my first exposure this style of programming. I think I understand the todo example, but I'm unsure how to handle more complex cases. In particular how to manage dependencies between different parts of the state. For example how would you implement a workspace style app using this approach?

The workspace has a list of buffers. Each buffer has a string. The workspace also has a list of editors. Each editor has a selection and the id of the buffer that its editing. A single buffer may have multiple editors.

Here's my idea of the state:

struct WorkspaceState: Equatable {
    var buffers: IdentifiedArrayOf<BufferState> = []
    var editors: IdentifiedArrayOf<EditorState> = []
}

struct BufferState: Equatable, Identifiable {
    let id: Int
    var text: String
}

struct EditorState: Equatable, Identifiable {
    let id: Int
    let bufferId: Int
    var selection: Range<Int>
}

And these actions illustrate where I'm stuck:

enum WorkspaceAction: Equatable {
    case buffer(id: UInt32, action: BufferAction)
    case editor(id: UInt32, action: EditorAction)
}

enum BufferAction: Equatable {
    case replaceText(range: Range<Int>, text: String)
}

enum EditorAction: Equatable {
    case insertText(String)
}

Questions:

1. How to update Editor state when Buffer changes?

Some code somewhere sends a BufferAction.replaceText. Once the Buffer is changed I want to update the state of all editors that are viewing the buffer. In particular the selection range should get adjusted if the inserted text is before or within the selection.

I "think" one way that I could do this is to have the BufferAction.replaceText action return an Effect that describes the changed state in the buffer and sends it as a workspace level action? So for example the buffer change state might be sent as a new WorkspaceAction.bufferDidChange(id, change). Is that the way to do it or are there other options I should consider?

2. How to handle EditorAction.insertText action?

The editor then needs to:

  1. Use its current selection range to decide where to insert the text into its buffer.
  2. Wait for whatever update mechanism was decided on for question 1 to complete
  3. Position its own selection to the end of the inserted text range.

Thoughts and suggestions are most appreciated.

Thanks,
Jesse

Rather than trying to handle everything in reducers I think you'll have a far easier time of it using a flat Action structure and delegating most of the work to your State structs.

enum WorkspaceAction: Equatable {
    case replaceBufferText(bufferId: UInt32, range: Range<Int>, text: String)
    case insertEditorText(editorId: UInt32, text: String)
}

// reducer
switch workspaceAction {
	case .replaceBufferText(bufferId, range, text) {
		workspaceState.replaceText(bufferId, range, text)
        return .none
	}
	case .insertEditorText(editorId, text) {
		workspaceState.insertEditorText(editorId, text)
        return .none
	}
}

struct WorkspaceState: Equatable {
    var buffers: IdentifiedArrayOf<BufferState> = []
    var editors: IdentifiedArrayOf<EditorState> = []

	mutating func replaceText(_ bufferId: UInt32, _ range: Range<Int>, _ text: String) {
		updateBuffer(bufferId, { buffer in buffer.replaceText(range, text) })
		updateBufferEditors(bufferId, { editor in editor.adjustSelection(...) })
	}

	mutating func updateBuffer(...) {

	}

	mutating func updateBufferEditors(...) {
		
	}
}

In this way the reducer is a very thin layer, it simply dispatches action to your State which has all of the functions that you need for mutations. Once you move work out of reducers and into your state/model it becomes far easier to model complicated updates.

2 Likes

Hey, your problem is very interesting.

I’m currently only on my smartphone so I couldn’t test any of my ideas. And my english isn’t that good.

I think the problem you want to solve can’t be modeled this exact same way.

The buffer needs to send updates to all its editors and therefore should be handled through the environment.

The workspace environment should have a class that has the list of all buffers and contains functions that handles them. For example create a new buffer, delete a buffer, write to / insert at the buffer, subscribe to changes from the buffer and more (BufferClient). These functions could be called through actions in the workspace reducer and could return different effects (more on that later).

These buffers themselves should be some classes which publish changes and which you can write to (BufferManager). The naming is copied from the MotionManager example.

By subscribing to changes from the buffer, the problem from question 1 could be solved. Image if some thing changes in the buffer, a returning effect gets triggered. This effect includes all changes and more. For example you could send the new range of the selection or something else. You could also keep track of all subscribed editor IDs and only send actions to some off them...

For example: in each action which changes the buffer you send the editor ID, which get saved in the BufferManager. And with this you could only send returning effects to the other subscribers.

And to be clear in my idea each editor state has to save the buffer and the changes. Also the var selection highly depends on the text inside the buffer.

And maybe it’s worth taking a look at files and file descriptors instead of buffers?

1 Like

@opsb @moebius Thanks for your suggestions, trying things out now.

Hey @Jesse_Grosjean,
anything new from your project?

Not much positive :frowning:

In the end I tried to flesh out my original idea... to use actions to notify editors of buffer changes. So for instance when a buffer changes it returns a BufferAction.postChange effect. Then I add a "glue" reducer that catches those actions and feeds them into the editor reducer so that it can react to the change.

How to update Editor state when Buffer changes?

This works relatively well:

  1. Buffer is isolated, it just returns .postChange effects when changed
  2. Editor is also pretty isolated, it just adds a .bufferDidChange where it reacts to changes
  3. There is a glue layer responsible for catching and mapping the .postChange to .bufferDidChange.

How to handle EditorAction.insertText action?

Unfortunately I couldn't meet the second requirement, at least not without a lot of extra complexity. That requirement was that when editor gets .insertText action it should:

  1. Send action to insert text into the buffer using the editor's selection range.
  2. Wait for buffer to process that action and send out the .postChange event
  3. Wait for to receive and process the mapped .bufferDidChange
  4. Finally editor should position its own selection to the end of the just inserted text

This is what I imagined the code below would accomplish:

return Effect.concatenate(
    Effect(value: .bufferSend(state.bufferId, .replaceText(range: replace, text: text))),
    Effect(value: .setSelection(nextSelection))
)

But that doesn't work, because the buffer change isn't delivered until both those actions are delivered. So actions are delivered in "wrong" order. The order is logical, but until running the code I was thinking it would be delivered in-between those two actions.

And generally my whole approach feels pretty complex and non-ideal.

import Combine
import ComposableArchitecture
import SwiftUI

// MARK: Buffer

struct BufferState: Equatable, Identifiable {
    let id: UUID
    var text: String
}

struct BufferChange: Equatable {
    let range: Range<Int>
    let text: String
    func lengthDelta() -> Int {
        text.count - range.count
    }
}

enum BufferAction: Equatable {
    case replaceText(range: Range<Int>, text: String)
    case postChange(change: BufferChange)
}

struct BufferEnvironment {}

let bufferReducer = Reducer<BufferState, BufferAction, BufferEnvironment> { state, action, _ in
    switch action {
    case .replaceText(let range, let insert):
        var text = state.text
        let start = text.index(text.startIndex, offsetBy: range.startIndex)
        let end = text.index(text.startIndex, offsetBy: range.endIndex)
        text.replaceSubrange(start..<end, with: insert)
        state.text = text
        return Effect(value: .postChange(change: BufferChange(range: range, text: insert)))
    case .postChange:
        return .none
    }
}

// MARK: Editor

struct EditorState: Equatable, Identifiable {
    let id: UUID
    let bufferId: UUID
    var selection: Selection
}

struct Selection: Equatable {
    var range: Range<Int>

    init(range: Range<Int>? = nil) {
        if let range = range {
            self.range = range
        } else {
            self.range = 0..<0
        }
    }

    func adjusted(for change: BufferChange) -> Self {
        var new = range
        let replaced = change.range
        let delta = change.lengthDelta()
        if replaced.upperBound <= range.lowerBound {
            new = range.lowerBound + delta..<range.upperBound + delta
        }
        return Selection(range: new)
    }
}

enum EditorAction: Equatable {
    case insertText(String)
    case setSelection(Selection)
    case bufferSend(UUID, BufferAction)
    case bufferDidChange(BufferChange)
}

struct EditorEnvironment {}

let editorReducer = Reducer<EditorState, EditorAction, EditorEnvironment> { state, action, _ in
    switch action {
    case .insertText(let text):
        let replace = state.selection.range
        let nextPosition = replace.lowerBound + text.count
        var nextSelection = Selection(range: nextPosition..<nextPosition)
        return Effect.concatenate(
            Effect(value: .bufferSend(state.bufferId, .replaceText(range: replace, text: text))),
            Effect(value: .setSelection(nextSelection))
        )
    case .setSelection(let selection):
        state.selection = selection
        return .none
    case .bufferDidChange(let change):
        state.selection = state.selection.adjusted(for: change)
        return .none
    case .bufferSend:
        return .none
    }
}

// MARK: Workspace

struct WorkspaceState: Equatable {
    var buffers: IdentifiedArrayOf<BufferState> = []
    var editors: IdentifiedArrayOf<EditorState> = []
}

enum WorkspaceAction: Equatable {
    case newBuffer
    case newEditor(bufferId: UUID)
    case buffer(id: UUID, action: BufferAction)
    case editor(id: UUID, action: EditorAction)
}

struct WorkspaceEnvironment {}

let workspaceReducer = Reducer<WorkspaceState, WorkspaceAction, WorkspaceEnvironment>.combine(
    Reducer<WorkspaceState, WorkspaceAction, WorkspaceEnvironment> { state, action, _ in
        switch action {
        case .newBuffer:
            state.buffers.append(BufferState(id: UUID(), text: ""))
            return .none
        case .newEditor(bufferId: let bufferId):
            state.editors.append(EditorState(id: UUID(), bufferId: bufferId, selection: Selection()))
            return .none
        case .buffer(let id, let action):
            return .none
        case .editor(let id, let action):
            return .none
        }
    },

    bufferReducer.forEach(
        state: \.buffers,
        action: /WorkspaceAction.buffer(id:action:),
        environment: { _ in BufferEnvironment() }
    ),

    Reducer<WorkspaceState, WorkspaceAction, WorkspaceEnvironment> { state, action, _ in
        switch action {
        // Catch BufferAction.postChange and wrap change in EditorAction.bufferDidChange and send to editor
        case .buffer(let id, let action):
            switch action {
            case .postChange(change: let change):
                let effects = state.editors.indices.compactMap { i -> Effect<WorkspaceAction, Never>? in
                    if state.editors[i].bufferId == id {
                        let editorId = state.editors[i].id
                        return editorReducer.run(&state.editors[i], .bufferDidChange(change), EditorEnvironment()).map { action in
                            WorkspaceAction.editor(id: editorId, action: action)
                        }
                    } else {
                        return nil
                    }
                }

                if effects.isEmpty {
                    return .none
                } else {
                    return Effect.concatenate(effects)
                }
            default:
                return .none
            }

        // Catch .bufferSend effects map them to BufferActions in workspace
        case .editor(let id, let action):
            return editorReducer.run(&state.editors[id: id]!, action, EditorEnvironment()).map { action in
                switch action {
                case .bufferSend(let bufferId, let action):
                    return .buffer(id: bufferId, action: action)
                default:
                    return .editor(id: id, action: action)
                }
            }
        default:
            return .none
        }
    }
)

func testWorkspace() {
    let store = Store(initialState: WorkspaceState(), reducer: workspaceReducer, environment: WorkspaceEnvironment())
    let viewStore = ViewStore(store)

    viewStore.send(.newBuffer)
    let bufferId = viewStore.state.buffers.last!.id
    viewStore.send(.newEditor(bufferId: bufferId))
    viewStore.send(.newEditor(bufferId: bufferId))
    let editorID = viewStore.state.editors.last!.id

    viewStore.send(.editor(id: editorID, action: .insertText("Hello world!")))
    print(viewStore.state)
    viewStore.send(.buffer(id: bufferId, action: .replaceText(range: 0..<0, text: "Inserted")))
    print(viewStore.state)
}

Alternative Approaches

  1. Delegate logic to State structs as suggested by @opsb.

    This might be proper solution, but didn't feel like I was making much use of the composable part of the composable architecture.

    My hope is that I can somehow use the Store loop too communicate events. And then I could "snap in" other state. For example state that counts words in all open buffers. State that keeps track of selected word. State the builds off that and should does network request to get definition of selected word, etc.

    I guess I could do all that in my own separate model update system, but I was hoping that I could take advantage of composable architecture to help me.


  1. Delegate logic to environment as suggested by @moebius. I don't think I understand this solution very well, so my summary may be incorrect.

    The workspace environment should have a class that has the list of all buffers and contains functions that handles them.

    This is where I got stuck... I should be storing buffer state in the environment? Do I still use WorkspaceState to store anything? From what I can see from Motion example (and most other Environment examples) is that I use the environment to pull in updates to my own state, but I don't use it to store state.

Effects are used to model things that happen in the world, user clicked a button, server responded to a request etc. Here you're using them as a mechanism for function dispatch, and as you're finding, it's really much more complicated than calling methods on objects.

Your code could just be

case .insertText(let editorId, let text):
    buffer.replaceText(range: editor.selection.range, text: text)
    editor.selection.adjust(text.count)
    return .none

The SCA is all about handling the view/side effects, after you're received an event though you want to hand off to the domain layer which can focus on modelling your problem

edit: I didn't bother showing how you lookup the editor etc but my previous post covered that.

1 Like

Thanks, I'm fully convinced that my idea of using effects as function dispatch method isn't a good idea after trying to implement the full idea :slight_smile:

1 Like

Hi Oliver, love your contribution. Want to connect, do you have a moment?