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.
4 files changed,
+107
−13
native/apple/Sources/DocsKit/DocumentCommands.swift+2 −1native/apple/Sources/DocsKit/DocumentToolbarSelection.swift+22 −0native/apple/Sources/DocsKit/EditorToolbar.swift+23 −12native/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")) |
| + } |
| +} |