diff --git a/CHANGELOG.md b/CHANGELOG.md index a2bba1c4..db81d2ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,9 @@ [Unreleased]: https://github.com/JakeWharton/mosaic/compare/0.16.0...HEAD Changed: -- Unsolicited focus and theme events are now ignored unless the terminal has reported that it supports focus and theme, respectively. - This should have no real impact on anything, except that now the `Terminal.capabilities` value can now be trusted to indicate whether `Terminal.state` will ever change. +- Unsolicited focus, theme, and resize events are now ignored unless the terminal has reported that it supports each of those modes. + This should have no real impact on anything, except that now the `Terminal.capabilities` value can now be trusted to indicate whether `Terminal.state` will ever change in the case of focus and theme. + For terminal size, a platform-specific fallback exists which will attempt to still correctly report the size, but asynchronously. - Terminal theme is now always queried for an initial value regardless of whether the terminal supports sending theme changes. - Java 11 is now the minimum-supported JVM version. diff --git a/mosaic-tty-terminal/src/commonMain/kotlin/com/jakewharton/mosaic/tty/terminal/TtyTerminal.kt b/mosaic-tty-terminal/src/commonMain/kotlin/com/jakewharton/mosaic/tty/terminal/TtyTerminal.kt index 5d01fbab..c72cdb26 100644 --- a/mosaic-tty-terminal/src/commonMain/kotlin/com/jakewharton/mosaic/tty/terminal/TtyTerminal.kt +++ b/mosaic-tty-terminal/src/commonMain/kotlin/com/jakewharton/mosaic/tty/terminal/TtyTerminal.kt @@ -214,10 +214,10 @@ public suspend fun Tty.asTerminalIn( } inBandResizeMode -> { - inBandResizeEvents = event.setting.isSupported - if (event.setting == Setting.Reset) { - toggleInBandResize = true - // Enabling in-band resize will trigger an initial event. + if (event.setting.isSupported) { + inBandResizeEvents = true + toggleInBandResize = event.setting == Setting.Reset + // Enabling in-band resize (even if already set) should trigger an initial event. write(inBandResizeEnable) } } @@ -226,7 +226,7 @@ public suspend fun Tty.asTerminalIn( is OperatingStatusResponseEvent -> { if (stage == StageCapabilityQueries) { - if (focusEvents or toggleInBandResize) { + if (focusEvents or inBandResizeEvents) { // By enabling these modes (or by sending an explicit default value query after // enabling the mode) wait for a reply about the default with a second DSR. stage = StageDefaultQueries @@ -297,7 +297,11 @@ public suspend fun Tty.asTerminalIn( } is ResizeEvent -> { - size.value = Terminal.Size(event.columns, event.rows, event.width, event.height) + if (inBandResizeEvents) { + size.value = Terminal.Size(event.columns, event.rows, event.width, event.height) + } else { + // TODO Report unsolicited resize events... somewhere. + } } is SystemThemeEvent -> { @@ -332,7 +336,7 @@ public suspend fun Tty.asTerminalIn( write("\r\n") } - if (!toggleInBandResize) { + if (!inBandResizeEvents) { currentSize().let { (columns, rows) -> size.value = Terminal.Size(columns, rows) } diff --git a/mosaic-tty-terminal/src/commonTest/kotlin/com/jakewharton/mosaic/tty/terminal/TerminalTester.kt b/mosaic-tty-terminal/src/commonTest/kotlin/com/jakewharton/mosaic/tty/terminal/TerminalTester.kt index a0a4284d..d4ae0d4f 100644 --- a/mosaic-tty-terminal/src/commonTest/kotlin/com/jakewharton/mosaic/tty/terminal/TerminalTester.kt +++ b/mosaic-tty-terminal/src/commonTest/kotlin/com/jakewharton/mosaic/tty/terminal/TerminalTester.kt @@ -30,7 +30,7 @@ fun terminalTest(block: suspend TerminalTester.() -> Unit) { } class TerminalTester( - private val testTty: TestTty, + val testTty: TestTty, ) { private data class Expect(val output: ByteString, val reply: ByteString) private val expects = Channel(UNLIMITED) diff --git a/mosaic-tty-terminal/src/commonTest/kotlin/com/jakewharton/mosaic/tty/terminal/TtyTerminalResizeTest.kt b/mosaic-tty-terminal/src/commonTest/kotlin/com/jakewharton/mosaic/tty/terminal/TtyTerminalResizeTest.kt new file mode 100644 index 00000000..f69f2b2f --- /dev/null +++ b/mosaic-tty-terminal/src/commonTest/kotlin/com/jakewharton/mosaic/tty/terminal/TtyTerminalResizeTest.kt @@ -0,0 +1,174 @@ +package com.jakewharton.mosaic.tty.terminal + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import com.jakewharton.mosaic.terminal.ResizeEvent +import com.jakewharton.mosaic.terminal.Terminal +import kotlin.test.Test + +class TtyTerminalResizeTest { + @Test fun noReply() = terminalTest { + expect("${CSI}0c", reply = "$CSI?62;22c") + expect("${CSI}5n", reply = "${CSI}0n") + + val teardown = withTerminal { setup -> + assertThat(capabilities.inBandResizeEvents).isFalse() + assertThat(state.size.value).isEqualTo(Terminal.Size(80, 24, 0, 0)) + + // No attempt to enable in-band resize events. + assertThat(setup).doesNotContain("$CSI?2048h") + + // Write an unsolicited resize event which should be ignored. + ptyWrite("${CSI}48;40;100;400;800t") + assertThat(events.receive()).isEqualTo(ResizeEvent(100, 40, 800, 400)) + assertThat(state.size.value).isEqualTo(Terminal.Size(80, 24, 0, 0)) + + // Platform-specific resize event should be honored. + testTty.resize(100, 40, 0, 0) + assertThat(events.receive()).isEqualTo(ResizeEvent(100, 40, 0, 0)) + assertThat(state.size.value).isEqualTo(Terminal.Size(100, 40, 0, 0)) + } + + // No attempt to disable in-band resize events. + assertThat(teardown).doesNotContain("$CSI?2048l") + } + + @Test fun replySetNoInitialValue() = terminalTest { + expect("${CSI}0c", reply = "$CSI?62;22c") + // Resize events are set (i.e, enabled). + expect("$CSI?2048\$p", reply = "$CSI?2048;1\$y") + expect("${CSI}5n", reply = "${CSI}0n") + // Second DSR is used to check for initial size. + expect("${CSI}5n", reply = "${CSI}0n") + + val teardown = withTerminal { setup -> + assertThat(capabilities.inBandResizeEvents).isTrue() + assertThat(state.size.value).isEqualTo(Terminal.Size(80, 24, 0, 0)) + + // Resize events re-enabled to trigger initial value reply. + assertThat(setup).contains("$CSI?2048h") + + // Write resize events which should be honored. + ptyWrite("${CSI}48;40;100;400;800t") + assertThat(events.receive()).isEqualTo(ResizeEvent(100, 40, 800, 400)) + assertThat(state.size.value).isEqualTo(Terminal.Size(100, 40, 800, 400)) + } + + // Resize events are left enabled. + assertThat(teardown).doesNotContain("$CSI?2048l") + } + + @Test fun replySetWithInitialValue() = terminalTest { + expect("${CSI}0c", reply = "$CSI?62;22c") + // Resize events are set (i.e, enabled). + expect("$CSI?2048\$p", reply = "$CSI?2048;1\$y") + expect("${CSI}5n", reply = "${CSI}0n") + expect("$CSI?2048h", reply = "${CSI}48;30;90;300;700t") + expect("${CSI}5n", reply = "${CSI}0n") + + withTerminal { + assertThat(state.size.value).isEqualTo(Terminal.Size(90, 30, 700, 300)) + } + } + + @Test fun replyResetNoInitialValue() = terminalTest { + expect("${CSI}0c", reply = "$CSI?62;22c") + // Resize events are reset (i.e, not enabled). + expect("$CSI?2048\$p", reply = "$CSI?2048;2\$y") + expect("${CSI}5n", reply = "${CSI}0n") + // Second DSR is used to check for initial size. + expect("${CSI}5n", reply = "${CSI}0n") + + val teardown = withTerminal { setup -> + assertThat(capabilities.inBandResizeEvents).isTrue() + assertThat(state.size.value).isEqualTo(Terminal.Size(80, 24, 0, 0)) + + // Enable resize events. + assertThat(setup).contains("$CSI?2048h") + + // Write resize events which should be honored. + ptyWrite("${CSI}48;40;100;400;800t") + assertThat(events.receive()).isEqualTo(ResizeEvent(100, 40, 800, 400)) + assertThat(state.size.value).isEqualTo(Terminal.Size(100, 40, 800, 400)) + } + + // Disable resize events. + assertThat(teardown).contains("$CSI?2048l") + } + + @Test fun replyResetWithInitialValue() = terminalTest { + expect("${CSI}0c", reply = "$CSI?62;22c") + // Resize events are reset (i.e, not enabled). + expect("$CSI?2048\$p", reply = "$CSI?2048;2\$y") + expect("${CSI}5n", reply = "${CSI}0n") + expect("$CSI?2048h", reply = "${CSI}48;30;90;300;700t") + expect("${CSI}5n", reply = "${CSI}0n") + + withTerminal { + assertThat(state.size.value).isEqualTo(Terminal.Size(90, 30, 700, 300)) + } + } + + @Test fun replyPermanentlySetNoInitialValue() = terminalTest { + expect("${CSI}0c", reply = "$CSI?62;22c") + // Resize events are permanently set (i.e, always enabled). + expect("$CSI?2048\$p", reply = "$CSI?2048;3\$y") + expect("${CSI}5n", reply = "${CSI}0n") + // Second DSR is used to check for initial size. + expect("${CSI}5n", reply = "${CSI}0n") + + val teardown = withTerminal { setup -> + assertThat(capabilities.inBandResizeEvents).isTrue() + assertThat(state.size.value).isEqualTo(Terminal.Size(80, 24, 0, 0)) + + // Focus events re-enabled to possibly trigger initial value reply. + assertThat(setup).contains("$CSI?2048h") + + // Write resize events which should be honored. + ptyWrite("${CSI}48;40;100;400;800t") + assertThat(events.receive()).isEqualTo(ResizeEvent(100, 40, 800, 400)) + assertThat(state.size.value).isEqualTo(Terminal.Size(100, 40, 800, 400)) + } + + // No attempt to disable focus events. + assertThat(teardown).doesNotContain("$CSI?2048l") + } + + @Test fun replyPermanentlySetWithInitialValue() = terminalTest { + expect("${CSI}0c", reply = "$CSI?62;22c") + // Resize events are permanently set (i.e, always enabled). + expect("$CSI?2048\$p", reply = "$CSI?2048;3\$y") + expect("${CSI}5n", reply = "${CSI}0n") + expect("$CSI?2048h", reply = "${CSI}48;30;90;300;700t") + expect("${CSI}5n", reply = "${CSI}0n") + + withTerminal { + assertThat(state.size.value).isEqualTo(Terminal.Size(90, 30, 700, 300)) + } + } + + @Test fun replyPermanentlyReset() = terminalTest { + expect("${CSI}0c", reply = "$CSI?62;22c") + // Resize events are permanently reset (i.e, not supported). + expect("$CSI?2048\$p", reply = "$CSI?2048;4\$y") + expect("${CSI}5n", reply = "${CSI}0n") + + val teardown = withTerminal { setup -> + assertThat(capabilities.inBandResizeEvents).isFalse() + assertThat(state.size.value).isEqualTo(Terminal.Size(80, 24, 0, 0)) + + // No attempt to enable focus events. + assertThat(setup).doesNotContain("$CSI?2048h") + + // Write an unsolicited resize event which should be ignored. + ptyWrite("${CSI}48;40;100;400;800t") + assertThat(events.receive()).isEqualTo(ResizeEvent(100, 40, 800, 400)) + assertThat(state.size.value).isEqualTo(Terminal.Size(80, 24, 0, 0)) + } + + // No attempt to disable focus events. + assertThat(teardown).doesNotContain("$CSI?2048l") + } +}