Skip to content

Commit 6319a91

Browse files
committed
Add bellStyle and clearScrollback
- BellStyle (none/sound/visual/soundAndVisual) with a bellStyle property on both platform views; .sound preserves the existing delegate-beep behavior, visual is a brief foreground-color flash. Follows the CursorStyle pattern: CaseIterable plus tagName/ displayName/init(tagName:), no other conformances. - Terminal.clearScrollback() / TerminalView.clearScrollback(): drops the lines scrolled off the top without touching the visible screen or the configured scrollback capacity (Cmd-K semantics).
1 parent e4f9529 commit 6319a91

7 files changed

Lines changed: 253 additions & 2 deletions

File tree

Sources/SwiftTerm/Apple/AppleTerminalView.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2626,6 +2626,18 @@ extension TerminalView {
26262626
terminalDelegate?.scrolled(source: self, position: scrollPosition)
26272627
queuePendingDisplay()
26282628
}
2629+
2630+
/**
2631+
* Discards the scrollback history without clearing the visible screen,
2632+
* the equivalent of Terminal.app's "Clear to Start" / Cmd-K affordance.
2633+
*/
2634+
public func clearScrollback ()
2635+
{
2636+
terminal.clearScrollback()
2637+
updateScroller()
2638+
terminalDelegate?.scrolled(source: self, position: scrollPosition)
2639+
queuePendingDisplay()
2640+
}
26292641

