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
51 changes: 48 additions & 3 deletions Sources/Noora/Utilities/KeyStrokeListener.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import Foundation

/// An enum that represents the key strokes supported by the `KeyStrokeListening`
public enum KeyStroke {
/// It represents the return key.
public enum KeyStroke: Equatable {
case returnKey
/// It represents the return key.
/// It represents a printable character key
case printable(Character)
/// It represents the up arrow
Expand All @@ -20,6 +20,20 @@ public enum KeyStroke {
case delete
/// It represents the escape key.
case escape
/// It represents a left mouse button press.
case leftMouseDown(position: TerminalPosition)
/// It represents a right mouse button press.
case rightMouseDown(position: TerminalPosition)
/// It represents a left mouse button release.
case leftMouseUp(position: TerminalPosition)
/// It represents a right mouse button release.
case rightMouseUp(position: TerminalPosition)
/// It represents dragging with left mouse button.
case leftMouseDrag(position: TerminalPosition)
/// It represents dragging with right mouse button.
case rightMouseDrag(position: TerminalPosition)
/// It represents mouse movement without any buttons pressed.
case mouseMoved(position: TerminalPosition)
}

/// A result that the caller can use in the onKeyPress callback to instruct the listener on how to
Expand Down Expand Up @@ -71,6 +85,7 @@ public struct KeyStrokeListener: KeyStrokeListening {
case ("\u{08}", _): .backspace
case ("\u{7F}", _): .delete
case (_, "\u{1B}"): .escape
case let (_, buf) where buf.hasPrefix("\u{1B}[<"): decodeSGRMouseEvent(from: buf)
default: nil
}

Expand All @@ -81,11 +96,41 @@ public struct KeyStrokeListener: KeyStrokeListening {
case .continue: continue
}
}
if buffer.count > 3 {
if buffer.count > 14 {
buffer = ""
}
}
}

/// Decodes a mouse event in SGR format (ESC[<btn;x;yM or ESC[<btn;x;ym)
private func decodeSGRMouseEvent(from buffer: String) -> KeyStroke? {
guard let endIndex = buffer.firstIndex(where: { $0 == "M" || $0 == "m" }) else {
return nil
}

let isPress = buffer[endIndex] == "M"
let parts = String(buffer.dropFirst(3).prefix(while: { $0 != "M" && $0 != "m" }))
.split(separator: ";")
.compactMap { Int($0) }

guard parts.count == 3 else {
return nil
}

let button = parts[0]
let position = TerminalPosition(row: parts[2], column: parts[1])

return switch (button, isPress) {
case (0, true): .leftMouseDown(position: position)
case (0, false): .leftMouseUp(position: position)
case (2, true): .rightMouseDown(position: position)
case (2, false): .rightMouseUp(position: position)
case (32, _): .leftMouseDrag(position: position)
case (34, _): .rightMouseDrag(position: position)
case (35, _): .mouseMoved(position: position)
default: nil
}
}
}

extension KeyStrokeListening {
Expand Down
65 changes: 62 additions & 3 deletions Sources/Noora/Utilities/Terminal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,33 @@ public protocol Terminaling {
var isColored: Bool { get }
func withoutCursor(_ body: () throws -> Void) rethrows
func inRawMode(_ body: @escaping () throws -> Void) rethrows
func withMouseTracking(
trackMotion: Bool,
_ body: () throws -> Void
) rethrows
func readCharacter() -> Character?
func readCharacterNonBlocking() -> Character?
func size() -> TerminalSize?
}

public struct TerminalSize {
let rows: Int
let columns: Int
public struct TerminalPosition: Equatable {
public let row: Int
public let column: Int

public init(row: Int, column: Int) {
self.row = row
self.column = column
}
}

public struct TerminalSize: Equatable {
public let rows: Int
public let columns: Int

public init(rows: Int, columns: Int) {
self.rows = rows
self.columns = columns
}
}

public struct Terminal: Terminaling {
Expand All @@ -30,7 +49,10 @@ public struct Terminal: Terminaling {
self.isColored = isColored
for signalType in [SIGINT, SIGTERM, SIGQUIT, SIGHUP] {
signal(signalType) { _ in
// Ensure cursor is visible
print("\u{1B}[?25h", terminator: "")
// Disable mouse tracking
print("\u{1B}[?1003l\u{1B}[?1006l\u{1B}[?1000l", terminator: "")
fflush(stdout)
exit(0)
}
Expand All @@ -57,6 +79,43 @@ public struct Terminal: Terminaling {
fflush(stdout)
}

/// Runs a block of code with mouse tracking enabled, disabling it afterwards.
/// - Parameter trackMotion: Whether or not to track drag and hover events in addition to clicks.
/// - Parameter body: The closure to execute with mouse tracking enabled.
public func withMouseTracking(
trackMotion: Bool = false,
_ body: () throws -> Void
) rethrows {
enableMouseTracking(trackMotion: trackMotion)
defer { disableMouseTracking() }
try body()
}

/// Enables mouse tracking in the terminal.
/// - Parameter trackMotion: Whether or not to track drag and hover events in addition to clicks.
public func enableMouseTracking(trackMotion: Bool = false) {
// Enable SGR mouse mode for better event reporting
print("\u{1B}[?1006h", terminator: "")
// Enable basic mouse tracking (clicks)
print("\u{1B}[?1000h", terminator: "")
if trackMotion {
// Enable motion tracking
print("\u{1B}[?1003h", terminator: "")
}
fflush(stdout)
}

/// Disables mouse tracking in the terminal.
public func disableMouseTracking() {
// Disable motion tracking
print("\u{1B}[?1003l", terminator: "")
// Disable SGR mouse mode
print("\u{1B}[?1006l", terminator: "")
// Disable basic mouse tracking
print("\u{1B}[?1000l", terminator: "")
fflush(stdout)
}

/// Enables raw mode for the terminal and restores the mode after the body is executed.
/// - Parameter body: The body to execute with raw mode enabled.
public func inRawMode(_ body: () throws -> Void) rethrows {
Expand Down
Loading