oficina / docs Public

Keep Docs formatting icons stable during native typing

Separate durable editing availability from transient replica synchronization. Retain the last valid toolbar selection while native input is pending, with all editing command guards preserved. Reviewed by GPT-6 Astra; 30 XCTest and 14 Swift Testing cases passed.
Paul Campbell pushed by codex agent, operated by paul@keymail.dev d9aa3028ccc4d8966cb662020b69fdbc4956e6ca parent bc1286e
4 files changed, +107 −13
  • native/apple/Sources/DocsKit/DocumentCommands.swift +2 −1
  • native/apple/Sources/DocsKit/DocumentToolbarSelection.swift +22 −0
  • native/apple/Sources/DocsKit/EditorToolbar.swift +23 −12
  • native/apple/Tests/DocsKitTests/DocumentToolbarSelectionTests.swift +60 −0

CI passed — run details

diff --git a/native/apple/Sources/DocsKit/DocumentCommands.swift b/native/apple/Sources/DocsKit/DocumentCommands.swift
index e234cd8..ba6d055 100644
--- a/native/apple/Sources/DocsKit/DocumentCommands.swift
+++ b/native/apple/Sources/DocsKit/DocumentCommands.swift
@@ -3,8 +3,9 @@ import Foundation
@MainActor struct DocumentCommands {
let model: DocsModel
let content: NSAttributedString
+ var isAvailable: Bool { model.detail?.can_write == true && !model.inputPaused }
var isEnabled: Bool {
- model.detail?.can_write == true && !model.composing && !model.hasPendingInput
+ isAvailable && !model.composing && !model.hasPendingInput
&& model.renderedGeneration == model.projectionGeneration
}
func hasMark(_ name: String) -> Bool {
diff --git a/native/apple/Sources/DocsKit/DocumentToolbarSelection.swift b/native/apple/Sources/DocsKit/DocumentToolbarSelection.swift
new file mode 100644
index 0000000..0889c84
--- /dev/null
+++ b/native/apple/Sources/DocsKit/DocumentToolbarSelection.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+/// Only read formatting from a selection whose projection has caught up with
+/// native text input. While it catches up, the toolbar keeps its last valid state.
+struct DocumentToolbarSelection: Equatable {
+ var marks: Set<String> = []
+ var blocks: Set<String> = []
+ var canUndo = false
+ var canRedo = false
+ var hasSelection = false
+
+ init() {}
+
+ @MainActor init?(commands: DocumentCommands) {
+ guard commands.isEnabled else { return nil }
+ marks = Set(["strong", "em", "code"].filter { commands.hasMark($0) })
+ blocks = Set(["bullet_list", "ordered_list", "blockquote"].filter { commands.hasBlock($0) })
+ canUndo = commands.model.projection?.canUndo == true
+ canRedo = commands.model.projection?.canRedo == true
+ hasSelection = commands.model.selection.length > 0
+ }
+}
diff --git a/native/apple/Sources/DocsKit/EditorToolbar.swift b/native/apple/Sources/DocsKit/EditorToolbar.swift
index e4931a9..df1cf05 100644
--- a/native/apple/Sources/DocsKit/EditorToolbar.swift
+++ b/native/apple/Sources/DocsKit/EditorToolbar.swift
@@ -2,11 +2,15 @@ import SwiftUI
@MainActor struct EditorToolbar: View {
@Bindable var model: DocsModel
let content: NSAttributedString
+ @State private var settledSelection = DocumentToolbarSelection()
+ private var selection: DocumentToolbarSelection { currentSelection ?? settledSelection }
+ private var currentSelection: DocumentToolbarSelection? { DocumentToolbarSelection(commands: actions) }
var body: some View {
+ let selection = self.selection
ScrollView(.horizontal) {
HStack(spacing: 12) {
- command("docs.editor.toolbar.undo", icon: "arrow.uturn.backward", op: "undo", enabled: model.projection?.canUndo == true)
- command("docs.editor.toolbar.redo", icon: "arrow.uturn.forward", op: "redo", enabled: model.projection?.canRedo == true)
+ command("docs.editor.toolbar.undo", icon: "arrow.uturn.backward", op: "undo", enabled: selection.canUndo)
+ command("docs.editor.toolbar.redo", icon: "arrow.uturn.forward", op: "redo", enabled: selection.canRedo)
Divider().frame(height: 24)
Menu {
Button { block("paragraph") } label: { Text("docs.editor.toolbar.block.paragraph", bundle: DocsResources.bundle) }
@@ -17,12 +21,12 @@ import SwiftUI
ForEach(["bullet_list", "ordered_list", "blockquote"], id: \.self) { type in
Button { block(type) } label: { Image(systemName: type == "bullet_list" ? "list.bullet" : type == "ordered_list" ? "list.number" : "text.quote") }
.accessibilityLabel(Text(LocalizedStringKey("native." + type), bundle: DocsResources.bundle))
- .foregroundStyle(actions.hasBlock(type) ? Color.accentColor : Color.primary)
- .accessibilityAddTraits(actions.hasBlock(type) ? .isSelected : [])
+ .foregroundStyle(selection.blocks.contains(type) ? Color.accentColor : Color.primary)
+ .accessibilityAddTraits(selection.blocks.contains(type) ? .isSelected : [])
}
- mark("docs.editor.toolbar.bold", icon: "bold", name: "strong")
- mark("docs.editor.toolbar.italic", icon: "italic", name: "em")
- mark("docs.editor.toolbar.code", icon: "chevron.left.forwardslash.chevron.right", name: "code")
+ mark("docs.editor.toolbar.bold", icon: "bold", name: "strong", active: selection.marks.contains("strong"))
+ mark("docs.editor.toolbar.italic", icon: "italic", name: "em", active: selection.marks.contains("em"))
+ mark("docs.editor.toolbar.code", icon: "chevron.left.forwardslash.chevron.right", name: "code", active: selection.marks.contains("code"))
Menu {
ForEach(["sans", "serif", "mono"], id: \.self) { family in
Button { format("font_" + family, exclusive: ["font_sans", "font_serif", "font_mono"]) } label: { Text(LocalizedStringKey("docs.editor.toolbar.font." + family), bundle: DocsResources.bundle) }
@@ -39,18 +43,25 @@ import SwiftUI
}
command("docs.editor.menu.format.indent", icon: "increase.indent", op: "indent", enabled: true)
command("docs.editor.menu.format.outdent", icon: "decrease.indent", op: "outdent", enabled: true)
- command("docs.editor.menu.format.clear", icon: "eraser", op: "clear-format", enabled: model.selection.length > 0)
+ command("docs.editor.menu.format.clear", icon: "eraser", op: "clear-format", enabled: selection.hasSelection)
}.buttonStyle(.borderless).padding(12)
- }.disabled(!actions.isEnabled)
+ }
+ // Replica work briefly gates commands on every keystroke. Keep that
+ // synchronization gate out of disabled styling so the bar does not blink.
+ .disabled(!actions.isAvailable)
+ .allowsHitTesting(actions.isEnabled)
+ .onChange(of: currentSelection, initial: true) { _, value in
+ if let value { settledSelection = value }
+ }
}
private func command(_ key: String, icon: String, op: String, enabled: Bool) -> some View {
Button { actions.perform(op) } label: { Image(systemName: icon) }
.accessibilityLabel(Text(LocalizedStringKey(key), bundle: DocsResources.bundle)).disabled(!enabled)
}
- private func mark(_ key: String, icon: String, name: String) -> some View {
+ private func mark(_ key: String, icon: String, name: String, active: Bool) -> some View {
Button { format(name) } label: { Image(systemName: icon) }.accessibilityLabel(Text(LocalizedStringKey(key), bundle: DocsResources.bundle))
- .foregroundStyle(actions.hasMark(name) ? Color.accentColor : Color.primary)
- .accessibilityAddTraits(actions.hasMark(name) ? .isSelected : [])
+ .foregroundStyle(active ? Color.accentColor : Color.primary)
+ .accessibilityAddTraits(active ? .isSelected : [])
}
private var actions: DocumentCommands { DocumentCommands(model: model, content: content) }
private func format(_ name: String, exclusive: [String] = []) { actions.format(name, exclusive: exclusive) }
diff --git a/native/apple/Tests/DocsKitTests/DocumentToolbarSelectionTests.swift b/native/apple/Tests/DocsKitTests/DocumentToolbarSelectionTests.swift
new file mode 100644
index 0000000..b0a55ac
--- /dev/null
+++ b/native/apple/Tests/DocsKitTests/DocumentToolbarSelectionTests.swift
@@ -0,0 +1,60 @@
+import Foundation
+import RastrilloApple
+import XCTest
+@testable import DocsKit
+
+final class DocumentToolbarSelectionTests: XCTestCase {
+ @MainActor func testTypingKeepsPresentationAvailableWhileCommandsWaitForProjection() async throws {
+ let model = DocsModel(client: NativeHTTP(origin: URL(string: "https://example.invalid")!))
+ model.detail = DocumentDetail(sqid: "fixture", title: "Fixture", guid: "fixture", can_write: true, can_share: false)
+ model.projection = CRDTProjection(children: [], canUndo: true, canRedo: false)
+ let content = NSMutableAttributedString(string: "bold\n")
+ content.addAttribute(.docsMarks, value: try JSONEncoder().encode(["strong": JSONValue.object([:])]), range: NSRange(location: 0, length: 4))
+ model.selection = NSRange(location: 4, length: 0)
+ let commands = DocumentCommands(model: model, content: content)
+ let settled = try XCTUnwrap(DocumentToolbarSelection(commands: commands))
+ XCTAssertEqual(settled.marks, ["strong"])
+ XCTAssertTrue(settled.canUndo)
+
+ // Native input advances the caret before the replica publishes its text.
+ XCTAssertTrue(model.enqueue(["op": .string("display_edit"), "at": .number(4), "length": .number(0), "text": .string("!!")]))
+ model.selection = NSRange(location: 6, length: 0)
+ XCTAssertTrue(model.hasPendingInput)
+ XCTAssertTrue(commands.isAvailable, "Transient input must not dim the whole toolbar")
+ XCTAssertFalse(commands.isEnabled)
+ XCTAssertNil(DocumentToolbarSelection(commands: commands), "Do not replace the bold highlight using stale text")
+ XCTAssertFalse(commands.format("em"), "Formatting must still reject an unacknowledged selection")
+ XCTAssertNil(model.typingMarks)
+ await model.discardAll()
+
+ // A new projection still needs the native view's acknowledgement.
+ model.detail = DocumentDetail(sqid: "fixture", title: "Fixture", guid: "fixture", can_write: true, can_share: false)
+ model.projection = CRDTProjection(children: [], canUndo: true, canRedo: false)
+ model.projectionGeneration = 2
+ model.selection = NSRange(location: 4, length: 0)
+ XCTAssertTrue(commands.isAvailable)
+ XCTAssertNil(DocumentToolbarSelection(commands: commands))
+ model.didRender(2)
+ XCTAssertEqual(DocumentToolbarSelection(commands: commands), settled)
+ model.selection = NSRange(location: 0, length: 2)
+ XCTAssertTrue(try XCTUnwrap(DocumentToolbarSelection(commands: commands)).hasSelection)
+ }
+
+ @MainActor func testReadOnlyAndPausedInputRemainDisabled() {
+ let model = DocsModel(client: NativeHTTP(origin: URL(string: "https://example.invalid")!))
+ let commands = DocumentCommands(model: model, content: NSAttributedString(string: ""))
+ model.detail = DocumentDetail(sqid: "fixture", title: "Fixture", guid: "fixture", can_write: false, can_share: false)
+ XCTAssertFalse(commands.isAvailable)
+ XCTAssertFalse(commands.format("strong"))
+ model.detail = DocumentDetail(sqid: "fixture", title: "Fixture", guid: "fixture", can_write: true, can_share: false)
+ model.inputPaused = true
+ XCTAssertFalse(commands.isAvailable)
+ XCTAssertFalse(commands.format("strong"))
+ model.inputPaused = false
+ model.composing = true
+ XCTAssertTrue(commands.isAvailable)
+ XCTAssertFalse(commands.isEnabled)
+ XCTAssertNil(DocumentToolbarSelection(commands: commands))
+ XCTAssertFalse(commands.format("strong"))
+ }
+}