diff --git a/Sources/Noora/Components/SingleChoicePrompt.swift b/Sources/Noora/Components/SingleChoicePrompt.swift index b85d2d6..3a9e0dd 100644 --- a/Sources/Noora/Components/SingleChoicePrompt.swift +++ b/Sources/Noora/Components/SingleChoicePrompt.swift @@ -77,27 +77,69 @@ struct SingleChoicePrompt { private func renderOptions(selectedOption: (T, String), options: [(T, String)]) { let titleOffset = title != nil ? " " : "" - let questions = options.map { option in - if option == selectedOption { - return "\(titleOffset) \("❯".hex(theme.primary)) \(option.1)" - } else { - return "\(titleOffset) \(option.1)" - } - }.joined(separator: "\n") + // Header - var content = "" + var header = "" if let title { - content = "◉ \(title.formatted(theme: theme, terminal: terminal))".hexIfColoredTerminal(theme.primary, terminal) + header = "◉ \(title.formatted(theme: theme, terminal: terminal))".hexIfColoredTerminal(theme.primary, terminal) .boldIfColoredTerminal(terminal) } - content += "\(title != nil ? "\n" : "")\(titleOffset)\(question.formatted(theme: theme, terminal: terminal))" + header += "\(title != nil ? "\n" : "")\(titleOffset)\(question.formatted(theme: theme, terminal: terminal))" if let description { - content += + header += "\n\(titleOffset)\(description.formatted(theme: theme, terminal: terminal).hexIfColoredTerminal(theme.muted, terminal))" } - content += "\n\(questions)" - content += "\n\(titleOffset)\("↑/↓/k/j up/down • enter confirm".hexIfColoredTerminal(theme.muted, terminal))" + + // Footer + + let footer = "\n\(titleOffset)\("↑/↓/k/j up/down • enter confirm".hexIfColoredTerminal(theme.muted, terminal))" + + func numberOfLines(for text: String) -> Int { + guard let terminalWidth = terminal.size?.columns else { return 1 } + let lines = text.raw.split(separator: "\n") + return lines.reduce(0) { sum, line in + let lineCount = (line.count + terminalWidth - 1) / terminalWidth + return sum + lineCount + } + } + + let headerLines = numberOfLines(for: header) + let footerLines = numberOfLines(for: footer) + 1 /// `Renderer.render` adds a newline at the end + + let maxVisibleOptions = if let terminalSize = terminal.size { + max(1, terminalSize.rows - headerLines - footerLines) + } else { + options.count + } + + let currentIndex = options.firstIndex(where: { $0 == selectedOption })! + let middleIndex = maxVisibleOptions / 2 + + var startIndex = max(0, currentIndex - middleIndex) + if startIndex + maxVisibleOptions > options.count { + startIndex = max(0, options.count - maxVisibleOptions) + } + + let endIndex = min(options.count, startIndex + maxVisibleOptions) + + // Questions + + var visibleOptions = [String]() + for (index, option) in options.enumerated() { + if (startIndex ..< endIndex) ~= index { + if option == selectedOption { + visibleOptions.append("\(titleOffset) \("❯".hex(theme.primary)) \(option.1)") + } else { + visibleOptions.append("\(titleOffset) \(option.1)") + } + } + } + let questions = visibleOptions.joined(separator: "\n") + + // Render + + let content = "\(header)\n\(questions)\(footer)" renderer.render(content, standardPipeline: standardPipelines.output) } } diff --git a/Sources/Noora/Utilities/Terminal.swift b/Sources/Noora/Utilities/Terminal.swift index 1acdda4..bb8db1a 100644 --- a/Sources/Noora/Utilities/Terminal.swift +++ b/Sources/Noora/Utilities/Terminal.swift @@ -9,6 +9,7 @@ import Foundation public protocol Terminaling { var isInteractive: Bool { get } var isColored: Bool { get } + var size: (rows: Int, columns: Int)? { get } func withoutCursor(_ body: () throws -> Void) rethrows func inRawMode(_ body: @escaping () throws -> Void) rethrows func readCharacter() -> Character? @@ -18,6 +19,8 @@ public struct Terminal: Terminaling { public let isInteractive: Bool public let isColored: Bool + public var size: (rows: Int, columns: Int)? { Terminal.size() } + public init(isInteractive: Bool = Terminal.isInteractive(), isColored: Bool = Terminal.isColored()) { self.isInteractive = isInteractive self.isColored = isColored @@ -105,4 +108,14 @@ public struct Terminal: Terminaling { return true } } + + /// Returns the size of the terminal. + public static func size() -> (rows: Int, columns: Int)? { + var w = winsize() + if ioctl(STDOUT_FILENO, TIOCGWINSZ, &w) == 0 { + return (Int(w.ws_row), Int(w.ws_col)) + } else { + return nil + } + } } diff --git a/Tests/NooraTests/Mocks/MockTerminal.swift b/Tests/NooraTests/Mocks/MockTerminal.swift index 3368cf3..53bfd2c 100644 --- a/Tests/NooraTests/Mocks/MockTerminal.swift +++ b/Tests/NooraTests/Mocks/MockTerminal.swift @@ -3,6 +3,7 @@ import Noora class MockTerminal: Terminaling { var isInteractive: Bool = true var isColored: Bool = true + var size: (rows: Int, columns: Int)? = nil init( isInteractive: Bool = true,