26302642
/**
26312643
* Sends the specified slice of byte arrays to the program running under the terminal emulator
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//
2+
// BellStyle.swift
3+
// SwiftTerm
4+
//
5+
// How a terminal view responds to the BEL (0x07) control character.
6+
//
7+
#if os(macOS) || os(iOS) || os(visionOS)
8+
import Foundation
9+
10+
/// How the terminal view responds to the bell (BEL, 0x07).
11+
///
12+
/// Like ``CursorStyle``, this enum only adopts CaseIterable; naming is
13+
/// provided by members (`tagName`, `displayName`, ``init(tagName:)``) so
14+
/// that client retroactive conformances (Codable and friends) keep working.
15+
public enum BellStyle: CaseIterable {
16+
/// The bell is ignored
17+
case none
18+
/// The `TerminalViewDelegate.bell` method is invoked (the default
19+
/// implementation beeps on macOS, produces haptic feedback on iOS)
20+
case sound
21+
/// The terminal view flashes briefly
22+
case visual
23+
/// Both the sound and the visual flash
24+
case soundAndVisual
25+
26+
/// A stable, machine-readable name, suitable for persisting settings;
27+
/// the inverse of ``init(tagName:)``
28+
public var tagName: String {
29+
switch self {
30+
case .none: return "none"
31+
case .sound: return "sound"
32+
case .visual: return "visual"
33+
case .soundAndVisual: return "soundAndVisual"
34+
}
35+
}
36+
37+
/// A human-readable name, for use in user interfaces
38+
public var displayName: String {
39+
switch self {
40+
case .none: return "None"
41+
case .sound: return "Sound"
42+
case .visual: return "Visual"
43+
case .soundAndVisual: return "Sound and Visual"
44+
}
45+
}
46+
47+
/// Creates a bell style from the stable name returned by ``tagName``
48+
public init? (tagName: String) {
49+
guard let match = BellStyle.allCases.first (where: { $0.tagName == tagName }) else {
50+
return nil
51+
}
52+
self = match
53+
}
54+
}
55+
#endif

Sources/SwiftTerm/Buffer.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,20 @@ public final class Buffer {
553553
cols = newCols
554554
}
555555

556+
/// Removes the scrollback history (the lines above the visible screen)
557+
/// without touching the visible screen contents or the buffer's capacity
558+
public func clearScrollback ()
559+
{
560+
guard yBase > 0 else {
561+
return
562+
}
563+
let amountToTrim = yBase
564+
lines.trimStart (count: amountToTrim)
565+
yBase = 0
566+
yDisp = 0
567+
savedY = max (savedY - amountToTrim, 0)
568+
}
569+
556570
public func changeHistorySize (_ newScrollback: Int?)
557571
{
558572
self.scrollback = newScrollback

Sources/SwiftTerm/Mac/MacTerminalView.swift

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2913,8 +2913,46 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations,
29132913
}
29142914
}
29152915

2916+
/// Controls how this view responds to the bell character; `.sound`
2917+
/// preserves the historical behavior of invoking the delegate's `bell`
2918+
public var bellStyle: BellStyle = .sound
2919+
29162920
open func bell(source: Terminal) {
2917-
terminalDelegate?.bell (source: self)
2921+
switch bellStyle {
2922+
case .none:
2923+
break
2924+
case .sound:
2925+
terminalDelegate?.bell (source: self)
2926+
case .visual:
2927+
flashVisualBell ()
2928+
case .soundAndVisual:
2929+
terminalDelegate?.bell (source: self)
2930+
flashVisualBell ()
2931+
}
2932+
}
2933+
2934+
/// Briefly flashes the view with the foreground color, the "visual bell"
2935+
func flashVisualBell ()
2936+
{
2937+
guard let layer = self.layer else {
2938+
return
2939+
}
2940+
let flash = CALayer ()
2941+
flash.frame = bounds
2942+
flash.backgroundColor = nativeForegroundColor.cgColor
2943+
flash.opacity = 0
2944+
layer.addSublayer (flash)
2945+
2946+
CATransaction.begin ()
2947+
CATransaction.setCompletionBlock {
2948+
flash.removeFromSuperlayer ()
2949+
}
2950+
let animation = CAKeyframeAnimation (keyPath: "opacity")
2951+
animation.values = [0.0, 0.35, 0.0]
2952+
animation.keyTimes = [0, 0.3, 1]
2953+
animation.duration = 0.2
2954+
flash.add (animation, forKey: "visualBell")
2955+
CATransaction.commit ()
29182956
}
29192957

29202958
public func progressReport(source: Terminal, report: Terminal.ProgressReport) {

Sources/SwiftTerm/Terminal.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5940,6 +5940,16 @@ open class Terminal {
59405940
*
59415941
* - Parameter newScrollback: The new scrollback size in lines. Pass `nil` to disable scrollback.
59425942
*/
5943+
/// Discards the scrollback history (the lines scrolled off the top of the
5944+
/// visible screen) without clearing the visible screen or changing the
5945+
/// configured scrollback capacity
5946+
public func clearScrollback ()
5947+
{
5948+
// Only the normal buffer has scrollback
5949+
normalBuffer.clearScrollback ()
5950+
refresh (startRow: 0, endRow: self.rows - 1)
5951+
}
5952+
59435953
public func changeScrollback (_ newScrollback: Int?)
59445954
{
59455955
// Only the normal buffer has scrollback, the alt buffer should never have scrollback.

Sources/SwiftTerm/iOS/iOSTerminalView.swift

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2937,8 +2937,43 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi
29372937
caretView?.style = newStyle
29382938
updateCaretView()
29392939
}
2940+
/// Controls how this view responds to the bell character; `.sound`
2941+
/// preserves the historical behavior of invoking the delegate's `bell`
2942+
public var bellStyle: BellStyle = .sound
2943+
29402944
open func bell(source: Terminal) {
2941-
terminalDelegate?.bell (source: self)
2945+
switch bellStyle {
2946+
case .none:
2947+
break
2948+
case .sound:
2949+
terminalDelegate?.bell (source: self)
2950+
case .visual:
2951+
flashVisualBell ()
2952+
case .soundAndVisual:
2953+
terminalDelegate?.bell (source: self)
2954+
flashVisualBell ()
2955+
}
2956+
}
2957+
2958+
/// Briefly flashes the view with the foreground color, the "visual bell"
2959+
func flashVisualBell ()
2960+
{
2961+
let flash = CALayer ()
2962+
flash.frame = bounds
2963+
flash.backgroundColor = nativeForegroundColor.cgColor
2964+
flash.opacity = 0
2965+
layer.addSublayer (flash)
2966+
2967+
CATransaction.begin ()
2968+
CATransaction.setCompletionBlock {
2969+
flash.removeFromSuperlayer ()
2970+
}
2971+
let animation = CAKeyframeAnimation (keyPath: "opacity")
2972+
animation.values = [0.0, 0.35, 0.0]
2973+
animation.keyTimes = [0, 0.3, 1]
2974+
animation.duration = 0.2
2975+
flash.add (animation, forKey: "visualBell")
2976+
CATransaction.commit ()
29422977
}
29432978

