diff --git a/README.md b/README.md index 0ee041d..f2441c9 100644 --- a/README.md +++ b/README.md @@ -201,14 +201,34 @@ struct PullRequestView: View { ### View Modifiers - `.diffTheme(_ theme: DiffTheme)` - Apply a color theme -- `.diffLineNumbers(_ show: Bool)` - Toggle line numbers -- `.diffFileHeaders(_ show: Bool)` - Toggle file headers +- `.diffLineNumbers(_ show: Bool)` - Toggle line numbers (legacy; prefer `.diffLineNumberStyle(_:)`) +- `.diffLineNumberStyle(_ style: LineNumberStyle)` - Gutter style: `.hidden`, `.single` (mobile-friendly compact column), `.dual` (desktop old/new) +- `.diffFileHeaders(_ show: Bool)` - Toggle file headers (the `diff --git` / `---` / `+++` block) +- `.diffHunkHeaders(_ show: Bool)` - Toggle the per-hunk `@@ -a,b +c,d @@` separator - `.diffFont(size: CGFloat?, weight: Font.Weight?, design: Font.Design?)` - Configure font - `.diffLineSpacing(_ spacing: LineSpacing)` - Set line spacing - `.diffWordWrap(_ wrap: Bool)` - Enable word wrapping - `.diffConfiguration(_ config: DiffConfiguration)` - Apply complete configuration - `.diffParser(_ parser: any DiffParsing)` - Plug in a custom parser (see below) +### Mobile-friendly defaults + +For phones and other narrow viewports the `.dual` gutter is cramped and the `@@` hunk header is rarely useful. A typical mobile renderer looks like: + +```swift +DiffRenderer(diffText: text) + .diffLineNumberStyle(.single) // one column, new# for + / old# for - + .diffHunkHeaders(false) // hide @@ -a,b +c,d @@ + .diffTheme(.dark) +``` + +There's also a `.mobile` preset that bundles these: + +```swift +DiffRenderer(diffText: text) + .diffConfiguration(.mobile) +``` + ## Custom Diff Formats `DiffRenderer` accepts unified-diff text by default. To consume any other format — annotated diffs, server-side payloads, JSON patches, language-server output — implement `DiffParsing` and inject it via the `.diffParser(_:)` modifier: diff --git a/Sources/gitdiff/DiffConfiguration.swift b/Sources/gitdiff/DiffConfiguration.swift index c3bf9e5..4cd51f9 100644 --- a/Sources/gitdiff/DiffConfiguration.swift +++ b/Sources/gitdiff/DiffConfiguration.swift @@ -4,7 +4,7 @@ import SwiftUI /// /// ## Topics /// ### Creating Configurations -/// - ``init(theme:showLineNumbers:showFileHeaders:fontFamily:fontSize:fontWeight:lineHeight:lineSpacing:wordWrap:contentPadding:)`` +/// - ``init(theme:showLineNumbers:lineNumberStyle:showFileHeaders:showHunkHeaders:fontFamily:fontSize:fontWeight:lineHeight:lineSpacing:wordWrap:contentPadding:)`` /// - ``default`` /// - ``compact`` /// - ``comfortable`` @@ -12,39 +12,57 @@ import SwiftUI public struct DiffConfiguration { /// The theme to use for colors public let theme: DiffTheme - - /// Whether to show line numbers + + /// Whether to show line numbers (legacy API — for richer control over + /// the gutter layout, prefer ``lineNumberStyle``). public let showLineNumbers: Bool - + + /// How the line-number gutter is rendered. Defaults to ``LineNumberStyle/dual`` + /// (the side-by-side `old | new` columns standard for unified diffs). + /// + /// - ``LineNumberStyle/hidden``: no gutter at all (equivalent to + /// `showLineNumbers = false`). + /// - ``LineNumberStyle/single``: a single column showing the new line + /// number for added/context lines and the old line number for removed + /// lines — compact and well-suited to narrow viewports (mobile). + /// - ``LineNumberStyle/dual``: two columns, old then new — full context, + /// wider gutter, the desktop convention. + public let lineNumberStyle: LineNumberStyle + /// Whether to show file headers public let showFileHeaders: Bool - + + /// Whether to show the per-hunk `@@ -x,y +x,y @@` separator line. + /// Default `true`; turn off for minimal renderers where the hunk + /// boundary is implied by the line backgrounds. + public let showHunkHeaders: Bool + /// Font family for code content public let fontFamily: Font.Design - + /// Font size for code content public let fontSize: CGFloat - + /// Font weight for code content public let fontWeight: Font.Weight - + /// Line height multiplier public let lineHeight: CGFloat - + /// Line spacing mode public let lineSpacing: LineSpacing - + /// Whether to wrap long lines public let wordWrap: Bool - + /// Padding for content public let contentPadding: EdgeInsets - + public enum LineSpacing { case compact case comfortable case spacious - + var value: CGFloat { switch self { case .compact: return 0 @@ -53,11 +71,20 @@ public struct DiffConfiguration { } } } - + + /// How the line-number gutter is rendered. See ``lineNumberStyle``. + public enum LineNumberStyle: Sendable, Hashable { + case hidden + case single + case dual + } + public init( theme: DiffTheme = .github, showLineNumbers: Bool = true, + lineNumberStyle: LineNumberStyle? = nil, showFileHeaders: Bool = true, + showHunkHeaders: Bool = true, fontFamily: Font.Design = .monospaced, fontSize: CGFloat = 13, fontWeight: Font.Weight = .regular, @@ -68,7 +95,12 @@ public struct DiffConfiguration { ) { self.theme = theme self.showLineNumbers = showLineNumbers + // If the caller didn't ask for a specific style we infer one from + // the legacy `showLineNumbers` flag so existing call sites keep + // their current behaviour. + self.lineNumberStyle = lineNumberStyle ?? (showLineNumbers ? .dual : .hidden) self.showFileHeaders = showFileHeaders + self.showHunkHeaders = showHunkHeaders self.fontFamily = fontFamily self.fontSize = fontSize self.fontWeight = fontWeight @@ -82,23 +114,23 @@ public struct DiffConfiguration { public extension DiffConfiguration { /// Default GitHub-style configuration static let `default` = DiffConfiguration() - + /// Compact configuration with minimal spacing static let compact = DiffConfiguration( fontSize: 12, lineSpacing: .compact, contentPadding: EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) ) - + /// Comfortable configuration with more spacing static let comfortable = DiffConfiguration( fontSize: 14, lineSpacing: .comfortable, contentPadding: EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16) ) - + /// Dark mode configuration with VS Code theme static let darkMode = DiffConfiguration( theme: .vsCodeDark ) -} \ No newline at end of file +} diff --git a/Sources/gitdiff/DiffConfigurationBuilder.swift b/Sources/gitdiff/DiffConfigurationBuilder.swift index 29de22c..bd020ce 100644 --- a/Sources/gitdiff/DiffConfigurationBuilder.swift +++ b/Sources/gitdiff/DiffConfigurationBuilder.swift @@ -7,7 +7,9 @@ public extension DiffConfiguration { DiffConfiguration( theme: theme, showLineNumbers: showLineNumbers, + lineNumberStyle: lineNumberStyle, showFileHeaders: showFileHeaders, + showHunkHeaders: showHunkHeaders, fontFamily: fontFamily, fontSize: fontSize, fontWeight: fontWeight, @@ -17,13 +19,16 @@ public extension DiffConfiguration { contentPadding: contentPadding ) } - - /// Creates a new configuration with line numbers toggled + + /// Creates a new configuration with line numbers toggled (legacy API). + /// Prefer ``withLineNumberStyle(_:)`` for finer control. func withLineNumbers(_ show: Bool) -> DiffConfiguration { DiffConfiguration( theme: theme, showLineNumbers: show, + lineNumberStyle: show ? .dual : .hidden, showFileHeaders: showFileHeaders, + showHunkHeaders: showHunkHeaders, fontFamily: fontFamily, fontSize: fontSize, fontWeight: fontWeight, @@ -33,13 +38,33 @@ public extension DiffConfiguration { contentPadding: contentPadding ) } - + + /// Creates a new configuration with the specified line-number gutter style. + func withLineNumberStyle(_ style: LineNumberStyle) -> DiffConfiguration { + DiffConfiguration( + theme: theme, + showLineNumbers: style != .hidden, + lineNumberStyle: style, + showFileHeaders: showFileHeaders, + showHunkHeaders: showHunkHeaders, + fontFamily: fontFamily, + fontSize: fontSize, + fontWeight: fontWeight, + lineHeight: lineHeight, + lineSpacing: lineSpacing, + wordWrap: wordWrap, + contentPadding: contentPadding + ) + } + /// Creates a new configuration with the specified font settings func withFont(size: CGFloat? = nil, weight: Font.Weight? = nil, design: Font.Design? = nil) -> DiffConfiguration { DiffConfiguration( theme: theme, showLineNumbers: showLineNumbers, + lineNumberStyle: lineNumberStyle, showFileHeaders: showFileHeaders, + showHunkHeaders: showHunkHeaders, fontFamily: design ?? fontFamily, fontSize: size ?? fontSize, fontWeight: weight ?? fontWeight, @@ -49,13 +74,15 @@ public extension DiffConfiguration { contentPadding: contentPadding ) } - + /// Creates a new configuration with the specified line spacing func withLineSpacing(_ spacing: LineSpacing) -> DiffConfiguration { DiffConfiguration( theme: theme, showLineNumbers: showLineNumbers, + lineNumberStyle: lineNumberStyle, showFileHeaders: showFileHeaders, + showHunkHeaders: showHunkHeaders, fontFamily: fontFamily, fontSize: fontSize, fontWeight: fontWeight, @@ -65,13 +92,33 @@ public extension DiffConfiguration { contentPadding: contentPadding ) } - + /// Creates a new configuration with file headers toggled func withFileHeaders(_ show: Bool) -> DiffConfiguration { DiffConfiguration( theme: theme, showLineNumbers: showLineNumbers, + lineNumberStyle: lineNumberStyle, showFileHeaders: show, + showHunkHeaders: showHunkHeaders, + fontFamily: fontFamily, + fontSize: fontSize, + fontWeight: fontWeight, + lineHeight: lineHeight, + lineSpacing: lineSpacing, + wordWrap: wordWrap, + contentPadding: contentPadding + ) + } + + /// Creates a new configuration with hunk headers toggled + func withHunkHeaders(_ show: Bool) -> DiffConfiguration { + DiffConfiguration( + theme: theme, + showLineNumbers: showLineNumbers, + lineNumberStyle: lineNumberStyle, + showFileHeaders: showFileHeaders, + showHunkHeaders: show, fontFamily: fontFamily, fontSize: fontSize, fontWeight: fontWeight, @@ -87,7 +134,9 @@ public extension DiffConfiguration { DiffConfiguration( theme: theme, showLineNumbers: showLineNumbers, + lineNumberStyle: lineNumberStyle, showFileHeaders: showFileHeaders, + showHunkHeaders: showHunkHeaders, fontFamily: fontFamily, fontSize: fontSize, fontWeight: fontWeight, @@ -108,16 +157,18 @@ public extension DiffConfiguration { .withLineNumbers(true) .withFont(size: 13) .withLineSpacing(.comfortable) - + /// Configuration optimized for mobile with smaller fonts. static let mobile = DiffConfiguration.default + .withLineNumberStyle(.single) + .withHunkHeaders(false) .withFont(size: 12) .withLineSpacing(.compact) .withWordWrap(true) - + /// Configuration for presentations with larger fonts. static let presentation = DiffConfiguration.default .withFont(size: 16, weight: .medium) .withLineSpacing(.spacious) .withLineNumbers(false) -} \ No newline at end of file +} diff --git a/Sources/gitdiff/DiffEnvironment.swift b/Sources/gitdiff/DiffEnvironment.swift index 925872f..55a89fe 100644 --- a/Sources/gitdiff/DiffEnvironment.swift +++ b/Sources/gitdiff/DiffEnvironment.swift @@ -38,56 +38,44 @@ public extension View { /// - Parameter theme: The theme to apply func diffTheme(_ theme: DiffTheme) -> some View { transformEnvironment(\.diffConfiguration) { config in - config = DiffConfiguration( - theme: theme, - showLineNumbers: config.showLineNumbers, - showFileHeaders: config.showFileHeaders, - fontFamily: config.fontFamily, - fontSize: config.fontSize, - fontWeight: config.fontWeight, - lineHeight: config.lineHeight, - lineSpacing: config.lineSpacing, - wordWrap: config.wordWrap, - contentPadding: config.contentPadding - ) + config = config.with(theme: theme) } } - /// Shows or hides line numbers. - /// - Parameter show: Whether to show line numbers + /// Shows or hides line numbers (legacy API — for finer control prefer + /// ``diffLineNumberStyle(_:)``). Maps `true` → ``DiffConfiguration/LineNumberStyle/dual``, + /// `false` → ``DiffConfiguration/LineNumberStyle/hidden``. func diffLineNumbers(_ show: Bool) -> some View { transformEnvironment(\.diffConfiguration) { config in - config = DiffConfiguration( - theme: config.theme, - showLineNumbers: show, - showFileHeaders: config.showFileHeaders, - fontFamily: config.fontFamily, - fontSize: config.fontSize, - fontWeight: config.fontWeight, - lineHeight: config.lineHeight, - lineSpacing: config.lineSpacing, - wordWrap: config.wordWrap, - contentPadding: config.contentPadding - ) + config = config.withLineNumbers(show) } } - /// Shows or hides file headers. - /// - Parameter show: Whether to show file headers + /// Selects how the line-number gutter is rendered: + /// `.hidden` (no gutter), `.single` (compact single column — ideal for + /// mobile / narrow viewports), or `.dual` (side-by-side old/new + /// columns — the desktop default). + func diffLineNumberStyle(_ style: DiffConfiguration.LineNumberStyle) -> some View { + transformEnvironment(\.diffConfiguration) { config in + config = config.withLineNumberStyle(style) + } + } + + /// Shows or hides file headers (the `diff --git` / `---` / `+++` header + /// block at the top of each file). func diffFileHeaders(_ show: Bool) -> some View { transformEnvironment(\.diffConfiguration) { config in - config = DiffConfiguration( - theme: config.theme, - showLineNumbers: config.showLineNumbers, - showFileHeaders: show, - fontFamily: config.fontFamily, - fontSize: config.fontSize, - fontWeight: config.fontWeight, - lineHeight: config.lineHeight, - lineSpacing: config.lineSpacing, - wordWrap: config.wordWrap, - contentPadding: config.contentPadding - ) + config = config.withFileHeaders(show) + } + } + + /// Shows or hides per-hunk `@@ -a,b +c,d @@` separator lines. Hide + /// these in minimal renderers where hunk boundaries are already + /// implied by line backgrounds — the prose around the diff (a chip + /// showing filename + stats, a sheet title, etc.) carries the location. + func diffHunkHeaders(_ show: Bool) -> some View { + transformEnvironment(\.diffConfiguration) { config in + config = config.withHunkHeaders(show) } } @@ -98,18 +86,7 @@ public extension View { /// - design: Font design (e.g., monospaced) func diffFont(size: CGFloat? = nil, weight: Font.Weight? = nil, design: Font.Design? = nil) -> some View { transformEnvironment(\.diffConfiguration) { config in - config = DiffConfiguration( - theme: config.theme, - showLineNumbers: config.showLineNumbers, - showFileHeaders: config.showFileHeaders, - fontFamily: design ?? config.fontFamily, - fontSize: size ?? config.fontSize, - fontWeight: weight ?? config.fontWeight, - lineHeight: config.lineHeight, - lineSpacing: config.lineSpacing, - wordWrap: config.wordWrap, - contentPadding: config.contentPadding - ) + config = config.withFont(size: size, weight: weight, design: design) } } @@ -117,37 +94,15 @@ public extension View { /// - Parameter spacing: The spacing mode func diffLineSpacing(_ spacing: DiffConfiguration.LineSpacing) -> some View { transformEnvironment(\.diffConfiguration) { config in - config = DiffConfiguration( - theme: config.theme, - showLineNumbers: config.showLineNumbers, - showFileHeaders: config.showFileHeaders, - fontFamily: config.fontFamily, - fontSize: config.fontSize, - fontWeight: config.fontWeight, - lineHeight: config.lineHeight, - lineSpacing: spacing, - wordWrap: config.wordWrap, - contentPadding: config.contentPadding - ) + config = config.withLineSpacing(spacing) } } - + /// Enables or disables word wrapping. /// - Parameter wrap: Whether to wrap long lines func diffWordWrap(_ wrap: Bool) -> some View { transformEnvironment(\.diffConfiguration) { config in - config = DiffConfiguration( - theme: config.theme, - showLineNumbers: config.showLineNumbers, - showFileHeaders: config.showFileHeaders, - fontFamily: config.fontFamily, - fontSize: config.fontSize, - fontWeight: config.fontWeight, - lineHeight: config.lineHeight, - lineSpacing: config.lineSpacing, - wordWrap: wrap, - contentPadding: config.contentPadding - ) + config = config.withWordWrap(wrap) } } @@ -167,7 +122,9 @@ public extension View { config = DiffConfiguration( theme: config.theme, showLineNumbers: config.showLineNumbers, + lineNumberStyle: config.lineNumberStyle, showFileHeaders: config.showFileHeaders, + showHunkHeaders: config.showHunkHeaders, fontFamily: config.fontFamily, fontSize: config.fontSize, fontWeight: config.fontWeight, diff --git a/Sources/gitdiff/Views/DiffFileView.swift b/Sources/gitdiff/Views/DiffFileView.swift index a8ed4db..5696471 100644 --- a/Sources/gitdiff/Views/DiffFileView.swift +++ b/Sources/gitdiff/Views/DiffFileView.swift @@ -49,13 +49,15 @@ struct DiffFileView: View { } else { ForEach(file.hunks) { hunk in VStack(alignment: .leading, spacing: 0) { - Text(hunk.header) - .font(.system(.caption, design: configuration.fontFamily)) - .foregroundColor(configuration.theme.headerText) - .padding(.horizontal) - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - .background(configuration.theme.headerBackground) + if configuration.showHunkHeaders { + Text(hunk.header) + .font(.system(.caption, design: configuration.fontFamily)) + .foregroundColor(configuration.theme.headerText) + .padding(.horizontal) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .background(configuration.theme.headerBackground) + } LazyVStack(spacing: configuration.lineSpacing.value) { ForEach(hunk.lines) { line in diff --git a/Sources/gitdiff/Views/DiffLineView.swift b/Sources/gitdiff/Views/DiffLineView.swift index 2fc5b3b..b72a8cf 100644 --- a/Sources/gitdiff/Views/DiffLineView.swift +++ b/Sources/gitdiff/Views/DiffLineView.swift @@ -50,9 +50,35 @@ struct DiffLineView: View { } } + /// For ``DiffConfiguration/LineNumberStyle/single``, show the most + /// relevant number for the line type: new for added/context, old for + /// removed. This matches how terminal tools like `git diff --color` and + /// Pi's own renderer present a compact gutter, where each line has at + /// most one number and the prefix carries the kind. + private var singleColumnLineNumber: String { + switch line.type { + case .added, .context, .header: + return line.newLineNumber.map(String.init) + ?? line.oldLineNumber.map(String.init) + ?? "" + case .removed: + return line.oldLineNumber.map(String.init) ?? "" + } + } + var body: some View { HStack(spacing: 0) { - if configuration.showLineNumbers { + switch configuration.lineNumberStyle { + case .hidden: + EmptyView() + case .single: + Text(singleColumnLineNumber) + .font(.system(size: configuration.fontSize * 0.85, design: configuration.fontFamily)) + .foregroundColor(configuration.theme.lineNumberText) + .frame(width: 32, alignment: .trailing) + .padding(.horizontal, 4) + .background(configuration.theme.lineNumberBackground) + case .dual: Text(line.oldLineNumber.map(String.init) ?? "") .font(.system(size: configuration.fontSize * 0.85, design: configuration.fontFamily)) .foregroundColor(configuration.theme.lineNumberText) diff --git a/Tests/gitdiffTests/DiffConfigurationTests.swift b/Tests/gitdiffTests/DiffConfigurationTests.swift new file mode 100644 index 0000000..e54ede5 --- /dev/null +++ b/Tests/gitdiffTests/DiffConfigurationTests.swift @@ -0,0 +1,98 @@ +import Testing +import SwiftUI + +@testable import gitdiff + +/// Contract tests for the new ``DiffConfiguration/LineNumberStyle`` and +/// ``DiffConfiguration/showHunkHeaders`` knobs. +struct DiffConfigurationTests { + + // MARK: - Legacy → new field inference + + @Test + func defaultsToDualLineNumberStyle() { + let config = DiffConfiguration() + #expect(config.lineNumberStyle == .dual) + #expect(config.showLineNumbers == true) + #expect(config.showHunkHeaders == true) + } + + @Test + func legacyShowLineNumbersFalseImpliesHiddenStyle() { + let config = DiffConfiguration(showLineNumbers: false) + #expect(config.lineNumberStyle == .hidden) + #expect(config.showLineNumbers == false) + } + + @Test + func explicitLineNumberStyleOverridesLegacyFlag() { + let config = DiffConfiguration(showLineNumbers: false, lineNumberStyle: .single) + /// Explicit `.single` wins over the legacy boolean — useful for callers + /// that haven't migrated their boilerplate but want the new gutter mode. + #expect(config.lineNumberStyle == .single) + } + + @Test + func diffLineNumbersFalseSwitchesToHidden() { + let config = DiffConfiguration().withLineNumbers(false) + #expect(config.lineNumberStyle == .hidden) + #expect(config.showLineNumbers == false) + } + + @Test + func diffLineNumbersTrueRestoresDual() { + let config = DiffConfiguration().withLineNumbers(false).withLineNumbers(true) + #expect(config.lineNumberStyle == .dual) + #expect(config.showLineNumbers == true) + } + + @Test + func withLineNumberStyleSetsBothFields() { + let config = DiffConfiguration().withLineNumberStyle(.single) + #expect(config.lineNumberStyle == .single) + #expect(config.showLineNumbers == true) + + let hidden = DiffConfiguration().withLineNumberStyle(.hidden) + #expect(hidden.lineNumberStyle == .hidden) + #expect(hidden.showLineNumbers == false) + } + + // MARK: - Hunk headers + + @Test + func defaultsToHunkHeadersOn() { + #expect(DiffConfiguration().showHunkHeaders == true) + } + + @Test + func withHunkHeadersCanDisable() { + let config = DiffConfiguration().withHunkHeaders(false) + #expect(config.showHunkHeaders == false) + } + + // MARK: - Chaining preserves new fields + + @Test + func chainingOtherModifiersPreservesNewFields() { + let config = DiffConfiguration() + .withLineNumberStyle(.single) + .withHunkHeaders(false) + .withFont(size: 14) + .withLineSpacing(.comfortable) + .with(theme: .dark) + .withWordWrap(true) + + #expect(config.lineNumberStyle == .single) + #expect(config.showHunkHeaders == false) + #expect(config.fontSize == 14) + } + + // MARK: - Preset + + @Test + func mobilePresetUsesSingleColumnAndHidesHunkHeaders() { + let mobile = DiffConfiguration.mobile + #expect(mobile.lineNumberStyle == .single) + #expect(mobile.showHunkHeaders == false) + } +}