From 9985b55f88e6919a1a37be204592f82a19e89149 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Thu, 5 Dec 2024 22:38:42 -0500 Subject: [PATCH] feat: Adds vertical and horizontal separators, renames modifier protocol. --- Sources/CliDoc/Nodes/ExampleSection.swift | 6 +- Sources/CliDoc/Nodes/Note.swift | 2 +- Sources/CliDoc/Nodes/ShellCommand.swift | 2 +- Sources/CliDocCore/NodeModifier.swift | 48 ---------- Sources/CliDocCore/Nodes/Empty.swift | 2 + Sources/CliDocCore/Nodes/Group.swift | 5 ++ Sources/CliDocCore/Nodes/HStack.swift | 7 +- Sources/CliDocCore/Nodes/Section.swift | 4 +- Sources/CliDocCore/Nodes/Separator.swift | 66 ++++++++++++++ Sources/CliDocCore/Nodes/VStack.swift | 11 ++- Sources/CliDocCore/TextModifier.swift | 11 +++ .../{Modifiers => }/TextStyle.swift | 81 +++++++++++++---- Sources/CliDocCore/Utils.swift | 11 --- Tests/CliDocCoreTests/CliDocCoreTests.swift | 88 +++++++++++++++++++ Tests/CliDocTests/CliDocTests.swift | 2 +- 15 files changed, 253 insertions(+), 93 deletions(-) delete mode 100644 Sources/CliDocCore/NodeModifier.swift create mode 100644 Sources/CliDocCore/Nodes/Separator.swift create mode 100644 Sources/CliDocCore/TextModifier.swift rename Sources/CliDocCore/{Modifiers => }/TextStyle.swift (51%) diff --git a/Sources/CliDoc/Nodes/ExampleSection.swift b/Sources/CliDoc/Nodes/ExampleSection.swift index a0340d9..5762e8e 100644 --- a/Sources/CliDoc/Nodes/ExampleSection.swift +++ b/Sources/CliDoc/Nodes/ExampleSection.swift @@ -69,8 +69,8 @@ public struct ExampleConfiguration { // MARK: - Style -public protocol ExampleSectionStyle: NodeModifier where Content == ExampleSectionConfiguration {} -public protocol ExampleStyle: NodeModifier where Content == ExampleConfiguration {} +public protocol ExampleSectionStyle: TextModifier where Content == ExampleSectionConfiguration {} +public protocol ExampleStyle: TextModifier where Content == ExampleConfiguration {} public extension ExampleSection { @@ -149,7 +149,7 @@ public struct DefaultExampleStyle: ExampleStyle { @inlinable public func render(content: ExampleConfiguration) -> some TextNode { - VStack(spacing: 2) { + VStack(separator: .newLine(count: 2)) { content.examples.map { example in VStack { Label(example.label.green.bold) diff --git a/Sources/CliDoc/Nodes/Note.swift b/Sources/CliDoc/Nodes/Note.swift index f54a328..f152c98 100644 --- a/Sources/CliDoc/Nodes/Note.swift +++ b/Sources/CliDoc/Nodes/Note.swift @@ -86,7 +86,7 @@ public extension Note { // MARK: - Style -public protocol NoteStyleModifier: NodeModifier where Content == NoteStyleConfiguration {} +public protocol NoteStyleModifier: TextModifier where Content == NoteStyleConfiguration {} public extension NoteStyleModifier where Self == DefaultNoteStyle { static var `default`: Self { diff --git a/Sources/CliDoc/Nodes/ShellCommand.swift b/Sources/CliDoc/Nodes/ShellCommand.swift index 2a400df..9748ad8 100644 --- a/Sources/CliDoc/Nodes/ShellCommand.swift +++ b/Sources/CliDoc/Nodes/ShellCommand.swift @@ -46,7 +46,7 @@ public extension ShellCommand { // MARK: - Style -public protocol ShellCommandStyle: NodeModifier where Self.Content == ShellCommandConfiguration {} +public protocol ShellCommandStyle: TextModifier where Self.Content == ShellCommandConfiguration {} public extension ShellCommandStyle where Self == DefaultShellCommandStyle { static var `default`: Self { DefaultShellCommandStyle() } diff --git a/Sources/CliDocCore/NodeModifier.swift b/Sources/CliDocCore/NodeModifier.swift deleted file mode 100644 index 5fccadc..0000000 --- a/Sources/CliDocCore/NodeModifier.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Rainbow - -public protocol NodeModifier { - // swiftlint:disable type_name - associatedtype _Body: TextNode - typealias Body = _Body - // swiftlint:enable type_name - - associatedtype Content - - @TextBuilder - func render(content: Content) -> Body -} - -public struct ModifiedNode { - - @usableFromInline - let content: Content - - @usableFromInline - let modifier: Modifier - - @usableFromInline - init(content: Content, modifier: Modifier) { - self.content = content - self.modifier = modifier - } -} - -extension ModifiedNode: TextNode where Modifier.Content == Content { - public var body: some TextNode { - modifier.render(content: content) - } -} - -extension ModifiedNode: NodeRepresentable where Self: TextNode { - @inlinable - public func render() -> String { - body.render() - } -} - -public extension TextNode { - @inlinable - func modifier(_ modifier: M) -> ModifiedNode { - .init(content: self, modifier: modifier) - } -} diff --git a/Sources/CliDocCore/Nodes/Empty.swift b/Sources/CliDocCore/Nodes/Empty.swift index 573f09c..f84924d 100644 --- a/Sources/CliDocCore/Nodes/Empty.swift +++ b/Sources/CliDocCore/Nodes/Empty.swift @@ -1,4 +1,6 @@ /// An empty text node. +/// +/// This gets removed from any output when rendering text nodes. public struct Empty: TextNode { @inlinable diff --git a/Sources/CliDocCore/Nodes/Group.swift b/Sources/CliDocCore/Nodes/Group.swift index 01f2fe9..575d036 100644 --- a/Sources/CliDocCore/Nodes/Group.swift +++ b/Sources/CliDocCore/Nodes/Group.swift @@ -1,4 +1,9 @@ +/// A group of text nodes. +/// +/// This allows you to group content together, which can optionally be +/// styled. public struct Group: TextNode { + @usableFromInline var content: Content diff --git a/Sources/CliDocCore/Nodes/HStack.swift b/Sources/CliDocCore/Nodes/HStack.swift index 018cd31..c5fe877 100644 --- a/Sources/CliDocCore/Nodes/HStack.swift +++ b/Sources/CliDocCore/Nodes/HStack.swift @@ -1,18 +1,19 @@ +/// A horizontal group of text nodes. public struct HStack: TextNode { @usableFromInline let content: [any TextNode] @usableFromInline - let separator: any TextNode + let separator: Separator.Horizontal @inlinable public init( - spacing: Int = 1, + separator: Separator.Horizontal = .space(count: 1), @TextBuilder content: () -> any TextNode ) { self.content = array(from: content()) - self.separator = seperator(" ", count: spacing > 0 ? spacing - 1 : 0) + self.separator = separator } @inlinable diff --git a/Sources/CliDocCore/Nodes/Section.swift b/Sources/CliDocCore/Nodes/Section.swift index 6e7378d..57113ab 100644 --- a/Sources/CliDocCore/Nodes/Section.swift +++ b/Sources/CliDocCore/Nodes/Section.swift @@ -78,7 +78,7 @@ public struct SectionConfiguration { } } -public protocol SectionStyle: NodeModifier where Content == SectionConfiguration {} +public protocol SectionStyle: TextModifier where Content == SectionConfiguration {} public extension SectionStyle where Self == DefaultSectionStyle { static var `default`: Self { DefaultSectionStyle() } @@ -87,7 +87,7 @@ public extension SectionStyle where Self == DefaultSectionStyle { public struct DefaultSectionStyle: SectionStyle { public func render(content: SectionConfiguration) -> some TextNode { - VStack(spacing: 2) { + VStack(separator: .newLine(count: 2)) { content.header content.content content.footer diff --git a/Sources/CliDocCore/Nodes/Separator.swift b/Sources/CliDocCore/Nodes/Separator.swift new file mode 100644 index 0000000..4e7e9b6 --- /dev/null +++ b/Sources/CliDocCore/Nodes/Separator.swift @@ -0,0 +1,66 @@ +public enum Separator { + + /// Represents a horizontal separator that can be used between text nodes, typically inside + /// an ``HStack`` + public enum Horizontal: TextNode { + /// Separate nodes by spaces of the given count. + case space(count: Int = 1) + + /// Separate nodes by tabs of the given count. + case tab(count: Int = 1) + + /// Separate nodes by the provided string of the given count. + case custom(String, count: Int = 1) + + @TextBuilder + @inlinable + public var body: some TextNode { + switch self { + case let .tab(count: count): + seperator("\t", count: count) + case let .space(count: count): + seperator(" ", count: count) + case let .custom(string, count: count): + seperator(string, count: count) + } + } + } + + /// Represents a vertical separator that can be used between text nodes, typically inside + /// a ``VStack`` + public enum Vertical: TextNode { + case newLine(count: Int = 1) + case custom(String, count: Int = 1) + + @TextBuilder + @inlinable + public var body: some TextNode { + switch self { + case let .newLine(count: count): + seperator("\n", count: count) + case let .custom(string, count: count): + seperator(string, count: count) + } + } + } + +} + +@usableFromInline +func ensuredCount(_ count: Int) -> Int { + guard count >= 1 else { return 1 } + return count +} + +@usableFromInline +func seperator(_ separator: String, count: Int) -> some TextNode { + let count = ensuredCount(count) + + assert(count >= 1, "Invalid count while creating a separator") + + var output = "" + for _ in 1 ... count { + output += separator + } + return output +} diff --git a/Sources/CliDocCore/Nodes/VStack.swift b/Sources/CliDocCore/Nodes/VStack.swift index c611ff4..137ecc4 100644 --- a/Sources/CliDocCore/Nodes/VStack.swift +++ b/Sources/CliDocCore/Nodes/VStack.swift @@ -1,18 +1,21 @@ -public struct VStack: TextNode { +/// A vertical stack of text nodes. +/// +/// +public struct VStack: TextNode { @usableFromInline let content: [any TextNode] @usableFromInline - let separator: any TextNode + let separator: Separator.Vertical @inlinable public init( - spacing: Int = 1, + separator: Separator.Vertical = .newLine(count: 1), @TextBuilder content: () -> any TextNode ) { self.content = array(from: content()) - self.separator = seperator("\n", count: spacing > 0 ? spacing - 1 : 0) + self.separator = separator } @inlinable diff --git a/Sources/CliDocCore/TextModifier.swift b/Sources/CliDocCore/TextModifier.swift new file mode 100644 index 0000000..46a9e1c --- /dev/null +++ b/Sources/CliDocCore/TextModifier.swift @@ -0,0 +1,11 @@ +public protocol TextModifier { + // swiftlint:disable type_name + associatedtype _Body: TextNode + typealias Body = _Body + // swiftlint:enable type_name + + associatedtype Content + + @TextBuilder + func render(content: Content) -> Body +} diff --git a/Sources/CliDocCore/Modifiers/TextStyle.swift b/Sources/CliDocCore/TextStyle.swift similarity index 51% rename from Sources/CliDocCore/Modifiers/TextStyle.swift rename to Sources/CliDocCore/TextStyle.swift index 23d1fe8..74ffd8a 100644 --- a/Sources/CliDocCore/Modifiers/TextStyle.swift +++ b/Sources/CliDocCore/TextStyle.swift @@ -13,14 +13,29 @@ public extension TextNode { } @inlinable - func color(red: UInt8, green: UInt8, blue: UInt8) -> some TextNode { + func color(_ red: UInt8, _ green: UInt8, _ blue: UInt8) -> some TextNode { textStyle(.color(rgb: (red, green, blue))) } + @inlinable + func backgroundColor(_ name: NamedBackgroundColor) -> some TextNode { + textStyle(.backgroundColor(name)) + } + + @inlinable + func backgroundColor(_ bit8: UInt8) -> some TextNode { + textStyle(.backgroundColor(bit8: bit8)) + } + + @inlinable + func backgroundColor(_ red: UInt8, _ green: UInt8, _ blue: UInt8) -> some TextNode { + textStyle(.backgroundColor(rgb: (red, green, blue))) + } + @inlinable func textStyle(_ styles: S...) -> some TextNode { styles.reduce(render()) { string, style in - style.render(content: string).render() + style.render(content: .init(string)).render() } } @@ -44,10 +59,20 @@ public extension TextNode { } -// TODO: Remove string restraint. -public protocol TextStyle: NodeModifier where Content == String {} +public protocol TextStyle: TextModifier where Content == TextStyleConfiguration {} + +public struct TextStyleConfiguration { + @usableFromInline + let node: any TextNode + + @usableFromInline + init(_ node: any TextNode) { + self.node = node + } +} public extension TextStyle where Self == StyledText { + @inlinable static var bold: Self { .init(.bold) } @@ -63,9 +88,6 @@ public extension TextStyle where Self == StyledText { @inlinable static var blink: Self { .init(.blink) } - @inlinable - static var swap: Self { .init(.swap) } - @inlinable static var strikeThrough: Self { .init(.strikethrough) } } @@ -74,37 +96,58 @@ public extension TextStyle where Self == ColorTextStyle { @inlinable static func color(_ name: NamedColor) -> Self { - .init(.named(name)) + .init(.foreground(.named(name))) } @inlinable static func color(bit8: UInt8) -> Self { - .init(.bit8(bit8)) + .init(.foreground(.bit8(bit8))) } @inlinable static func color(rgb: RGB) -> Self { - .init(.bit24(rgb)) + .init(.foreground(.bit24(rgb))) + } + + @inlinable + static func backgroundColor(_ name: NamedBackgroundColor) -> Self { + .init(.background(.named(name))) + } + + @inlinable + static func backgroundColor(bit8: UInt8) -> Self { + .init(.background(.bit8(bit8))) + } + + @inlinable + static func backgroundColor(rgb: RGB) -> Self { + .init(.background(.bit24(rgb))) } } public struct ColorTextStyle: TextStyle { - enum Location { + @usableFromInline + enum Style { case foreground(ColorType) - case background(ColorType) + case background(BackgroundColorType) } @usableFromInline - let color: ColorType + let style: Style @usableFromInline - init(_ color: ColorType) { - self.color = color + init(_ style: Style) { + self.style = style } @inlinable - public func render(content: String) -> some TextNode { - content.applyingColor(color) + public func render(content: TextStyleConfiguration) -> some TextNode { + switch style { + case let .foreground(color): + return content.node.render().applyingColor(color) + case let .background(color): + return content.node.render().applyingBackgroundColor(color) + } } } @@ -118,7 +161,7 @@ public struct StyledText: TextStyle { } @inlinable - public func render(content: String) -> some TextNode { - content.applyingStyle(style) + public func render(content: TextStyleConfiguration) -> some TextNode { + content.node.render().applyingStyle(style) } } diff --git a/Sources/CliDocCore/Utils.swift b/Sources/CliDocCore/Utils.swift index d6fbe5d..183aa9b 100644 --- a/Sources/CliDocCore/Utils.swift +++ b/Sources/CliDocCore/Utils.swift @@ -9,17 +9,6 @@ func array(from node: any TextNode) -> [any TextNode] { } } -@usableFromInline -func seperator(_ separator: String, count: Int) -> any TextNode { - assert(count >= 0, "Invalid count while creating a separator") - - var output = "" - for _ in 0 ... count { - output += separator - } - return output -} - extension Array where Element == (any TextNode) { @usableFromInline diff --git a/Tests/CliDocCoreTests/CliDocCoreTests.swift b/Tests/CliDocCoreTests/CliDocCoreTests.swift index 031522c..8ce5dea 100644 --- a/Tests/CliDocCoreTests/CliDocCoreTests.swift +++ b/Tests/CliDocCoreTests/CliDocCoreTests.swift @@ -32,6 +32,18 @@ struct CliDocCoreTests { "bar" } #expect(stack.render() == "foo bar") + + let tabStack = HStack(separator: .tab()) { + "foo" + "bar" + } + #expect(tabStack.render() == "foo\tbar") + + let customStack = HStack(separator: .custom(":blob:")) { + "foo" + "bar" + } + #expect(customStack.render() == "foo:blob:bar") } @Test @@ -45,6 +57,15 @@ struct CliDocCoreTests { foo bar """) + + let customStack = VStack(separator: .custom("\n\t")) { + "foo" + "bar" + } + #expect(customStack.render() == """ + foo + \tbar + """) } @Test @@ -65,6 +86,73 @@ struct CliDocCoreTests { #expect(array.render() == "foo bar") } + @Test(arguments: [ + Style.bold, .italic, .dim, .underline, .blink, .strikethrough + ]) + func testTextStyles(style: Style) { + let node = Group { "foo" }.textStyle(StyledText(style)) + let string = "foo".applyingStyle(style) + #expect(node.render() == string) + } + + @Test + func testTextStylesDirectlyOnNode() { + let bold = Group { "foo" }.bold() + let string = "foo".bold + #expect(bold.render() == string) + + let dim = Group { "foo" }.dim() + let dimString = "foo".dim + #expect(dim.render() == dimString) + + let italic = Group { "foo" }.italic() + let italicString = "foo".italic + #expect(italic.render() == italicString) + + let blink = Group { "foo" }.blink() + let blinkString = "foo".blink + #expect(blink.render() == blinkString) + + let strikeThrough = Group { "foo" }.strikeThrough() + let strikeThroughString = "foo".applyingStyle(.strikethrough) + #expect(strikeThrough.render() == strikeThroughString) + + let underline = Group { "foo" }.underline() + let underlineString = "foo".underline + #expect(underline.render() == underlineString) + } + + @Test(arguments: NamedColor.allCases) + func testNamedColors(color: NamedColor) { + let foregroundNode = Group { "foo" }.color(color) + let string = "foo".applyingColor(color) + #expect(foregroundNode.render() == string) + + let backgroundNode = Group { "foo" }.backgroundColor(color.toBackgroundColor) + let backgroundString = "foo".applyingBackgroundColor(color.toBackgroundColor) + #expect(backgroundNode.render() == backgroundString) + } + + @Test + func testBit8Colors() { + let color: UInt8 = 32 + let foregroundNode = Group { "foo" }.color(color) + let string = "foo".applyingColor(.bit8(color)) + #expect(foregroundNode.render() == string) + + let backgroundNode = Group { "foo" }.backgroundColor(color) + let backgroundString = "foo".applyingBackgroundColor(.bit8(color)) + #expect(backgroundNode.render() == backgroundString) + + let rgbNode = Group { "foo" }.color(color, color, color) + let rgbString = "foo".applyingColor(.bit24((color, color, color))) + #expect(rgbNode.render() == rgbString) + + let rgbBackgroundNode = Group { "foo" }.backgroundColor(color, color, color) + let rgbBackgroundString = "foo".applyingBackgroundColor(.bit24((color, color, color))) + #expect(rgbBackgroundNode.render() == rgbBackgroundString) + } + @Test( arguments: SectionArg.arguments ) diff --git a/Tests/CliDocTests/CliDocTests.swift b/Tests/CliDocTests/CliDocTests.swift index dd2cbea..458b361 100644 --- a/Tests/CliDocTests/CliDocTests.swift +++ b/Tests/CliDocTests/CliDocTests.swift @@ -95,7 +95,7 @@ extension ExampleSectionStyle where Self == DefaultExampleSectionStyle some TextNode { - VStack(spacing: 2) { + VStack(separator: .newLine(count: 2)) { content.examples.map { example in VStack { example.label.red