diff --git a/Sources/TerminalUI/Rendering/Dimensions.swift b/Sources/TerminalUI/Rendering/Dimensions.swift new file mode 100644 index 0000000..570ff33 --- /dev/null +++ b/Sources/TerminalUI/Rendering/Dimensions.swift @@ -0,0 +1,102 @@ + +// MARK: Horizontal + +/// A measurement of the horizontal dimension. +public struct Horizontal: Equatable, Hashable { + fileprivate let value: Int +} + +extension Horizontal: AdditiveArithmetic { + public static func + (lhs: Horizontal, rhs: Horizontal) -> Horizontal { + Horizontal(value: lhs.value + rhs.value) + } + + public static func - (lhs: Horizontal, rhs: Horizontal) -> Horizontal { + Horizontal(value: lhs.value - rhs.value) + } +} + +extension Horizontal: Comparable { + public static func < (lhs: Horizontal, rhs: Horizontal) -> Bool { + lhs.value < rhs.value + } +} + +extension Horizontal: CustomStringConvertible { + public var description: String { + value.description + } +} + +extension Horizontal: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self.init(value: value) + } +} + +extension Horizontal: Strideable { + public func advanced(by n: Int) -> Horizontal { + Horizontal(value: value + n) + } + + public func distance(to other: Horizontal) -> Int { + value - other.value + } +} + +extension Horizontal { + package init(_ value: some BinaryInteger) { + self.init(value: Int(value)) + } +} + +// MARK: - Vertical + +/// A measurement of the vertical dimension. +public struct Vertical: Equatable, Hashable { + fileprivate let value: Int +} + +extension Vertical: AdditiveArithmetic { + public static func + (lhs: Vertical, rhs: Vertical) -> Vertical { + Vertical(value: lhs.value + rhs.value) + } + + public static func - (lhs: Vertical, rhs: Vertical) -> Vertical { + Vertical(value: lhs.value - rhs.value) + } +} + +extension Vertical: Comparable { + public static func < (lhs: Vertical, rhs: Vertical) -> Bool { + lhs.value < rhs.value + } +} + +extension Vertical: CustomStringConvertible { + public var description: String { + value.description + } +} + +extension Vertical: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self.init(value: value) + } +} + +extension Vertical: Strideable { + public func advanced(by n: Int) -> Vertical { + Vertical(value: value + n) + } + + public func distance(to other: Vertical) -> Int { + value - other.value + } +} + +extension Vertical { + package init(_ value: some BinaryInteger) { + self.init(value: Int(value)) + } +} diff --git a/Sources/TerminalUI/Rendering/Edge.swift b/Sources/TerminalUI/Rendering/Edge.swift new file mode 100644 index 0000000..81a1d13 --- /dev/null +++ b/Sources/TerminalUI/Rendering/Edge.swift @@ -0,0 +1,59 @@ + +public enum Edge {} + +extension Edge { + public struct Set { + fileprivate let _insets: (Value) -> EdgeInsets + } +} + +extension Edge.Set { + func insets(_ value: Value) -> EdgeInsets { + _insets(value) + } +} + +extension Edge.Set { + + public static var all: Self { + Self { value in + let vertical = Vertical(value) + let horizontal = Horizontal(value) + return EdgeInsets( + top: vertical, + leading: horizontal, + bottom: vertical, + trailing: horizontal) + } + } +} + +extension Edge.Set { + + public static var top: Self { + Self { EdgeInsets(top: $0, leading: 0, bottom: 0, trailing: 0) } + } + + public static var bottom: Self { + Self { EdgeInsets(top: 0, leading: 0, bottom: $0, trailing: 0) } + } + + public static var vertical: Self { + Self { EdgeInsets(top: $0, leading: 0, bottom: $0, trailing: 0) } + } +} + +extension Edge.Set { + + public static var leading: Self { + Self { EdgeInsets(top: 0, leading: $0, bottom: 0, trailing: 0) } + } + + public static var trailing: Self { + Self { EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: $0) } + } + + public static var horizontal: Self { + Self { EdgeInsets(top: 0, leading: $0, bottom: 0, trailing: $0) } + } +} diff --git a/Sources/TerminalUI/Rendering/EdgeInsets.swift b/Sources/TerminalUI/Rendering/EdgeInsets.swift new file mode 100644 index 0000000..fc632af --- /dev/null +++ b/Sources/TerminalUI/Rendering/EdgeInsets.swift @@ -0,0 +1,19 @@ + +public struct EdgeInsets { + let top: Vertical + let leading: Horizontal + let bottom: Vertical + let trailing: Horizontal + + public init( + top: Vertical, + leading: Horizontal, + bottom: Vertical, + trailing: Horizontal + ) { + self.top = top + self.leading = leading + self.bottom = bottom + self.trailing = trailing + } +} diff --git a/Sources/TerminalUI/Rendering/Position.swift b/Sources/TerminalUI/Rendering/Position.swift index 41f347f..9cdd48b 100644 --- a/Sources/TerminalUI/Rendering/Position.swift +++ b/Sources/TerminalUI/Rendering/Position.swift @@ -1,7 +1,7 @@ struct Position: Equatable, Hashable { - let x: Int - let y: Int + let x: Horizontal + let y: Vertical } extension Position { diff --git a/Sources/TerminalUI/Rendering/Size.swift b/Sources/TerminalUI/Rendering/Size.swift index b916770..f30c2e2 100644 --- a/Sources/TerminalUI/Rendering/Size.swift +++ b/Sources/TerminalUI/Rendering/Size.swift @@ -1,8 +1,8 @@ import Foundation struct Size { - let width: Int - let height: Int + let width: Horizontal + let height: Vertical } extension Size { @@ -10,6 +10,6 @@ extension Size { static var window: Size { var size = winsize() _ = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &size) - return Size(width: Int(size.ws_col), height: Int(size.ws_row)) + return Size(width: Horizontal(size.ws_col), height: Vertical(size.ws_row)) } } diff --git a/Sources/TerminalUI/ViewModifiers/Padding.swift b/Sources/TerminalUI/ViewModifiers/Padding.swift new file mode 100644 index 0000000..5d5aade --- /dev/null +++ b/Sources/TerminalUI/ViewModifiers/Padding.swift @@ -0,0 +1,66 @@ + +extension View { + + public func padding(_ insets: EdgeInsets) -> some View { + Padding(content: self, insets: insets) + } + + public func padding( + _ set: Edge.Set, + _ value: Value + ) -> some View { + padding(set.insets(value)) + } + + public func padding(_ length: Int) -> some View { + padding(.all, length) + } +} + +private struct Padding: Builtin, View { + + let content: Content + let insets: EdgeInsets + + func render( + in canvas: any Canvas, + size: Size, + environment: EnvironmentValues + ) { + content._render( + in: canvas.inset(insets), + size: size.inset(insets), + environment: environment + ) + } +} + +extension Canvas { + fileprivate func inset(_ insets: EdgeInsets) -> Canvas { + InsetCanvas(base: self, insets: insets) + } +} + +private struct InsetCanvas: Canvas { + let base: Base + let insets: EdgeInsets + + func draw(_ pixel: Pixel, at position: Position) { + base.draw(pixel, at: position.offset(by: insets)) + } +} + +extension Position { + fileprivate func offset(by insets: EdgeInsets) -> Position { + Position(x: x + insets.leading, y: y + insets.top) + } +} + +extension Size { + fileprivate func inset(_ insets: EdgeInsets) -> Size { + Size( + width: width - insets.leading - insets.trailing, + height: height - insets.top - insets.bottom + ) + } +} diff --git a/Sources/TerminalUI/Views/Text.swift b/Sources/TerminalUI/Views/Text.swift index 36b7468..3634932 100644 --- a/Sources/TerminalUI/Views/Text.swift +++ b/Sources/TerminalUI/Views/Text.swift @@ -21,7 +21,7 @@ public struct Text: Builtin, View { hidden: environment.hidden, strikethrough: environment.strikethrough ) - canvas.draw(pixel, at: Position(x: index, y: 0)) + canvas.draw(pixel, at: Position(x: Horizontal(index), y: 0)) } } } diff --git a/Tests/TerminalUITests/Internal/DimensionsTests.swift b/Tests/TerminalUITests/Internal/DimensionsTests.swift new file mode 100644 index 0000000..abb55da --- /dev/null +++ b/Tests/TerminalUITests/Internal/DimensionsTests.swift @@ -0,0 +1,94 @@ +import TerminalUI +import TerminalUITesting +import Testing + +@Suite("Dimensions") struct DimensionTests { + + @Suite("Horizontal") struct HorizontalTests { + + @Test("ExpressibleByIntegerLiteral") + func expressibleByIntegerLiteral() { + let horizontal: Horizontal = 3 + #expect(horizontal.description == "3") + } + + @Test("AdditiveArithmetic") + func additiveArithmetic() { + #expect(Horizontal(4) + Horizontal(2) == Horizontal(6)) + #expect(Horizontal(4) - Horizontal(2) == Horizontal(2)) + } + + @Test("Comparable") + func comparable() { + #expect(Horizontal(1) < Horizontal(2)) + #expect(Horizontal(2) > Horizontal(1)) + #expect(Horizontal(1) <= Horizontal(1)) + #expect(Horizontal(1) >= Horizontal(1)) + } + + @Test("Equatable") + func equatable() { + #expect(Horizontal(1) == Horizontal(1)) + #expect(Horizontal(2) != Horizontal(1)) + } + + @Test("Strideable") + func strideable() { + var horizontals = (Horizontal(1)...Horizontal(3)).makeIterator() + #expect(horizontals.next() == 1) + #expect(horizontals.next() == 2) + #expect(horizontals.next() == 3) + #expect(horizontals.next() == nil) + } + + @Test("init(some BinaryInteger)") + func initBinaryInteger() { + let value = Int.random(in: 0..<1_000_000) + #expect(Horizontal(value).description == value.description) + } + } + + @Suite("Vertical") struct VerticalTests { + + @Test("AdditiveArithmetic") + func additiveArithmetic() { + #expect(Vertical(4) + Vertical(2) == Vertical(6)) + #expect(Vertical(4) - Vertical(2) == Vertical(2)) + } + + @Test("Comparable") + func comparable() { + #expect(Vertical(1) < Vertical(2)) + #expect(Vertical(2) > Vertical(1)) + #expect(Vertical(1) <= Vertical(1)) + #expect(Vertical(1) >= Vertical(1)) + } + + @Test("Equatable") + func equatable() { + #expect(Vertical(1) == Vertical(1)) + #expect(Vertical(2) != Vertical(1)) + } + + @Test("ExpressibleByIntegerLiteral") + func expressibleByIntegerLiteral() { + let vertical: Vertical = 3 + #expect(vertical.description == "3") + } + + @Test("Strideable") + func strideable() { + var verticals = (Vertical(1)...Vertical(3)).makeIterator() + #expect(verticals.next() == 1) + #expect(verticals.next() == 2) + #expect(verticals.next() == 3) + #expect(verticals.next() == nil) + } + + @Test("init(some BinaryInteger)") + func initBinaryInteger() { + let value: Int = Int.random(in: 0..<1_000_000) + #expect(Vertical(value).description == value.description) + } + } +} diff --git a/Tests/TerminalUITests/ViewModifiers/PaddingTests.swift b/Tests/TerminalUITests/ViewModifiers/PaddingTests.swift new file mode 100644 index 0000000..b3707b9 --- /dev/null +++ b/Tests/TerminalUITests/ViewModifiers/PaddingTests.swift @@ -0,0 +1,149 @@ +@testable import TerminalUI +import TerminalUITesting +import Testing + +@Suite("Padding", .tags(.viewModifier)) +struct PaddingTests { + + let canvas = TestCanvas() + let view = Color.blue + let pixel = Pixel(" ", background: .blue) + + @Test("edge insets") + func edgeInsets() { + + canvas.render(size: Size(width: 8, height: 6)) { + view.padding(EdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4)) + } + + #expect(canvas.pixels == [ + Position(x: 3, y: 2): pixel, + Position(x: 4, y: 2): pixel, + Position(x: 3, y: 3): pixel, + Position(x: 4, y: 3): pixel, + ]) + } + + @Test("all") + func all() async throws { + + canvas.render(size: Size(width: 3, height: 3)) { + view.padding(.all, 1) + } + + #expect(canvas.pixels == [ + Position(x: 2, y: 2): pixel, + ]) + } + + @Test("top") + func top() async throws { + + canvas.render(size: Size(width: 3, height: 3)) { + view.padding(.top, 1) + } + + #expect(canvas.pixels == [ + Position(x: 1, y: 2): pixel, + Position(x: 2, y: 2): pixel, + Position(x: 3, y: 2): pixel, + Position(x: 1, y: 3): pixel, + Position(x: 2, y: 3): pixel, + Position(x: 3, y: 3): pixel, + ]) + } + + @Test("leading") + func leading() async throws { + + canvas.render(size: Size(width: 3, height: 3)) { + view.padding(.leading, 1) + } + + #expect(canvas.pixels == [ + Position(x: 2, y: 1): pixel, + Position(x: 3, y: 1): pixel, + Position(x: 2, y: 2): pixel, + Position(x: 3, y: 2): pixel, + Position(x: 2, y: 3): pixel, + Position(x: 3, y: 3): pixel, + ]) + } + + @Test("bottom") + func bottom() async throws { + + canvas.render(size: Size(width: 3, height: 3)) { + view.padding(.bottom, 1) + } + + #expect(canvas.pixels == [ + Position(x: 1, y: 1): pixel, + Position(x: 2, y: 1): pixel, + Position(x: 3, y: 1): pixel, + Position(x: 1, y: 2): pixel, + Position(x: 2, y: 2): pixel, + Position(x: 3, y: 2): pixel, + ]) + } + + @Test("trailing") + func trailing() async throws { + + canvas.render(size: Size(width: 3, height: 3)) { + view.padding(.trailing, 1) + } + + #expect(canvas.pixels == [ + Position(x: 1, y: 1): pixel, + Position(x: 2, y: 1): pixel, + Position(x: 1, y: 2): pixel, + Position(x: 2, y: 2): pixel, + Position(x: 1, y: 3): pixel, + Position(x: 2, y: 3): pixel, + ]) + } + + @Test("horizontal") + func horizontal() async throws { + + canvas.render(size: Size(width: 3, height: 3)) { + view.padding(.horizontal, 1) + } + + #expect(canvas.pixels == [ + Position(x: 2, y: 1): pixel, + Position(x: 2, y: 2): pixel, + Position(x: 2, y: 3): pixel, + ]) + } + + @Test("vertical") + func vertical() async throws { + + canvas.render(size: Size(width: 3, height: 3)) { + view.padding(.vertical, 1) + } + + #expect(canvas.pixels == [ + Position(x: 1, y: 2): pixel, + Position(x: 2, y: 2): pixel, + Position(x: 3, y: 2): pixel, + ]) + } + + @Test("length") + func length() async throws { + + canvas.render(size: Size(width: 4, height: 4)) { + view.padding(1) + } + + #expect(canvas.pixels == [ + Position(x: 2, y: 2): pixel, + Position(x: 3, y: 2): pixel, + Position(x: 2, y: 3): pixel, + Position(x: 3, y: 3): pixel, + ]) + } +}