29442979
public func progressReport(source: Terminal, report: Terminal.ProgressReport) {

Tests/SwiftTermTests/ProfileSupportTests.swift

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,93 @@ final class ColorParseTests {
100100
}
101101
}
102102

103+
final class BellStyleTests {
104+
@Test func tagNameRoundTrips () {
105+
for style in BellStyle.allCases {
106+
#expect (BellStyle (tagName: style.tagName) == style)
107+
}
108+
#expect (BellStyle (tagName: "kazoo") == nil)
109+
}
110+
}
111+
112+
@MainActor
113+
final class BellDispatchTests {
114+
final class CountingDelegate: TerminalViewDelegate {
115+
var bells = 0
116+
func sizeChanged (source: TerminalView, newCols: Int, newRows: Int) {}
117+
func setTerminalTitle (source: TerminalView, title: String) {}
118+
func hostCurrentDirectoryUpdate (source: TerminalView, directory: String?) {}
119+
func send (source: TerminalView, data: ArraySlice<UInt8>) {}
120+
func scrolled (source: TerminalView, position: Double) {}
121+
func rangeChanged (source: TerminalView, startY: Int, endY: Int) {}
122+
func requestOpenLink (source: TerminalView, link: String, params: [String: String]) {}
123+
func clipboardCopy (source: TerminalView, content: Data) {}
124+
func bell (source: TerminalView) {
125+
bells += 1
126+
}
127+
}
128+
129+
@Test func bellStyleGatesDelegate () {
130+
let view = TerminalView (frame: CGRect (x: 0, y: 0, width: 400, height: 300))
131+
let delegate = CountingDelegate ()
132+
view.terminalDelegate = delegate
133+
134+
view.bellStyle = .sound
135+
view.bell (source: view.getTerminal ())
136+
#expect (delegate.bells == 1)
137+
138+
view.bellStyle = .none
139+
view.bell (source: view.getTerminal ())
140+
#expect (delegate.bells == 1)
141+
142+
view.bellStyle = .visual
143+
view.bell (source: view.getTerminal ())
144+
#expect (delegate.bells == 1)
145+
146+
view.bellStyle = .soundAndVisual
147+
view.bell (source: view.getTerminal ())
148+
#expect (delegate.bells == 2)
149+
}
150+
}
151+
152+
final class ClearScrollbackTests {
153+
class DummyDelegate: TerminalDelegate {
154+
func send (source: Terminal, data: ArraySlice<UInt8>) {}
155+
}
156+
157+
@Test func clearScrollbackDropsHistoryKeepsScreen () {
158+
let terminal = Terminal (delegate: DummyDelegate (),
159+
options: TerminalOptions (cols: 20, rows: 5, scrollback: 100))
160+
for i in 0..<30 {
161+
terminal.feed (text: "line \(i)\r\n")
162+
}
163+
let buffer = terminal.buffer
164+
#expect (buffer.yBase > 0)
165+
let visibleBefore = terminal.getText (
166+
start: Position (col: 0, row: buffer.yBase),
167+
end: Position (col: 19, row: buffer.yBase))
168+
169+
terminal.clearScrollback ()
170+
#expect (buffer.yBase == 0)
171+
#expect (buffer.yDisp == 0)
172+
let visibleAfter = terminal.getText (
173+
start: Position (col: 0, row: 0),
174+
end: Position (col: 19, row: 0))
175+
#expect (visibleAfter == visibleBefore)
176+
}
177+
178+
@Test func clearScrollbackOnEmptyBufferIsANoop () {
179+
let terminal = Terminal (delegate: DummyDelegate (),
180+
options: TerminalOptions (cols: 20, rows: 5, scrollback: 100))
181+
terminal.feed (text: "hello")
182+
terminal.clearScrollback ()
183+
#expect (terminal.buffer.yBase == 0)
184+
let text = terminal.getText (start: Position (col: 0, row: 0),
185+
end: Position (col: 5, row: 0))
186+
#expect (text == "hello")
187+
}
188+
}
189+
103190
@MainActor
104191
final class TerminalViewOptionsTests {
105192
@Test func startupOptionsReachTheTerminal () {

0 commit comments

Comments
 (0)