Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion lib/minga_editor/frontend/frame_transaction.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()
]
Expand Down
6 changes: 1 addition & 5 deletions macos/Sources/Renderer/CommandDispatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
172 changes: 167 additions & 5 deletions macos/Tests/MingaTests/CommandDispatcherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)] = [
Expand Down Expand Up @@ -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")
Expand Down
25 changes: 25 additions & 0 deletions macos/Tests/MingaTests/SettingsStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
22 changes: 22 additions & 0 deletions test/minga_editor/frontend/emit_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)

Expand Down
11 changes: 11 additions & 0 deletions test/minga_editor/frontend/frame_transaction_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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([
Expand Down
41 changes: 41 additions & 0 deletions test/minga_editor/handlers/gui_action_handler_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading