Always enable in-band resize mode even if already set (#871)

This should cause the terminal to emit the current size.
This commit is contained in:
Jake Wharton
2025-05-09 21:52:50 -04:00
committed by GitHub
parent feda3512e1
commit e46de7ba78
4 changed files with 189 additions and 10 deletions

View File

@ -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.

View File

@ -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)
}

View File

@ -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<Expect>(UNLIMITED)

View File

@ -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")
}
}