Skip to content
Draft
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
32 changes: 32 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Sources/SwiftTerm/HeadlessTerminal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//
// Created by Miguel de Icaza on 4/5/20.
//
#if !os(iOS) && !os(Windows)
#if !os(iOS) && !os(Windows) && false // Temporarily disabled due to LocalProcess issues
import Foundation

///
Expand Down
4 changes: 3 additions & 1 deletion Sources/SwiftTerm/LocalProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
//
// Created by Miguel de Icaza on 4/5/20.
//
#if !os(iOS) && !os(Windows)
#if !os(iOS) && !os(Windows) && false // Temporarily disabled due to System module issues
import Foundation
import Dispatch
#if canImport(Subprocess)
import Subprocess
#if canImport(System)
import System
#endif
#endif

/// Delegate that is invoked by the ``LocalProcess`` class in response to various
/// process-related events.
Expand Down
149 changes: 130 additions & 19 deletions Sources/SwiftTerm/Terminal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,10 @@ open class Terminal {
/// the terminal, the content will be wrapped in "ESC [ 200 ~" to start, and "ESC [ 201 ~" to end.
public private(set) var bracketedPasteMode: Bool = false

/// Indicates that the application has enabled synchronized updates (DEC mode 2026), which defers
/// display updates until the mode is disabled or explicitly flushed.
public private(set) var synchronizedUpdates: Bool = false

private var charset: [UInt8:String]? = nil
var gcharset: Int = 0
var reverseWraparound: Bool = false
Expand All @@ -349,6 +353,12 @@ open class Terminal {
var refreshEnd = -1
var scrollInvariantRefreshStart = Int.max
var scrollInvariantRefreshEnd = -1

// Deferred refresh ranges for synchronized updates
var deferredRefreshStart = Int.max
var deferredRefreshEnd = -1
var deferredScrollInvariantRefreshStart = Int.max
var deferredScrollInvariantRefreshEnd = -1
var userScrolling = false
var lineFeedMode = false

Expand Down Expand Up @@ -725,6 +735,13 @@ open class Terminal {
setInsertMode(false)
setWraparound(true)
bracketedPasteMode = false
synchronizedUpdates = false

// Reset deferred update ranges
deferredRefreshStart = Int.max
deferredRefreshEnd = -1
deferredScrollInvariantRefreshStart = Int.max
deferredScrollInvariantRefreshEnd = -1

// charset'
charset = nil
Expand Down Expand Up @@ -2993,6 +3010,8 @@ open class Terminal {
// keyboard emulation mode: 1050, 1051, 1052, 1053, 1060, 1061
case 2004:
res = bracketedPasteMode ? modeSet : modeReset
case 2026:
res = synchronizedUpdates ? modeSet : modeReset
default:
break
}
Expand Down Expand Up @@ -3705,6 +3724,13 @@ open class Terminal {
case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste)
bracketedPasteMode = false
break
case 2026: // synchronized updates (https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036)
if synchronizedUpdates {
// Flush any deferred updates when disabling synchronized updates
flushDeferredUpdates()
}
synchronizedUpdates = false
break
default:
log ("Unhandled DEC Private Mode Reset (DECRST) with \(par)")
break
Expand Down Expand Up @@ -3939,6 +3965,8 @@ open class Terminal {
case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste)
// TODO: must implement bracketed paste mode
bracketedPasteMode = true
case 2026: // synchronized updates (https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036)
synchronizedUpdates = true
default:
log ("Unhandled DEC Private Mode Set (DECSET) with \(par)")
break;
Expand Down Expand Up @@ -4499,24 +4527,49 @@ open class Terminal {
*/
func updateRange (_ y: Int, scrolling: Bool = false)
{
if !scrolling {
let effectiveY = buffer.yDisp + y
if effectiveY >= 0 {
if effectiveY < scrollInvariantRefreshStart {
scrollInvariantRefreshStart = effectiveY
if synchronizedUpdates {
// Store updates in deferred ranges when synchronized updates are enabled
if !scrolling {
let effectiveY = buffer.yDisp + y
if effectiveY >= 0 {
if effectiveY < deferredScrollInvariantRefreshStart {
deferredScrollInvariantRefreshStart = effectiveY
}
if effectiveY > deferredScrollInvariantRefreshEnd {
deferredScrollInvariantRefreshEnd = effectiveY
}
}
}

if y >= 0 {
if y < deferredRefreshStart {
deferredRefreshStart = y
}
if effectiveY > scrollInvariantRefreshEnd {
scrollInvariantRefreshEnd = effectiveY
if y > deferredRefreshEnd {
deferredRefreshEnd = y
}
}
}

if y >= 0 {
if y < refreshStart {
refreshStart = y
} else {
// Normal behavior when synchronized updates are disabled
if !scrolling {
let effectiveY = buffer.yDisp + y
if effectiveY >= 0 {
if effectiveY < scrollInvariantRefreshStart {
scrollInvariantRefreshStart = effectiveY
}
if effectiveY > scrollInvariantRefreshEnd {
scrollInvariantRefreshEnd = effectiveY
}
}
}
if y > refreshEnd {
refreshEnd = y

if y >= 0 {
if y < refreshStart {
refreshStart = y
}
if y > refreshEnd {
refreshEnd = y
}
}
}
}
Expand All @@ -4529,11 +4582,21 @@ open class Terminal {

public func updateFullScreen ()
{
refreshStart = 0
refreshEnd = rows

scrollInvariantRefreshStart = buffer.yDisp
scrollInvariantRefreshEnd = buffer.yDisp + rows
if synchronizedUpdates {
// Store updates in deferred ranges when synchronized updates are enabled
deferredRefreshStart = 0
deferredRefreshEnd = rows

deferredScrollInvariantRefreshStart = buffer.yDisp
deferredScrollInvariantRefreshEnd = buffer.yDisp + rows
} else {
// Normal behavior when synchronized updates are disabled
refreshStart = 0
refreshEnd = rows

scrollInvariantRefreshStart = buffer.yDisp
scrollInvariantRefreshEnd = buffer.yDisp + rows
}
}

/**
Expand All @@ -4547,6 +4610,11 @@ open class Terminal {
*/
public func getUpdateRange () -> (startY: Int, endY: Int)?
{
// When synchronized updates are enabled, don't report any updates
if synchronizedUpdates {
return nil
}

if refreshEnd == -1 && refreshStart == Int.max {
//print ("Emtpy update range")
return nil
Expand Down Expand Up @@ -4604,6 +4672,11 @@ open class Terminal {
*/
public func getScrollInvariantUpdateRange () -> (startY: Int, endY: Int)?
{
// When synchronized updates are enabled, don't report any updates
if synchronizedUpdates {
return nil
}

if scrollInvariantRefreshEnd == -1 && scrollInvariantRefreshStart == Int.max {
//print ("Emtpy update range")
return nil
Expand All @@ -4622,6 +4695,44 @@ open class Terminal {

scrollInvariantRefreshStart = Int.max
scrollInvariantRefreshEnd = -1

// Also clear deferred ranges when synchronized updates are enabled
if synchronizedUpdates {
deferredRefreshStart = Int.max
deferredRefreshEnd = -1
deferredScrollInvariantRefreshStart = Int.max
deferredScrollInvariantRefreshEnd = -1
}
}

/**
* Flushes deferred updates from synchronized updates mode to the current refresh ranges
*/
private func flushDeferredUpdates ()
{
if deferredRefreshEnd != -1 || deferredRefreshStart != Int.max {
if refreshStart == Int.max || deferredRefreshStart < refreshStart {
refreshStart = deferredRefreshStart
}
if refreshEnd == -1 || deferredRefreshEnd > refreshEnd {
refreshEnd = deferredRefreshEnd
}
}

if deferredScrollInvariantRefreshEnd != -1 || deferredScrollInvariantRefreshStart != Int.max {
if scrollInvariantRefreshStart == Int.max || deferredScrollInvariantRefreshStart < scrollInvariantRefreshStart {
scrollInvariantRefreshStart = deferredScrollInvariantRefreshStart
}
if scrollInvariantRefreshEnd == -1 || deferredScrollInvariantRefreshEnd > scrollInvariantRefreshEnd {
scrollInvariantRefreshEnd = deferredScrollInvariantRefreshEnd
}
}

// Clear deferred ranges
deferredRefreshStart = Int.max
deferredRefreshEnd = -1
deferredScrollInvariantRefreshStart = Int.max
deferredScrollInvariantRefreshEnd = -1
}

/**
Expand Down
104 changes: 104 additions & 0 deletions Tests/SwiftTermTests/SynchronizedUpdatesTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//
// SynchronizedUpdatesTests.swift
// SwiftTermTests
//
// Tests for synchronized updates functionality (DEC mode 2026)
//

import XCTest
@testable import SwiftTerm

// Mock delegate for testing
class MockTerminalDelegate: TerminalDelegate {
func bell(source: Terminal) {}
func bufferActivated(source: Terminal) {}
func send(source: Terminal, data: ArraySlice<UInt8>) {}
func showCursor(source: Terminal) {}
func hideCursor(source: Terminal) {}
func setTerminalTitle(source: Terminal, title: String) {}
func setTerminalIconTitle(source: Terminal, title: String) {}
func sizeChanged(source: Terminal) {}
func setTerminalForegroundColor(source: Terminal, color: Color) {}
func setTerminalBackgroundColor(source: Terminal, color: Color) {}
func windowCommand(source: Terminal, command: Terminal.WindowManipulationCommand) -> [UInt8]? { return nil }
func mouseModeChanged(source: Terminal) {}
func cursorStyleChanged(source: Terminal, newStyle: CursorStyle) {}
}

final class SynchronizedUpdatesTests: XCTestCase {

func testSynchronizedUpdatesMode() {
let delegate = MockTerminalDelegate()
let terminal = Terminal(delegate: delegate)

// Initial state should have synchronized updates disabled
XCTAssertFalse(terminal.synchronizedUpdates)

// Enable synchronized updates mode
terminal.feed(text: "\u{1b}[?2026h")
XCTAssertTrue(terminal.synchronizedUpdates)

// Disable synchronized updates mode
terminal.feed(text: "\u{1b}[?2026l")
XCTAssertFalse(terminal.synchronizedUpdates)
}

func testSynchronizedUpdatesUpdateDeferral() {
let delegate = MockTerminalDelegate()
let terminal = Terminal(delegate: delegate)

// Initial setup - write some text and clear ranges
terminal.feed(text: "Initial text\n")
terminal.clearUpdateRange()

// Enable synchronized updates
terminal.feed(text: "\u{1b}[?2026h")
XCTAssertTrue(terminal.synchronizedUpdates)

// Write more text that should normally trigger updates
terminal.feed(text: "Deferred text\n")

// With synchronized updates enabled, getUpdateRange should return nil
XCTAssertNil(terminal.getUpdateRange())
XCTAssertNil(terminal.getScrollInvariantUpdateRange())

// Disable synchronized updates - this should flush deferred updates
terminal.feed(text: "\u{1b}[?2026l")
XCTAssertFalse(terminal.synchronizedUpdates)

// Now updates should be available
let updateRange = terminal.getUpdateRange()
XCTAssertNotNil(updateRange)
}

func testSynchronizedUpdatesUpdateFullScreen() {
let delegate = MockTerminalDelegate()
let terminal = Terminal(delegate: delegate)

// Enable synchronized updates
terminal.feed(text: "\u{1b}[?2026h")

// Call updateFullScreen - this should defer updates
terminal.updateFullScreen()

// Should not return any updates while synchronized updates are enabled
XCTAssertNil(terminal.getUpdateRange())

// Disable synchronized updates
terminal.feed(text: "\u{1b}[?2026l")

// Now should have full screen update range
let updateRange = terminal.getUpdateRange()
XCTAssertNotNil(updateRange)
if let range = updateRange {
XCTAssertEqual(range.startY, 0)
XCTAssertEqual(range.endY, terminal.rows)
}
}

static var allTests = [
("testSynchronizedUpdatesMode", testSynchronizedUpdatesMode),
("testSynchronizedUpdatesUpdateDeferral", testSynchronizedUpdatesUpdateDeferral),
("testSynchronizedUpdatesUpdateFullScreen", testSynchronizedUpdatesUpdateFullScreen),
]
}