Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project uses semantic versioning while it remains pre-1.0.

## [Unreleased]

### Added

- Added opt-in, bounded text-only normal-screen resize recovery for application-owned semantic
history, preserving append chronology, retained-frame cursor/mouse state, redacted diagnostics,
and shared JVM/Scala Native behavior. #57, fixes #56

## [0.8.0] - 2026-08-02

### Added
Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,55 @@ IDs are remapped so retained cleanup cannot remove historical output. A retained
append incompatible because iTerm2 provides no reliable relocation cleanup; one-shot appended
iTerm2 images remain supported. Real image persistence depends on the terminal emulator.

Applications that retain a bounded semantic transcript can also reconstruct only the durable tail
lost from the active viewport during a normal-screen resize:

```scala
import scalatui.ansi.Ansi
import scalatui.core.{
NormalResizeClearPolicy,
NormalResizeRecoveryProvider,
TUI,
TUIOptions
}

val semanticTranscript = Vector("old result", "newer result") // Application-owned and bounded.
val recovery = NormalResizeRecoveryProvider { context =>
val invalidatedEntries = semanticTranscript.reverseIterator
.foldLeft((Vector.empty[String], 0)) { case ((entries, oldRows), entry) =>
if oldRows >= context.previousMaxRows then entries -> oldRows
else
val entryRows = Ansi.wrapLogicalLinesWithAnsi(entry, context.previousWidth).length
(entry +: entries) -> (oldRows + entryRows)
}
._1

invalidatedEntries
.flatMap(Ansi.wrapLogicalLinesWithAnsi(_, context.width))
.takeRight(context.maxRows) // Retains oldest-to-newest order within the newest tail.
}

val tui = TUI(terminal, TUIOptions(
normalResizeClearPolicy = NormalResizeClearPolicy.PreserveScrollback,
normalResizeRecovery = Some(recovery)
))
```

The provider runs synchronously only for a committed geometry-changing resize, after the live frame
has been rendered and its strict viewport budget is known. `previousWidth`, `previousHeight`, and
`previousMaxRows` describe the old viewport and its maximum durable prefix; `maxRows` is also
bounded by space above the new live frame, preventing viewport growth from replaying older history.
The provider may run again if another resize makes the candidate stale, so it must be fast,
side-effect-light, and retryable. It returns ordinary text lines only: existing SGR/OSC 8
sanitization is preserved, while typed controls, images, cursors, and raw terminal output are
unavailable. A later append is placed in chronological order as `recovered tail -> newly appended
output -> retained live frame`.

Siglyph does not retain the transcript or inspect emulator scrollback. The application must select
only the current-width newest tail that belonged in the invalidated viewport; exact survivor and
deduplication behavior remains terminal-dependent. Configuring recovery with alternate-screen mode
or `ClearScrollback` fails before terminal startup.

Applications that need a full-screen terminal experience can opt into alternate-screen mode:

```scala
Expand Down
65 changes: 65 additions & 0 deletions core/src/scalatui/core/NormalResizeRecovery.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package scalatui.core

/**
* Current and previous terminal geometry plus strict row bounds for one normal-screen resize
* recovery attempt.
*
* Siglyph supplies positive current and previous dimensions from the shared JVM/Scala Native
* runtime. `previousMaxRows` is the maximum durable prefix that could have occupied the old
* viewport above the prior live frame. `maxRows` is the smaller of that old capacity and the space
* currently available above the new live frame. Applications use the old bound to select the
* invalidated semantic tail, reflow it at `width`, and return at most `maxRows` display rows in
* oldest-to-newest order. Siglyph does not expose or infer terminal scrollback survivors.
*/
final case class NormalResizeRecoveryContext(
width: Int,
height: Int,
maxRows: Int,
previousWidth: Int,
previousHeight: Int,
previousMaxRows: Int
) derives CanEqual:
require(width > 0, "Normal resize recovery width must be positive")
require(height > 0, "Normal resize recovery height must be positive")
require(maxRows > 0, "Normal resize recovery row budget must be positive")
require(maxRows < height, "Normal resize recovery row budget must leave a live-frame row")
require(previousWidth > 0, "Previous normal resize recovery width must be positive")
require(previousHeight > 0, "Previous normal resize recovery height must be positive")
require(previousMaxRows > 0, "Previous normal resize recovery row capacity must be positive")
require(
previousMaxRows < previousHeight,
"Previous normal resize recovery row capacity must leave a live-frame row"
)
require(
maxRows <= previousMaxRows,
"Normal resize recovery row budget must not exceed previous row capacity"
)

/**
* Synchronous application provider for bounded normal-screen resize recovery.
*
* `render` runs as serialized TUI Render work outside lifecycle and terminal-write locks. A resize
* can discard an unpublished candidate and invoke the provider again, so implementations must be
* fast, side-effect-light, and retryable. Returned strings are ordinary text-only component lines:
* existing SGR/OSC 8 allowlisting and width sanitization apply, while typed terminal controls,
* images, cursors, components, and raw trusted output are intentionally unavailable.
*
* The contract is implemented in shared core on JVM and Scala Native. Applications retain and
* select their own semantic transcript; Siglyph retains no recovery output after publication and
* cannot promise emulator-independent scrollback deduplication.
*/
trait NormalResizeRecoveryProvider:
/**
* Select the old semantic tail using `previousWidth` and `previousMaxRows`, then reflow and
* return at most `context.maxRows` ordinary lines for the current geometry.
*
* Lines use oldest-to-newest order within the selected newest durable tail. Returning too many
* rows fails the runtime before recovery publication; throwing uses normal fail-fast cleanup.
*/
def render(context: NormalResizeRecoveryContext): Vector[String]

object NormalResizeRecoveryProvider:
/** Build a provider from a synchronous retryable callback. */
def apply(callback: NormalResizeRecoveryContext => Vector[String]): NormalResizeRecoveryProvider =
new NormalResizeRecoveryProvider:
override def render(context: NormalResizeRecoveryContext): Vector[String] = callback(context)
Loading