diff --git a/lib/minga_editor/frontend/frame_transaction.ex b/lib/minga_editor/frontend/frame_transaction.ex index 39235d0b3..ab4414ab0 100644 --- a/lib/minga_editor/frontend/frame_transaction.ex +++ b/lib/minga_editor/frontend/frame_transaction.ex @@ -17,7 +17,6 @@ defmodule MingaEditor.Frontend.FrameTransaction do Opcodes.set_font(), Opcodes.set_font_fallback(), Opcodes.register_font(), - Opcodes.gui_config_state(), Opcodes.clipboard_write(), Opcodes.presentation_operation() ] diff --git a/macos/Sources/Renderer/CommandDispatcher.swift b/macos/Sources/Renderer/CommandDispatcher.swift index 5e3a8d4bc..3070abc05 100644 --- a/macos/Sources/Renderer/CommandDispatcher.swift +++ b/macos/Sources/Renderer/CommandDispatcher.swift @@ -471,13 +471,9 @@ final class CommandDispatcher { // setTitle / setWindowBg / setLinkCursor / clipboardWrite: post-commit // side-channels. // protocolError: handshake rejection, always pre-transaction. - // setFont / setFontFallback / registerFont / guiConfigState: startup + // setFont / setFontFallback / registerFont: startup // config emitted before the first frame (equivalent to Go's CommandNoop // for font commands, but Swift actually applies them). - case .guiConfigState: - // Settings is an independently interactive scene and has no frame consumer. - applyImmediately(command) - case .setTitle, .setWindowBg, .setLinkCursor, .protocolError, .setFont, .setFontFallback, .registerFont, .clipboardWrite: if openFrameSeq != nil { diff --git a/macos/Tests/MingaTests/CommandDispatcherTests.swift b/macos/Tests/MingaTests/CommandDispatcherTests.swift index 6cfeeb394..cf2567e6c 100644 --- a/macos/Tests/MingaTests/CommandDispatcherTests.swift +++ b/macos/Tests/MingaTests/CommandDispatcherTests.swift @@ -2007,6 +2007,17 @@ struct CommandDispatcherStagingTests { ) } + private func config(cursorBlink: Bool, fontSize: Int = 13) -> Wire.ConfigState { + Wire.ConfigState( + options: [ + "cursor_blink": .bool(cursorBlink), + "font_size": .int(fontSize), + ], + themePreviews: [], + keybindings: [] + ) + } + private func decodedFrame(_ commands: [RenderCommand]) throws -> DecodedFrame { let decoded = try commands.map { command in DecodedCommand( @@ -3394,6 +3405,147 @@ struct CommandDispatcherStagingTests { #expect(effectCount == 0) } + @Test("settings from a frame missing its required theme remain unpublished") + @MainActor func rejectedConfigStatePreservesLastGoodSettings() throws { + let (dispatcher, gui) = makeDispatcher() + var cursorBlinkChanges: [Bool] = [] + var results: [FrameTransactionResult] = [] + gui.settingsState.onCursorBlinkChanged = { cursorBlinkChanges.append($0) } + dispatcher.onTransactionResult = { results.append($0) } + + dispatcher.dispatch(.beginFrame(frameSeq: 1, baseFrameSeq: 0, generation: 1)) + dispatcher.dispatch(.guiTheme(slots: completeThemeSlots())) + dispatcher.dispatch(.guiConfigState(config(cursorBlink: false, fontSize: 14))) + dispatcher.dispatch(.commitFrame(frameSeq: 1, seq: 0)) + + dispatcher.dispatch(.beginFrame(frameSeq: 2, baseFrameSeq: 0, generation: 1)) + dispatcher.dispatch(.guiConfigState(config(cursorBlink: true, fontSize: 18))) + + #expect(gui.settingsState.cursorBlink == false) + #expect(gui.settingsState.fontSize == 14) + #expect(cursorBlinkChanges == [false]) + + dispatcher.dispatch(.commitFrame(frameSeq: 2, seq: 0)) + + #expect(gui.settingsState.cursorBlink == false) + #expect(gui.settingsState.fontSize == 14) + #expect(cursorBlinkChanges == [false]) + #expect(results.last == .rejected( + generation: 1, frameSeq: 2, lastAppliedFrameSeq: 1, + reason: .missingTheme + )) + } + + @Test("settings from an abandoned frame never publish") + @MainActor func abandonedConfigStatePreservesLastGoodSettings() throws { + let (dispatcher, gui) = makeDispatcher() + var cursorBlinkChanges: [Bool] = [] + gui.settingsState.onCursorBlinkChanged = { cursorBlinkChanges.append($0) } + + dispatcher.dispatch(.beginFrame(frameSeq: 1, baseFrameSeq: 0, generation: 1)) + dispatcher.dispatch(.guiTheme(slots: completeThemeSlots())) + dispatcher.dispatch(.guiConfigState(config(cursorBlink: false, fontSize: 14))) + dispatcher.dispatch(.commitFrame(frameSeq: 1, seq: 0)) + + dispatcher.dispatch(.beginFrame(frameSeq: 2, baseFrameSeq: 1, generation: 1)) + dispatcher.dispatch(.guiConfigState(config(cursorBlink: true, fontSize: 18))) + dispatcher.dispatch(.beginFrame(frameSeq: 3, baseFrameSeq: 0, generation: 1)) + dispatcher.dispatch(.guiTheme(slots: completeThemeSlots())) + dispatcher.dispatch(.commitFrame(frameSeq: 3, seq: 0)) + + #expect(gui.settingsState.cursorBlink == false) + #expect(gui.settingsState.fontSize == 14) + #expect(cursorBlinkChanges == [false]) + } + + @Test("settings publish once when their complete frame commits") + @MainActor func committedConfigStatePublishesOnce() throws { + let (dispatcher, gui) = makeDispatcher() + var cursorBlinkChanges: [Bool] = [] + gui.settingsState.onCursorBlinkChanged = { cursorBlinkChanges.append($0) } + + dispatcher.dispatch(.beginFrame(frameSeq: 1, baseFrameSeq: 0, generation: 1)) + dispatcher.dispatch(.guiTheme(slots: completeThemeSlots())) + dispatcher.dispatch(.guiConfigState(config(cursorBlink: false, fontSize: 18))) + + #expect(gui.settingsState.isLoading) + #expect(gui.settingsState.cursorBlink) + #expect(cursorBlinkChanges.isEmpty) + + dispatcher.dispatch(.commitFrame(frameSeq: 1, seq: 0)) + + #expect(gui.settingsState.isLoading == false) + #expect(gui.settingsState.cursorBlink == false) + #expect(gui.settingsState.fontSize == 18) + #expect(cursorBlinkChanges == [false]) + } + + @Test("repeated settings replace in place under the staging resource limit") + @MainActor func repeatedConfigStateUsesLastUpdateAndReplacementWeight() throws { + let staging = FrameResourceWeight( + commands: 2, ownedUTF8Bytes: .max, arrayEntries: .max, + rows: .max, spans: .max, overlays: .max, + spliceEntries: .max, locatorEntries: .max + ) + let gui = GUIState() + let dispatcher = CommandDispatcher( + cols: 80, rows: 24, guiState: gui, + resourcePolicy: policy(stagingWeight: staging) + ) + var cursorBlinkChanges: [Bool] = [] + var results: [FrameTransactionResult] = [] + gui.settingsState.onCursorBlinkChanged = { cursorBlinkChanges.append($0) } + dispatcher.onTransactionResult = { results.append($0) } + + dispatcher.dispatch(.beginFrame(frameSeq: 1, baseFrameSeq: 0, generation: 1)) + dispatcher.dispatch(.guiTheme(slots: completeThemeSlots())) + dispatcher.dispatch(.guiConfigState(config(cursorBlink: true, fontSize: 14))) + dispatcher.dispatch(.guiConfigState(config(cursorBlink: false, fontSize: 18))) + dispatcher.dispatch(.commitFrame(frameSeq: 1, seq: 0)) + + #expect(gui.settingsState.cursorBlink == false) + #expect(gui.settingsState.fontSize == 18) + #expect(cursorBlinkChanges == [false]) + #expect(results == [.applied(generation: 1, frameSeq: 1)]) + } + + @Test("settings remain last-good when another staged command exceeds the resource limit") + @MainActor func resourceRejectedConfigStatePreservesLastGoodSettings() throws { + let staging = FrameResourceWeight( + commands: 2, ownedUTF8Bytes: .max, arrayEntries: .max, + rows: .max, spans: .max, overlays: .max, + spliceEntries: .max, locatorEntries: .max + ) + let gui = GUIState() + let dispatcher = CommandDispatcher( + cols: 80, rows: 24, guiState: gui, + resourcePolicy: policy(stagingWeight: staging) + ) + var cursorBlinkChanges: [Bool] = [] + var results: [FrameTransactionResult] = [] + gui.settingsState.onCursorBlinkChanged = { cursorBlinkChanges.append($0) } + dispatcher.onTransactionResult = { results.append($0) } + + dispatcher.dispatch(.beginFrame(frameSeq: 1, baseFrameSeq: 0, generation: 1)) + dispatcher.dispatch(.guiTheme(slots: completeThemeSlots())) + dispatcher.dispatch(.guiConfigState(config(cursorBlink: false, fontSize: 14))) + dispatcher.dispatch(.commitFrame(frameSeq: 1, seq: 0)) + + dispatcher.dispatch(.beginFrame(frameSeq: 2, baseFrameSeq: 1, generation: 1)) + dispatcher.dispatch(.guiConfigState(config(cursorBlink: true, fontSize: 18))) + dispatcher.dispatch(.setTitle("staged")) + dispatcher.dispatch(.setWindowBg(r: 0x22, g: 0x33, b: 0x44)) + dispatcher.dispatch(.commitFrame(frameSeq: 2, seq: 0)) + + #expect(gui.settingsState.cursorBlink == false) + #expect(gui.settingsState.fontSize == 14) + #expect(cursorBlinkChanges == [false]) + #expect(results.last == .rejected( + generation: 1, frameSeq: 2, lastAppliedFrameSeq: 1, + reason: .resourcePolicy + )) + } + @Test("invalid transcript operations reject without publishing sibling state") @MainActor func invalidTranscriptRejectsWholeTransaction() throws { let invalidCommands: [(RenderCommand, PreparedFrameRejection)] = [ @@ -3718,16 +3870,26 @@ struct CommandDispatcherStagingTests { #expect(requested.isEmpty) } - @Test("gui_config_state applies immediately with no open transaction") - @MainActor func guiConfigStateAppliesOutOfBand() throws { - let (dispatcher, _) = makeDispatcher() + @Test("gui_config_state outside a transaction rejects without mutating settings") + @MainActor func guiConfigStateOutOfBandRejects() throws { + let (dispatcher, gui) = makeDispatcher() var requested: [UInt32] = [] + var cursorBlinkChanges: [Bool] = [] dispatcher.onRequestKeyframe = { requested.append($0) } + gui.settingsState.onCursorBlinkChanged = { cursorBlinkChanges.append($0) } - let configState = Wire.ConfigState(options: [:], themePreviews: [], keybindings: []) + let configState = Wire.ConfigState( + options: ["cursor_blink": .bool(false)], + themePreviews: [], + keybindings: [] + ) dispatcher.dispatch(.guiConfigState(configState)) - #expect(requested.isEmpty) + #expect(gui.settingsState.isLoading) + #expect(gui.settingsState.cursorBlink) + #expect(cursorBlinkChanges.isEmpty) + #expect(requested == [0]) + #expect(gui.resyncState.pending) } @Test("clipboard_write applies immediately with no open transaction") diff --git a/macos/Tests/MingaTests/SettingsStateTests.swift b/macos/Tests/MingaTests/SettingsStateTests.swift index 468d1442d..1d8710f1a 100644 --- a/macos/Tests/MingaTests/SettingsStateTests.swift +++ b/macos/Tests/MingaTests/SettingsStateTests.swift @@ -61,4 +61,29 @@ struct SettingsStateTests { #expect(state.keybindings.count == 1) #expect(cursorBlinkChanges == [false]) } + + @Test("cursor blink callback reports each committed value transition once") + func cursorBlinkCallbackTracksCommittedTransitions() { + let state = SettingsState() + var cursorBlinkChanges: [Bool] = [] + state.onCursorBlinkChanged = { cursorBlinkChanges.append($0) } + + let disabled = Wire.ConfigState( + options: ["cursor_blink": .bool(false)], + themePreviews: [], + keybindings: [] + ) + let enabled = Wire.ConfigState( + options: ["cursor_blink": .bool(true)], + themePreviews: [], + keybindings: [] + ) + + state.apply(configState: disabled) + state.apply(configState: disabled) + state.apply(configState: enabled) + state.apply(configState: enabled) + + #expect(cursorBlinkChanges == [false, true]) + } } diff --git a/test/minga_editor/frontend/emit_test.exs b/test/minga_editor/frontend/emit_test.exs index 183b1cf38..dff7dd7c4 100644 --- a/test/minga_editor/frontend/emit_test.exs +++ b/test/minga_editor/frontend/emit_test.exs @@ -10,10 +10,12 @@ defmodule MingaEditor.Frontend.EmitTest do alias Minga.Buffer.Process, as: BufferProcess alias Minga.Editing.Completion alias Minga.RenderModel.Cursor + alias Minga.RenderModel.UI.ConfigState alias MingaEditor.RenderPipeline.ComposedFrame alias MingaEditor.Frontend.Capabilities alias MingaEditor.Frontend.Emit alias MingaEditor.Frontend.Emit.Context + alias MingaEditor.Frontend.FrameTransaction alias Minga.Core.Face alias Minga.Protocol.Opcodes alias Minga.RenderModel.Window, as: RenderWindow @@ -25,6 +27,7 @@ defmodule MingaEditor.Frontend.EmitTest do alias MingaEditor.Session.State, as: SessionState alias MingaEditor.Shell.Traditional.ModalWorkflow alias MingaEditor.State.ModalOverlay.Completion, as: CompletionPayload + alias MingaEditor.State.Appearance alias MingaEditor.State.Windows alias MingaEditor.UI.FontRegistry @@ -98,6 +101,25 @@ defmodule MingaEditor.Frontend.EmitTest do assert Enum.all?(commands, &is_binary/1) end + test "GUI config state is emitted inside the frame transaction" do + frame = ComposedFrame.new([], Cursor.new(0, 0, :block)) + config_state = %ConfigState{options: [{"cursor_blink", false}]} + state = gui_state(port_manager: Process.get(:emit_test_frontend)) + appearance = Appearance.cache_gui_config(state.appearance, config_state) + state = %{state | appearance: appearance} + ctx = Context.from_editor_state(state) + + Emit.emit(frame, ctx) + + commands = assert_receive_frame_commands() + config_index = Enum.find_index(commands, &match?(<<0x97, _::binary>>, &1)) + + assert config_index != nil + assert config_index > 0 + assert config_index < Enum.count(commands) - 1 + assert FrameTransaction.validate(commands) == :ok + end + test "semantic TUI path emits semantic window commands instead of cell-grid clear" do frame = build_frame_with_window(emit_state(), viewport_top: 0) diff --git a/test/minga_editor/frontend/frame_transaction_test.exs b/test/minga_editor/frontend/frame_transaction_test.exs index c19940975..d8cccc955 100644 --- a/test/minga_editor/frontend/frame_transaction_test.exs +++ b/test/minga_editor/frontend/frame_transaction_test.exs @@ -2,6 +2,8 @@ defmodule MingaEditor.Frontend.FrameTransactionTest do use ExUnit.Case, async: true alias Minga.Protocol.Opcodes + alias Minga.Frontend.Adapter.GUI.ConfigStateEncoder + alias Minga.RenderModel.UI.ConfigState alias MingaEditor.Frontend.FrameTransaction alias MingaEditor.Frontend.Protocol @@ -29,6 +31,15 @@ defmodule MingaEditor.Frontend.FrameTransactionTest do "opcode 0x71 outside a frame" end + test "rejects GUI config state outside a frame" do + command = ConfigStateEncoder.encode_command(%ConfigState{options: []}) + + assert {:error, {:out_of_transaction_command, opcode}} = + FrameTransaction.validate([command]) + + assert opcode == Opcodes.gui_config_state() + end + test "rejects retired cell-grid clear inside a frame" do assert {:error, {:retired_render_command, 0x12}} = FrameTransaction.validate([ diff --git a/test/minga_editor/handlers/gui_action_handler_test.exs b/test/minga_editor/handlers/gui_action_handler_test.exs index 18156c729..e9c3b817f 100644 --- a/test/minga_editor/handlers/gui_action_handler_test.exs +++ b/test/minga_editor/handlers/gui_action_handler_test.exs @@ -81,6 +81,38 @@ defmodule MingaEditor.Handlers.GuiActionHandlerTest do refute_receive {:"$gen_cast", {:render, _, _, _}}, 0 end + test "config_query refreshes settings and submits a framed keyframe response", %{ + sidebar_registry: table + } do + capabilities = %Capabilities{frontend_type: :native_gui, semantic_ui: true} + owner = self() + renderer = spawn(fn -> renderer_probe(owner) end) + on_exit(fn -> Process.exit(renderer, :kill) end) + state = base_state(table, backend: :gui, capabilities: capabilities) + state = %{state | render: MingaEditor.State.Render.connect_renderer(state.render, renderer)} + snapshot = MingaEditor.Input.Router.capture_snapshot(state) + revision = state.render.render_correlation.latest_intent_revision + + handled = GuiActionHandler.dispatch(state, :config_query) + + assert handled.appearance.gui_config_state != nil + assert handled.render.render_correlation.keyframe_pending? + + rendered = MingaEditor.Input.Router.post_action_housekeeping(handled, snapshot) + + assert rendered.render.render_correlation.latest_intent_revision == revision + 1 + + assert_receive {:renderer_reset, + %MingaEditor.RenderPipeline.Intent{ + frame: %MingaEditor.RenderPipeline.FrameIntent{ + force_keyframe?: false, + gui_config_state: config_state + } + }} + + assert config_state == handled.appearance.gui_config_state + end + test "space leader replay is key-local and outer housekeeping submits exactly one render", %{ sidebar_registry: table } do @@ -850,6 +882,15 @@ defmodule MingaEditor.Handlers.GuiActionHandlerTest do defp clear_window_reset_pending(state), do: state + defp renderer_probe(owner) do + receive do + {:"$gen_call", from, {:reset_connection, intent, _seq, _pushed_at}} -> + GenServer.reply(from, :ok) + send(owner, {:renderer_reset, intent}) + renderer_probe(owner) + end + end + defp power_thermal_events_registry do :"power_thermal_events_#{System.unique_integer([:positive])}" end