diff --git a/mobile/src/terminal/document/terminal-theme.ts b/mobile/src/terminal/document/terminal-theme.ts index 1d46a06521b..dd75fc1c7b0 100644 --- a/mobile/src/terminal/document/terminal-theme.ts +++ b/mobile/src/terminal/document/terminal-theme.ts @@ -172,10 +172,15 @@ export type TerminalDocumentThemeTarget = { export function applyTerminalTheme( scope: TerminalDocumentScope, - input: TerminalDocumentThemeMessage + input: TerminalDocumentThemeMessage, + force = false ) { scope.terminalThemeInput = input - scope.terminalTheme = normalizeTerminalTheme(scope, input) + const nextTheme = normalizeTerminalTheme(scope, input) + const themeChanged = Object.keys(scope.defaultTheme).some( + (key) => scope.terminalTheme[key] !== nextTheme[key] + ) + scope.terminalTheme = nextTheme const background = scope.terminalTheme.background || TERMINAL_BACKGROUND_FALLBACK scope.paintDocumentBackground(background) // Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754); @@ -186,7 +191,12 @@ export function applyTerminalTheme( scope.terminalMinimumContrastRatio = publishedFloor === null ? resolveTerminalContrastFloor(background) : publishedFloor if (scope.term) { - scope.term.options.theme = scope.terminalTheme - scope.term.options.minimumContrastRatio = scope.terminalMinimumContrastRatio + // Reassigning the theme resets live OSC palette changes; visibility recovery still repaints. + if (force || themeChanged) { + scope.term.options.theme = scope.terminalTheme + } + if (scope.term.options.minimumContrastRatio !== scope.terminalMinimumContrastRatio) { + scope.term.options.minimumContrastRatio = scope.terminalMinimumContrastRatio + } } } diff --git a/mobile/src/terminal/document/webgl-recovery.ts b/mobile/src/terminal/document/webgl-recovery.ts index 663449aaa50..26151e7d0a4 100644 --- a/mobile/src/terminal/document/webgl-recovery.ts +++ b/mobile/src/terminal/document/webgl-recovery.ts @@ -91,7 +91,7 @@ function onDocumentVisibilityChange(scope: TerminalDocumentScope) { } // Why: iOS may restore the xterm model while discarding GPU pixels/theme // paint state, so visibility must rebuild the atlas and repaint every row. - applyTerminalTheme(scope, scope.terminalThemeInput) + applyTerminalTheme(scope, scope.terminalThemeInput, true) try { if (scope.webglAddon && scope.webglAddon.clearTextureAtlas) { scope.webglAddon.clearTextureAtlas() diff --git a/mobile/src/terminal/terminal-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts index bea9e2e1ca0..adb049e859d 100644 --- a/mobile/src/terminal/terminal-webview-engine.test.ts +++ b/mobile/src/terminal/terminal-webview-engine.test.ts @@ -7,6 +7,7 @@ import { XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { createTerminalDocumentScope } from './document/document-scope' import { attachWebglAddon, startWebglRecovery } from './document/webgl-recovery' import { XTERM_HTML } from './terminal-webview-html' +import { applyTerminalTheme } from './document/terminal-theme' function createWebglRecoveryHarness(failSecondAttach = false) { const timers: Array<() => void> = [] @@ -209,17 +210,19 @@ describe('terminal WebView bundled engine', () => { it('reapplies theme, clears the active atlas, and refreshes when visible', () => { const harness = createWebglRecoveryHarness() + applyTerminalTheme(harness.scope, harness.scope.terminalThemeInput) + const firstTheme = harness.term.options.theme // Hidden: nothing is re-applied, because a repaint of an invisible terminal is wasted. happy-dom // reports a visible document, so the hidden arm is the stub and the visible one is the default. vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden') harness.fireVisibilityChange() - expect(harness.term.options.theme).toEqual({}) + expect(harness.term.options.theme).toBe(firstTheme) vi.restoreAllMocks() harness.fireVisibilityChange() // The theme is re-applied through the document's own path, read off the terminal it wrote to. - expect(harness.term.options.theme).not.toEqual({}) + expect(harness.term.options.theme).not.toBe(firstTheme) expect(harness.addons[0]?.clearTextureAtlas).toHaveBeenCalledTimes(1) expect(harness.term.refresh).toHaveBeenCalledTimes(1) }) diff --git a/mobile/src/terminal/terminal-webview-theme.test.ts b/mobile/src/terminal/terminal-webview-theme.test.ts index 8af147e0f31..e8534fb8d3c 100644 --- a/mobile/src/terminal/terminal-webview-theme.test.ts +++ b/mobile/src/terminal/terminal-webview-theme.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createTerminalDocumentScope } from './document/document-scope' import { applyTerminalTheme, resolveTerminalContrastFloor } from './document/terminal-theme' import type { @@ -113,3 +113,68 @@ describe('mobile terminal-webview contrast floor gate', () => { }) }) }) + +describe('mobile terminal theme idempotence', () => { + function countingThemeScope() { + const term: TerminalDocumentThemeTarget = { options: { minimumContrastRatio: DARK_FLOOR } } + const scope = themeScope(term) + scope.terminalTheme = { ...scope.defaultTheme } + term.options.theme = scope.terminalTheme + const themeWrite = vi.fn() + const contrastWrite = vi.fn() + let theme = term.options.theme + let contrast = term.options.minimumContrastRatio + Object.defineProperty(term.options, 'theme', { + get: () => theme, + set: (next) => { + themeWrite(next) + theme = next + }, + configurable: true + }) + Object.defineProperty(term.options, 'minimumContrastRatio', { + get: () => contrast, + set: (next) => { + contrastWrite(next) + contrast = next + }, + configurable: true + }) + return { scope, term, themeWrite, contrastWrite } + } + + it('preserves a live palette through repeated equal normalized host themes', () => { + const { scope, term, themeWrite, contrastWrite } = countingThemeScope() + const livePalette = term.options.theme + const second = { theme: { foreground: '#c0caf5', background: '#1a1b26', ignored: '#ffffff' } } + applyTerminalTheme(scope, { theme: { background: '#1a1b26' } }) + applyTerminalTheme(scope, second) + expect(themeWrite).not.toHaveBeenCalled() + expect(contrastWrite).not.toHaveBeenCalled() + expect(term.options.theme).toBe(livePalette) + expect(scope.terminalThemeInput).toBe(second) + }) + + it('changes palette and published contrast independently', () => { + const { scope, themeWrite, contrastWrite } = countingThemeScope() + applyTerminalTheme(scope, { theme: { background: '#282828' } }) + expect(themeWrite).toHaveBeenCalledTimes(1) + expect(contrastWrite).not.toHaveBeenCalled() + applyTerminalTheme(scope, { theme: { background: '#282828' }, minimumContrastRatio: 1 }) + expect(themeWrite).toHaveBeenCalledTimes(1) + expect(contrastWrite).toHaveBeenCalledExactlyOnceWith(1) + }) + + it('forces an identical palette for visibility recovery', () => { + const { scope, themeWrite } = countingThemeScope() + applyTerminalTheme(scope, { theme: { ...scope.defaultTheme } }, true) + expect(themeWrite).toHaveBeenCalledTimes(1) + }) + + it('retains theme and published contrast before terminal construction', () => { + const scope = themeScope() + applyTerminalTheme(scope, { theme: { background: '#282828' }, minimumContrastRatio: 1 }) + expect(scope.terminalTheme.background).toBe('#282828') + expect(scope.terminalMinimumContrastRatio).toBe(1) + }) +})