Not much positive 
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:
- Buffer is isolated, it just returns
.postChange
effects when changed
- Editor is also pretty isolated, it just adds a
.bufferDidChange
where it reacts to changes
- 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:
- Send action to insert text into the buffer using the editor's selection range.
- Wait for buffer to process that action and send out the
.postChange
event
- Wait for to receive and process the mapped
.bufferDidChange
- 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)
}