From a5a6114d1a39073e73a2a41071ae0332bc692cc3 Mon Sep 17 00:00:00 2001 From: Finn Voorhees Date: Fri, 21 Feb 2025 18:13:16 +0000 Subject: [PATCH] Scroll SingleChoicePrompt when larger than terminal height --- .../Noora/Components/SingleChoicePrompt.swift | 68 +++++++++++++++---- Sources/Noora/Utilities/Terminal.swift | 13 ++++ .../Components/SingleChoicePromptTests.swift | 45 +++++++++++- Tests/NooraTests/Mocks/MockTerminal.swift | 5 +- 4 files changed, 116 insertions(+), 15 deletions(-) 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/Components/SingleChoicePromptTests.swift b/Tests/NooraTests/Components/SingleChoicePromptTests.swift index ef47c5e..8e00495 100644 --- a/Tests/NooraTests/Components/SingleChoicePromptTests.swift +++ b/Tests/NooraTests/Components/SingleChoicePromptTests.swift @@ -14,7 +14,7 @@ struct SingleChoicePromptTests { } let renderer = MockRenderer() - let terminal = MockTerminal() + let terminal = MockTerminal(size: (rows: 10, columns: 80)) let keyStrokeListener = MockKeyStrokeListener() @Test func renders_the_right_content() throws { @@ -118,4 +118,47 @@ struct SingleChoicePromptTests { ✔︎ How would you like to integrate Tuist?: option1 """) } + + @Test func renders_the_right_content_when_more_options_than_terminal_height() throws { + // Given + let subject = SingleChoicePrompt( + title: nil, + question: "How would you like to integrate Tuist?", + description: nil, + theme: Theme.test(), + terminal: terminal, + collapseOnSelection: true, + renderer: renderer, + standardPipelines: StandardPipelines(), + keyStrokeListener: keyStrokeListener + ) + keyStrokeListener.keyPressStub = .init(repeating: .downArrowKey, count: 10) + + // When + _ = subject.run(options: (1 ... 20).map { "Option \($0)" }) + + // Then + #expect(renderer.renders[0] == """ + How would you like to integrate Tuist? + ❯ Option 1 + Option 2 + Option 3 + Option 4 + Option 5 + Option 6 + Option 7 + ↑/↓/k/j up/down • enter confirm + """) + #expect(renderer.renders[renderer.renders.count - 2] == """ + How would you like to integrate Tuist? + Option 8 + Option 9 + Option 10 + ❯ Option 11 + Option 12 + Option 13 + Option 14 + ↑/↓/k/j up/down • enter confirm + """) + } } diff --git a/Tests/NooraTests/Mocks/MockTerminal.swift b/Tests/NooraTests/Mocks/MockTerminal.swift index 3368cf3..d3b8ec2 100644 --- a/Tests/NooraTests/Mocks/MockTerminal.swift +++ b/Tests/NooraTests/Mocks/MockTerminal.swift @@ -3,13 +3,16 @@ import Noora class MockTerminal: Terminaling { var isInteractive: Bool = true var isColored: Bool = true + var size: (rows: Int, columns: Int)? = nil init( isInteractive: Bool = true, - isColored: Bool = true + isColored: Bool = true, + size: (rows: Int, columns: Int)? = nil ) { self.isInteractive = isInteractive self.isColored = isColored + self.size = size } func inRawMode(_ body: @escaping () throws -> Void) rethrows {