diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index fc49dac3ac30..1f71f8f9ea30 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ D0B1001CA1B2C3D4E5F60001 /* PaneDropRoutingSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1001DA1B2C3D4E5F60001 /* PaneDropRoutingSupport.swift */; }; D0B1001EA1B2C3D4E5F60001 /* BrowserPaneDropTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1001FA1B2C3D4E5F60001 /* BrowserPaneDropTargetView.swift */; }; D0B10016A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */; }; + D0B10024A1B2C3D4E5F60001 /* ViewZoomControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10025A1B2C3D4E5F60001 /* ViewZoomControls.swift */; }; D0B10018A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */; }; D7AB34300000000000000001 /* SidebarDropPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000002 /* SidebarDropPlanner.swift */; }; D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */; }; @@ -269,6 +270,7 @@ A50014F2 /* MarkdownPanelFileLinkResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50014F0 /* MarkdownPanelFileLinkResolver.swift */; }; A50014F3 /* MarkdownViewerAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50014F1 /* MarkdownViewerAssets.swift */; }; A50014F5 /* MarkdownWebRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50014F4 /* MarkdownWebRenderer.swift */; }; + A50014F7 /* MarkdownTypographySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50014F6 /* MarkdownTypographySettings.swift */; }; A5001422 /* FilePreviewPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001423 /* FilePreviewPanel.swift */; }; A5001441A5001441A5001441 /* FileOpenSocketSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001440A5001440A5001440 /* FileOpenSocketSupport.swift */; }; A5001443A5001443A5001443 /* FilePreviewModeSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001442A5001442A5001442 /* FilePreviewModeSupport.swift */; }; @@ -684,6 +686,7 @@ D0B1001DA1B2C3D4E5F60001 /* PaneDropRoutingSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneDropRoutingSupport.swift; sourceTree = ""; }; D0B1001FA1B2C3D4E5F60001 /* BrowserPaneDropTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneDropTargetView.swift; sourceTree = ""; }; D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewTextEditor.swift; sourceTree = ""; }; + D0B10025A1B2C3D4E5F60001 /* ViewZoomControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ViewZoomControls.swift; sourceTree = ""; }; A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; @@ -792,6 +795,7 @@ A50014F0 /* MarkdownPanelFileLinkResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelFileLinkResolver.swift; sourceTree = ""; }; A50014F1 /* MarkdownViewerAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownViewerAssets.swift; sourceTree = ""; }; A50014F4 /* MarkdownWebRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownWebRenderer.swift; sourceTree = ""; }; + A50014F6 /* MarkdownTypographySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownTypographySettings.swift; sourceTree = ""; }; A5001423 /* FilePreviewPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewPanel.swift; sourceTree = ""; }; A5001440A5001440A5001440 /* FileOpenSocketSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileOpenSocketSupport.swift; sourceTree = ""; }; A5001442A5001442A5001442 /* FilePreviewModeSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewModeSupport.swift; sourceTree = ""; }; @@ -1303,7 +1307,9 @@ A50014F0 /* MarkdownPanelFileLinkResolver.swift */, A5001419 /* MarkdownPanelView.swift */, A50014F4 /* MarkdownWebRenderer.swift */, + A50014F6 /* MarkdownTypographySettings.swift */, A50014F1 /* MarkdownViewerAssets.swift */, + D0B10025A1B2C3D4E5F60001 /* ViewZoomControls.swift */, A5001423 /* FilePreviewPanel.swift */, D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */, A5001442A5001442A5001442 /* FilePreviewModeSupport.swift */, @@ -1982,9 +1988,11 @@ A50014F2 /* MarkdownPanelFileLinkResolver.swift in Sources */, A5001421 /* MarkdownPanelView.swift in Sources */, A50014F5 /* MarkdownWebRenderer.swift in Sources */, + A50014F7 /* MarkdownTypographySettings.swift in Sources */, A50014F3 /* MarkdownViewerAssets.swift in Sources */, A5001422 /* FilePreviewPanel.swift in Sources */, D0B10016A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift in Sources */, + D0B10024A1B2C3D4E5F60001 /* ViewZoomControls.swift in Sources */, A5001443A5001443A5001443 /* FilePreviewModeSupport.swift in Sources */, A5001445A5001445A5001445 /* FilePreviewWorkspaceOpenSupport.swift in Sources */, A5001447A5001447A5001447 /* FilePreviewMagnifyingPDFView.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 479200dba60d..dc0c95058414 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -2,6 +2,8 @@ "sourceLanguage": "en", "version": "1.0", "strings": { + "filePreview.pdf.zoomControls": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Zoom Controls" } }, "ja": { "stringUnit": { "state": "translated", "value": "ズームコントロール" } } } }, + "view.zoom.controls": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "View Zoom" } }, "ja": { "stringUnit": { "state": "translated", "value": "表示ズーム" } } } }, "cli.error.dismissNotificationSelector": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "dismiss-notification requires exactly one of --id or --all-read" } }, "ja": { "stringUnit": { "state": "translated", "value": "dismiss-notification には --id または --all-read のどちらか一方だけを指定してください" } } } }, "cli.error.markNotificationReadSelector": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "mark-notification-read requires exactly one selector: --id, --workspace, or --all" } }, "ja": { "stringUnit": { "state": "translated", "value": "mark-notification-read には --id、--workspace、--all のいずれか一つだけを指定してください" } } } }, "cli.error.markNotificationReadSurfaceRequiresWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "--surface requires --workspace" } }, "ja": { "stringUnit": { "state": "translated", "value": "--surface には --workspace が必要です" } } } }, diff --git a/Resources/markdown-viewer/shell.html b/Resources/markdown-viewer/shell.html index 50beed48c87c..6c0fad636d8b 100644 --- a/Resources/markdown-viewer/shell.html +++ b/Resources/markdown-viewer/shell.html @@ -29,23 +29,37 @@ } .markdown-body { background: transparent; - /* GitHub uses 16px on github.com for body text; keep that exact - metric so headings, line-height, and spacing match. */ - font-size: 15px; + font-family: var(--cmux-markdown-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"); + /* cmux defaults to 15px for panel density, while cmux.json can + override the body and heading metrics below. */ + font-size: var(--cmux-markdown-font-size, 15px); /* The package sets max-width via wrapper class on github.com; we let it span the panel and just cap it to a comfortable reading width on very wide splits. */ max-width: 980px; margin: 0 auto; } +.markdown-body h1 { font-size: var(--cmux-markdown-h1-font-size, 2em); } +.markdown-body h2 { font-size: var(--cmux-markdown-h2-font-size, 1.5em); } +.markdown-body h3 { font-size: var(--cmux-markdown-h3-font-size, 1.25em); } +.markdown-body h4 { font-size: var(--cmux-markdown-h4-font-size, 1em); } +.markdown-body h5 { font-size: var(--cmux-markdown-h5-font-size, .875em); } +.markdown-body h6 { font-size: var(--cmux-markdown-h6-font-size, .85em); } /* Fenced code blocks: github-markdown-css already styles `pre`; we add highlight.js's hljs class so the syntax theme applies. */ .markdown-body pre code.hljs { padding: 16px; background: transparent; - font-size: 13.5px; + font-family: var(--cmux-markdown-code-block-font-family, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace); + font-size: var(--cmux-markdown-code-block-font-size, 13.5px); line-height: 1.5; } +.markdown-body pre, +.markdown-body pre code, +.cmux-frontmatter pre, +.cmux-frontmatter pre code.hljs { + font-family: var(--cmux-markdown-code-block-font-family, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace); +} .markdown-body pre { padding: 0; /* hljs class already pads */ overflow: auto; diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0afdc615a327..c968b210a05a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11989,16 +11989,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchConfiguredShortcut(event: event, action: .browserZoomIn) { - return shortcutEventBrowserPanel(event)?.zoomIn() ?? false - } - - if matchConfiguredShortcut(event: event, action: .browserZoomOut) { - return shortcutEventBrowserPanel(event)?.zoomOut() ?? false - } - - if matchConfiguredShortcut(event: event, action: .browserZoomReset) { - return shortcutEventBrowserPanel(event)?.resetZoom() ?? false + if let viewZoomCommand = configuredViewZoomShortcutCommand(for: event) { + if shouldLetFocusedTerminalHandleViewZoomShortcut(event) { + return false + } + let didHandle = shortcutEventTabManager(event)?.performViewZoomCommand(viewZoomCommand) ?? false + return didHandle } if matchConfiguredShortcut(event: event, action: .findInDirectory) { @@ -12094,6 +12090,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + private func configuredViewZoomShortcutCommand(for event: NSEvent) -> ViewZoomCommand? { + if matchConfiguredShortcut(event: event, action: .browserZoomIn) { + return .zoomIn + } + if matchConfiguredShortcut(event: event, action: .browserZoomOut) { + return .zoomOut + } + if matchConfiguredShortcut(event: event, action: .browserZoomReset) { + return .reset + } + return ViewZoomControl.command(for: event) + } + + private func shouldLetFocusedTerminalHandleViewZoomShortcut(_ event: NSEvent) -> Bool { + let targetWindow = resolvedShortcutEventWindow(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + guard let firstResponder = targetWindow?.firstResponder, + cmuxOwningGhosttyView(for: firstResponder) != nil else { + return false + } + return browserZoomShortcutAction( + flags: event.modifierFlags, + chars: event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode, + literalChars: event.characters + ) != nil + } + + private func shortcutEventTabManager(_ event: NSEvent) -> TabManager? { + preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager + } + #if DEBUG private func logBrowserZoomShortcutTrace( stage: String, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d8202e70898f..682ba239f282 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1462,6 +1462,7 @@ struct ContentView: View { static let panelName = "panel.name" static let panelIsBrowser = "panel.isBrowser" static let panelIsTerminal = "panel.isTerminal" + static let panelSupportsViewZoom = "panel.supportsViewZoom" static let panelHasPane = "panel.hasPane" static let panelHasCustomName = "panel.hasCustomName" static let panelShouldPin = "panel.shouldPin" @@ -6046,10 +6047,15 @@ struct ContentView: View { let workspace = panelContext.workspace let panelId = panelContext.panelId let panelIsTerminal = panelContext.panel.panelType == .terminal + let panelSupportsViewZoom = + panelContext.panel is BrowserPanel || + panelContext.panel is MarkdownPanel || + ((panelContext.panel as? FilePreviewPanel)?.previewMode == .text) snapshot.setBool(CommandPaletteContextKeys.hasFocusedPanel, true) snapshot.setString(CommandPaletteContextKeys.panelName, panelDisplayName(workspace: workspace, panelId: panelId, fallback: panelContext.panel.displayTitle)) snapshot.setBool(CommandPaletteContextKeys.panelIsBrowser, panelContext.panel.panelType == .browser) snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelIsTerminal) + snapshot.setBool(CommandPaletteContextKeys.panelSupportsViewZoom, panelSupportsViewZoom) snapshot.setBool(CommandPaletteContextKeys.panelHasPane, workspace.paneId(forPanelId: panelId) != nil) snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil) snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId)) @@ -6755,27 +6761,27 @@ struct ContentView: View { CommandPaletteCommandContribution( commandId: "palette.browserZoomIn", title: constant(String(localized: "command.browserZoomIn.title", defaultValue: "Zoom In")), - subtitle: browserPanelSubtitle, - keywords: ["browser", "zoom", "in"], - when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + subtitle: panelSubtitle, + keywords: ["view", "zoom", "in", "browser", "markdown", "text"], + when: { $0.bool(CommandPaletteContextKeys.panelSupportsViewZoom) } ) ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserZoomOut", title: constant(String(localized: "command.browserZoomOut.title", defaultValue: "Zoom Out")), - subtitle: browserPanelSubtitle, - keywords: ["browser", "zoom", "out"], - when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + subtitle: panelSubtitle, + keywords: ["view", "zoom", "out", "browser", "markdown", "text"], + when: { $0.bool(CommandPaletteContextKeys.panelSupportsViewZoom) } ) ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserZoomReset", title: constant(String(localized: "command.browserZoomReset.title", defaultValue: "Actual Size")), - subtitle: browserPanelSubtitle, - keywords: ["browser", "zoom", "reset", "actual size"], - when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + subtitle: panelSubtitle, + keywords: ["view", "zoom", "reset", "actual size", "browser", "markdown", "text"], + when: { $0.bool(CommandPaletteContextKeys.panelSupportsViewZoom) } ) ) contributions.append( @@ -7438,17 +7444,17 @@ struct ContentView: View { } } registry.register(commandId: "palette.browserZoomIn") { - if !tabManager.zoomInFocusedBrowser() { + if !tabManager.performViewZoomCommand(.zoomIn) { NSSound.beep() } } registry.register(commandId: "palette.browserZoomOut") { - if !tabManager.zoomOutFocusedBrowser() { + if !tabManager.performViewZoomCommand(.zoomOut) { NSSound.beep() } } registry.register(commandId: "palette.browserZoomReset") { - if !tabManager.resetZoomFocusedBrowser() { + if !tabManager.performViewZoomCommand(.reset) { NSSound.beep() } } diff --git a/Sources/KeyboardShortcutContext.swift b/Sources/KeyboardShortcutContext.swift index 43e9d3f48482..07323f8e2337 100644 --- a/Sources/KeyboardShortcutContext.swift +++ b/Sources/KeyboardShortcutContext.swift @@ -53,8 +53,7 @@ extension KeyboardShortcutSettings.Action { return .rightSidebarFocus case .renameTab, .renameWorkspace: return .nonBrowserPanel - case .browserBack, .browserForward, .browserReload, .toggleBrowserDeveloperTools, .showBrowserJavaScriptConsole, - .browserZoomIn, .browserZoomOut, .browserZoomReset: + case .browserBack, .browserForward, .browserReload, .toggleBrowserDeveloperTools, .showBrowserJavaScriptConsole: return .browserPanel default: return .application diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index a3ef69051b30..0069113202ea 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -159,6 +159,22 @@ extension CmuxSettingsFileStore { "reactGrabVersion": ReactGrabSettings.defaultVersion, ], ], + [ + "markdown": [ + "fontFamily": MarkdownTypographySettings.defaultFontFamily, + "fontSize": MarkdownTypographySettings.defaultFontSize, + "headingSizes": [ + "h1": MarkdownTypographySettings.defaultHeadingSizes.h1, + "h2": MarkdownTypographySettings.defaultHeadingSizes.h2, + "h3": MarkdownTypographySettings.defaultHeadingSizes.h3, + "h4": MarkdownTypographySettings.defaultHeadingSizes.h4, + "h5": MarkdownTypographySettings.defaultHeadingSizes.h5, + "h6": MarkdownTypographySettings.defaultHeadingSizes.h6, + ], + "codeBlockFontFamily": MarkdownTypographySettings.defaultCodeBlockFontFamily, + "codeBlockFontSize": MarkdownTypographySettings.defaultCodeBlockFontSize, + ], + ], [ "shortcuts": [ "bindings": shortcutsBindings, diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 9851841ae5fe..094c4e80ebb8 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -329,6 +329,9 @@ final class CmuxSettingsFileStore { if let browserSection = root["browser"] as? [String: Any] { parseBrowserSection(browserSection, sourcePath: sourcePath, snapshot: &snapshot) } + if let markdownSection = root["markdown"] as? [String: Any] { + parseMarkdownSection(markdownSection, sourcePath: sourcePath, snapshot: &snapshot) + } if let shortcutsSection = root["shortcuts"] { parseShortcutsSection(shortcutsSection, sourcePath: sourcePath, snapshot: &snapshot) } @@ -780,6 +783,120 @@ final class CmuxSettingsFileStore { } } + private func parseMarkdownSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let raw = jsonString(section["fontFamily"]) { + if let normalized = MarkdownTypographySettings.normalizedFontFamily(raw) { + snapshot.managedUserDefaults[MarkdownTypographySettings.fontFamilyKey] = .string(normalized) + } else { + logInvalid("markdown.fontFamily", sourcePath: sourcePath) + } + } else if section.keys.contains("fontFamily") { + logInvalid("markdown.fontFamily", sourcePath: sourcePath) + } + + parseMarkdownSize( + section["fontSize"], + path: "markdown.fontSize", + defaultsKey: MarkdownTypographySettings.fontSizeKey, + range: MarkdownTypographySettings.fontSizeRange, + sourcePath: sourcePath, + snapshot: &snapshot + ) + + if let headingSizes = section["headingSizes"] as? [String: Any] { + parseMarkdownSize( + headingSizes["h1"], + path: "markdown.headingSizes.h1", + defaultsKey: MarkdownTypographySettings.headingH1SizeKey, + range: MarkdownTypographySettings.headingSizeRange, + sourcePath: sourcePath, + snapshot: &snapshot + ) + parseMarkdownSize( + headingSizes["h2"], + path: "markdown.headingSizes.h2", + defaultsKey: MarkdownTypographySettings.headingH2SizeKey, + range: MarkdownTypographySettings.headingSizeRange, + sourcePath: sourcePath, + snapshot: &snapshot + ) + parseMarkdownSize( + headingSizes["h3"], + path: "markdown.headingSizes.h3", + defaultsKey: MarkdownTypographySettings.headingH3SizeKey, + range: MarkdownTypographySettings.headingSizeRange, + sourcePath: sourcePath, + snapshot: &snapshot + ) + parseMarkdownSize( + headingSizes["h4"], + path: "markdown.headingSizes.h4", + defaultsKey: MarkdownTypographySettings.headingH4SizeKey, + range: MarkdownTypographySettings.headingSizeRange, + sourcePath: sourcePath, + snapshot: &snapshot + ) + parseMarkdownSize( + headingSizes["h5"], + path: "markdown.headingSizes.h5", + defaultsKey: MarkdownTypographySettings.headingH5SizeKey, + range: MarkdownTypographySettings.headingSizeRange, + sourcePath: sourcePath, + snapshot: &snapshot + ) + parseMarkdownSize( + headingSizes["h6"], + path: "markdown.headingSizes.h6", + defaultsKey: MarkdownTypographySettings.headingH6SizeKey, + range: MarkdownTypographySettings.headingSizeRange, + sourcePath: sourcePath, + snapshot: &snapshot + ) + } else if section.keys.contains("headingSizes") { + logInvalid("markdown.headingSizes", sourcePath: sourcePath) + } + + if let raw = jsonString(section["codeBlockFontFamily"]) { + if let normalized = MarkdownTypographySettings.normalizedFontFamily(raw) { + snapshot.managedUserDefaults[MarkdownTypographySettings.codeBlockFontFamilyKey] = .string(normalized) + } else { + logInvalid("markdown.codeBlockFontFamily", sourcePath: sourcePath) + } + } else if section.keys.contains("codeBlockFontFamily") { + logInvalid("markdown.codeBlockFontFamily", sourcePath: sourcePath) + } + + parseMarkdownSize( + section["codeBlockFontSize"], + path: "markdown.codeBlockFontSize", + defaultsKey: MarkdownTypographySettings.codeBlockFontSizeKey, + range: MarkdownTypographySettings.codeBlockFontSizeRange, + sourcePath: sourcePath, + snapshot: &snapshot + ) + } + + private func parseMarkdownSize( + _ rawValue: Any?, + path: String, + defaultsKey: String, + range: ClosedRange, + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + guard rawValue != nil else { return } + guard let raw = jsonDouble(rawValue), + let normalized = MarkdownTypographySettings.normalizedSize(raw, range: range) else { + logInvalid(path, sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[defaultsKey] = .double(normalized) + } + private func parseShortcutsSection( _ value: Any, sourcePath: String, diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index d0887364f972..a5aea3cd96aa 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1867,7 +1867,7 @@ final class BrowserPortalAnchorView: NSView { } @MainActor -final class BrowserPanel: Panel, ObservableObject { +final class BrowserPanel: Panel, ObservableObject, ViewZoomControlling { /// Shared process pool for cookie sharing across all browser panels private static let sharedProcessPool = WKProcessPool() @@ -5125,6 +5125,27 @@ extension BrowserPanel { return applyPageZoom(clamped) } + var viewZoomFactor: CGFloat { + currentPageZoomFactor() + } + + @discardableResult + func setViewZoomFactor(_ factor: CGFloat) -> Bool { + setPageZoomFactor(ViewZoomControl.normalized(factor)) + } + + @discardableResult + func performViewZoomCommand(_ command: ViewZoomCommand) -> Bool { + switch command { + case .zoomIn: + return zoomIn() + case .zoomOut: + return zoomOut() + case .reset: + return resetZoom() + } + } + /// Take a snapshot of the web view func takeSnapshot(completion: @escaping (NSImage?) -> Void) { let config = WKSnapshotConfiguration() diff --git a/Sources/Panels/FilePreviewMagnifyingPDFView.swift b/Sources/Panels/FilePreviewMagnifyingPDFView.swift index 0c891de23160..0c83574f524f 100644 --- a/Sources/Panels/FilePreviewMagnifyingPDFView.swift +++ b/Sources/Panels/FilePreviewMagnifyingPDFView.swift @@ -9,6 +9,7 @@ final class FilePreviewMagnifyingPDFView: PDFView { var onRotate: ((NSEvent) -> Void)? var onSwipe: ((NSEvent) -> Void)? var onFocusChanged: ((Bool) -> Void)? + var onViewZoomCommand: ((ViewZoomCommand) -> Void)? override var acceptsFirstResponder: Bool { true } @@ -33,6 +34,14 @@ final class FilePreviewMagnifyingPDFView: PDFView { super.mouseDown(with: event) } + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if let command = ViewZoomControl.command(for: event), let onViewZoomCommand { + onViewZoomCommand(command) + return true + } + return super.performKeyEquivalent(with: event) + } + override func magnify(with event: NSEvent) { if let onMagnify { onMagnify(event) @@ -73,4 +82,5 @@ final class FilePreviewMagnifyingPDFView: PDFView { super.swipe(with: event) } } + } diff --git a/Sources/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index 1c61f5af4cb7..5a078cc84029 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -830,7 +830,7 @@ enum FilePreviewTextSaver { } @MainActor -final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPanel { +final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPanel, ViewZoomControlling { let id: UUID let panelType: PanelType = .filePreview let filePath: String @@ -843,6 +843,7 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan @Published private(set) var isSaving = false @Published private(set) var focusFlashToken = 0 @Published private(set) var previewMode: FilePreviewMode + @Published private(set) var viewZoomFactor: CGFloat = ViewZoomControl.defaultFactor private var originalTextContent = "" private var textEncoding: String.Encoding = .utf8 @@ -928,6 +929,21 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan focusCoordinator.notePreferredIntent(intent) } + @discardableResult + func setViewZoomFactor(_ factor: CGFloat) -> Bool { + guard previewMode == .text else { return false } + let normalized = ViewZoomControl.normalized(factor) + guard abs(viewZoomFactor - normalized) > 0.0001 else { return false } + viewZoomFactor = normalized + return true + } + + @discardableResult + func performViewZoomCommand(_ command: ViewZoomCommand) -> Bool { + guard previewMode == .text else { return false } + return setViewZoomFactor(ViewZoomControl.applying(command, to: viewZoomFactor)) + } + func currentFilePreviewFocusIntent(in window: NSWindow?) -> FilePreviewPanelFocusIntent? { guard let window, let responder = window.firstResponder else { return nil } @@ -1180,6 +1196,14 @@ struct FilePreviewPanelView: View { isDisabled: !panel.isDirty || panel.isSaving, action: { panel.saveTextContent() } ) + + PanelHeaderViewZoomButton( + zoomFactor: Binding( + get: { panel.viewZoomFactor }, + set: { panel.setViewZoomFactor($0) } + ), + isDisabled: panel.isFileUnavailable || panel.previewMode != .text + ) } FileExternalOpenMenu(fileURL: panel.fileURL, isDisabled: panel.isFileUnavailable) @@ -1981,6 +2005,7 @@ final class FilePreviewPDFThumbnailSidebarView: NSView, NSCollectionViewDataSour private final class FilePreviewPDFOutlineView: NSOutlineView { var onFocusChanged: ((Bool) -> Void)? + var onViewZoomCommand: ((ViewZoomCommand) -> Void)? override var acceptsFirstResponder: Bool { true } override var canBecomeKeyView: Bool { true } @@ -2005,6 +2030,14 @@ private final class FilePreviewPDFOutlineView: NSOutlineView { window?.makeFirstResponder(self) super.mouseDown(with: event) } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if let command = ViewZoomControl.command(for: event), let onViewZoomCommand { + onViewZoomCommand(command) + return true + } + return super.performKeyEquivalent(with: event) + } } private final class FilePreviewPDFThumbnailItem: NSCollectionViewItem { @@ -2311,6 +2344,9 @@ final class FilePreviewPDFContainerView: NSView, NSSplitViewDelegate, NSOutlineV pdfView.onSmartMagnify = { [weak self] in self?.togglePDFSmartZoom() } + pdfView.onViewZoomCommand = { [weak self] command in + self?.performPDFViewZoomCommand(command) + } pdfView.onRotate = { [weak self] event in self?.rotatePDF(with: event) } @@ -2380,6 +2416,9 @@ final class FilePreviewPDFContainerView: NSView, NSSplitViewDelegate, NSOutlineV outlineView.onFocusChanged = { [weak self] isActive in self?.setActivePDFRegion(isActive ? .pdfOutline : nil) } + outlineView.onViewZoomCommand = { [weak self] command in + self?.performPDFViewZoomCommand(command) + } outlineView.translatesAutoresizingMaskIntoConstraints = false outlineScrollView.hasVerticalScroller = true @@ -2535,6 +2574,17 @@ final class FilePreviewPDFContainerView: NSView, NSSplitViewDelegate, NSOutlineV setPDFScaleFactor(1.0, preservingVisibleCenter: true) } + private func performPDFViewZoomCommand(_ command: ViewZoomCommand) { + switch command { + case .zoomIn: + zoomIn() + case .zoomOut: + zoomOut() + case .reset: + actualSize() + } + } + @objc private func rotateLeft() { rotateCurrentPDFPage(by: -90) } @@ -3448,6 +3498,9 @@ private final class FilePreviewImageContainerView: NSView { scrollView.onSmartMagnify = { [weak self] event in self?.toggleImageSmartZoom(with: event) } + scrollView.onViewZoomCommand = { [weak self] command in + self?.performImageViewZoomCommand(command) + } scrollView.onRotate = { [weak self] event in self?.rotateImage(with: event) } @@ -3499,6 +3552,17 @@ private final class FilePreviewImageContainerView: NSView { setImageScale(1.0, preservingVisibleCenter: true) } + private func performImageViewZoomCommand(_ command: ViewZoomCommand) { + switch command { + case .zoomIn: + zoomIn() + case .zoomOut: + zoomOut() + case .reset: + actualSize() + } + } + @objc private func rotateLeft() { rotateImage(by: -90) } @@ -3696,6 +3760,7 @@ private final class FilePreviewImageScrollView: NSScrollView { var onMagnify: ((NSEvent) -> Void)? var onScrollZoom: ((NSEvent) -> Void)? var onSmartMagnify: ((NSEvent) -> Void)? + var onViewZoomCommand: ((ViewZoomCommand) -> Void)? var onRotate: ((NSEvent) -> Void)? private var panStartClipPoint: CGPoint? private var panStartDocumentOrigin: CGPoint? @@ -3711,6 +3776,14 @@ private final class FilePreviewImageScrollView: NSScrollView { } } + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if let command = ViewZoomControl.command(for: event), let onViewZoomCommand { + onViewZoomCommand(command) + return true + } + return super.performKeyEquivalent(with: event) + } + override func scrollWheel(with event: NSEvent) { if FilePreviewInteraction.hasZoomModifier(event), let onScrollZoom { onScrollZoom(event) @@ -3801,6 +3874,7 @@ private final class FilePreviewImageScrollView: NSScrollView { hasPushedPanCursor = false } } + } private final class FilePreviewImageDocumentView: NSView { diff --git a/Sources/Panels/FilePreviewTextEditor.swift b/Sources/Panels/FilePreviewTextEditor.swift index 36e66b0138dc..4bf6c79d48df 100644 --- a/Sources/Panels/FilePreviewTextEditor.swift +++ b/Sources/Panels/FilePreviewTextEditor.swift @@ -4,11 +4,16 @@ import SwiftUI @MainActor protocol FilePreviewTextEditingPanel: AnyObject { var textContent: String { get } + var viewZoomFactor: CGFloat { get } func attachTextView(_ textView: NSTextView) func retryPendingFocus() func updateTextContent(_ nextContent: String) @discardableResult + func setViewZoomFactor(_ factor: CGFloat) -> Bool + @discardableResult + func performViewZoomCommand(_ command: ViewZoomCommand) -> Bool + @discardableResult func saveTextContent() -> Task? } @@ -41,7 +46,7 @@ struct FilePreviewTextEditor: NSViewRepresentable where PanelModel: textView.importsGraphics = false textView.usesFindPanel = true textView.usesFontPanel = false - textView.font = .monospacedSystemFont(ofSize: 13, weight: .regular) + textView.applyPreviewFontSize(ViewZoomControl.textEditorFontSize(for: panel.viewZoomFactor)) textView.drawsBackground = false textView.minSize = NSSize(width: 0, height: 0) textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) @@ -69,6 +74,7 @@ struct FilePreviewTextEditor: NSViewRepresentable where PanelModel: guard let textView = scrollView.documentView as? SavingTextView else { return } textView.panel = panel textView.applyFilePreviewTextEditorInsets() + textView.applyPreviewFontSize(ViewZoomControl.textEditorFontSize(for: panel.viewZoomFactor)) panel.attachTextView(textView) guard textView.string != panel.textContent else { return } context.coordinator.isApplyingPanelUpdate = true @@ -135,12 +141,8 @@ extension NSTextView { } final class SavingTextView: NSTextView { - private static let defaultPreviewFontSize: CGFloat = 13 - private static let minimumPreviewFontSize: CGFloat = 8 - private static let maximumPreviewFontSize: CGFloat = 36 - weak var panel: (any FilePreviewTextEditingPanel)? - private var previewFontSize: CGFloat = 13 + private var previewFontSize: CGFloat = ViewZoomControl.textEditorDefaultFontSize private var pendingSaveShortcutChordPrefix: ShortcutStroke? deinit {} @@ -155,6 +157,13 @@ final class SavingTextView: NSTextView { guard event.type == .keyDown else { return super.performKeyEquivalent(with: event) } + if let zoomCommand = ViewZoomControl.command(for: event) { + guard let panel else { return false } + panel.performViewZoomCommand(zoomCommand) + applyPreviewFontSize(ViewZoomControl.textEditorFontSize(for: panel.viewZoomFactor)) + return true + } + guard let shouldSave = saveShortcutMatch(for: event) else { return super.performKeyEquivalent(with: event) } @@ -179,24 +188,35 @@ final class SavingTextView: NSTextView { } override func smartMagnify(with event: NSEvent) { - if previewFontSize == Self.defaultPreviewFontSize { + if previewFontSize == ViewZoomControl.textEditorDefaultFontSize { setPreviewFontSize(18) } else { - setPreviewFontSize(Self.defaultPreviewFontSize) + setPreviewFontSize(ViewZoomControl.textEditorDefaultFontSize) } } + func applyPreviewFontSize(_ nextFontSize: CGFloat) { + let clamped = Self.clampedFontSize(nextFontSize) + guard abs(previewFontSize - clamped) > 0.001 || font?.pointSize != clamped else { return } + previewFontSize = clamped + let nextFont = NSFont.monospacedSystemFont(ofSize: clamped, weight: .regular) + font = nextFont + typingAttributes[.font] = nextFont + } + private func adjustPreviewFontSize(by factor: CGFloat) { setPreviewFontSize(previewFontSize * factor) } private func setPreviewFontSize(_ nextFontSize: CGFloat) { - let clamped = min(max(nextFontSize, Self.minimumPreviewFontSize), Self.maximumPreviewFontSize) - guard clamped.isFinite else { return } - previewFontSize = clamped - let nextFont = NSFont.monospacedSystemFont(ofSize: clamped, weight: .regular) - font = nextFont - typingAttributes[.font] = nextFont + let clamped = Self.clampedFontSize(nextFontSize) + panel?.setViewZoomFactor(ViewZoomControl.textEditorZoomFactor(forFontSize: clamped)) + applyPreviewFontSize(clamped) + } + + private static func clampedFontSize(_ fontSize: CGFloat) -> CGFloat { + guard fontSize.isFinite else { return ViewZoomControl.textEditorDefaultFontSize } + return ViewZoomControl.textEditorFontSize(for: ViewZoomControl.textEditorZoomFactor(forFontSize: fontSize)) } private func saveShortcutMatch(for event: NSEvent) -> Bool? { diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift index d36dcabffb46..aa6f2b295420 100644 --- a/Sources/Panels/MarkdownPanel.swift +++ b/Sources/Panels/MarkdownPanel.swift @@ -12,7 +12,7 @@ enum MarkdownPanelDisplayMode: String, CaseIterable, Identifiable { /// A panel that renders a markdown file with live file-watching. /// When the file changes on disk, the content is automatically reloaded. @MainActor -final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel { +final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel, ViewZoomControlling { let id: UUID let panelType: PanelType = .markdown @@ -49,6 +49,9 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel /// Token incremented to trigger focus flash animation. @Published private(set) var focusFlashToken: Int = 0 + /// Per-panel view zoom used by both rendered preview and TextEdit mode. + @Published private(set) var viewZoomFactor: CGFloat = ViewZoomControl.defaultFactor + // MARK: - File watching // nonisolated(unsafe) because deinit is not guaranteed to run on the @@ -110,6 +113,14 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel } } + @discardableResult + func setViewZoomFactor(_ factor: CGFloat) -> Bool { + let normalized = ViewZoomControl.normalized(factor) + guard abs(viewZoomFactor - normalized) > 0.0001 else { return false } + viewZoomFactor = normalized + return true + } + func attachTextView(_ textView: NSTextView) { self.textView = textView } diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index c66d974102f1..3f833ca5dd4f 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -29,6 +29,16 @@ struct MarkdownPanelView: View { @State private var copyConfirmation: CopyConfirmation? = nil @State private var copyConfirmationGeneration: Int = 0 @State private var renderer = MarkdownWebRendererHandle() + @AppStorage(MarkdownTypographySettings.fontFamilyKey) private var markdownFontFamily = MarkdownTypographySettings.defaultFontFamily + @AppStorage(MarkdownTypographySettings.fontSizeKey) private var markdownFontSize = MarkdownTypographySettings.defaultFontSize + @AppStorage(MarkdownTypographySettings.headingH1SizeKey) private var markdownHeadingH1Size = MarkdownTypographySettings.unsetHeadingSize + @AppStorage(MarkdownTypographySettings.headingH2SizeKey) private var markdownHeadingH2Size = MarkdownTypographySettings.unsetHeadingSize + @AppStorage(MarkdownTypographySettings.headingH3SizeKey) private var markdownHeadingH3Size = MarkdownTypographySettings.unsetHeadingSize + @AppStorage(MarkdownTypographySettings.headingH4SizeKey) private var markdownHeadingH4Size = MarkdownTypographySettings.unsetHeadingSize + @AppStorage(MarkdownTypographySettings.headingH5SizeKey) private var markdownHeadingH5Size = MarkdownTypographySettings.unsetHeadingSize + @AppStorage(MarkdownTypographySettings.headingH6SizeKey) private var markdownHeadingH6Size = MarkdownTypographySettings.unsetHeadingSize + @AppStorage(MarkdownTypographySettings.codeBlockFontFamilyKey) private var markdownCodeBlockFontFamily = MarkdownTypographySettings.defaultCodeBlockFontFamily + @AppStorage(MarkdownTypographySettings.codeBlockFontSizeKey) private var markdownCodeBlockFontSize = MarkdownTypographySettings.defaultCodeBlockFontSize private enum CopyConfirmation: Equatable { case markdown @@ -84,7 +94,11 @@ struct MarkdownPanelView: View { ZStack { MarkdownWebRenderer( markdown: panel.content, - theme: MarkdownWebTheme.resolve(backgroundColor: themeBackgroundColor), + theme: MarkdownWebTheme.resolve( + backgroundColor: themeBackgroundColor, + typography: markdownTypography + ), + zoomFactor: panel.viewZoomFactor, panelId: panel.id, workspaceId: panel.workspaceId, filePath: panel.filePath, @@ -130,6 +144,13 @@ struct MarkdownPanelView: View { ) } markdownModeButton + PanelHeaderViewZoomButton( + zoomFactor: Binding( + get: { panel.viewZoomFactor }, + set: { panel.setViewZoomFactor($0) } + ), + isDisabled: panel.isFileUnavailable + ) MarkdownPanelToolbar( confirmation: copyConfirmation?.label, onCopyMarkdown: { copyAsMarkdown() }, @@ -195,6 +216,58 @@ struct MarkdownPanelView: View { themeBackgroundColor.isLightColor ? .light : .dark } + private var markdownTypography: MarkdownWebTypography { + let bodyFontSize = MarkdownTypographySettings.normalizedSize( + markdownFontSize, + range: MarkdownTypographySettings.fontSizeRange + ) ?? MarkdownTypographySettings.defaultFontSize + let derivedHeadingSizes = MarkdownTypographySettings.defaultHeadingSizes(forFontSize: bodyFontSize) + return MarkdownWebTypography( + fontFamily: MarkdownTypographySettings.normalizedFontFamily(markdownFontFamily) + ?? MarkdownTypographySettings.defaultFontFamily, + fontSize: bodyFontSize, + headingSizes: MarkdownWebTypography.HeadingSizes( + h1: markdownHeadingSize( + markdownHeadingH1Size, + fallback: derivedHeadingSizes.h1 + ), + h2: markdownHeadingSize( + markdownHeadingH2Size, + fallback: derivedHeadingSizes.h2 + ), + h3: markdownHeadingSize( + markdownHeadingH3Size, + fallback: derivedHeadingSizes.h3 + ), + h4: markdownHeadingSize( + markdownHeadingH4Size, + fallback: derivedHeadingSizes.h4 + ), + h5: markdownHeadingSize( + markdownHeadingH5Size, + fallback: derivedHeadingSizes.h5 + ), + h6: markdownHeadingSize( + markdownHeadingH6Size, + fallback: derivedHeadingSizes.h6 + ) + ), + codeBlockFontFamily: MarkdownTypographySettings.normalizedFontFamily(markdownCodeBlockFontFamily) + ?? MarkdownTypographySettings.defaultCodeBlockFontFamily, + codeBlockFontSize: MarkdownTypographySettings.normalizedSize( + markdownCodeBlockFontSize, + range: MarkdownTypographySettings.codeBlockFontSizeRange + ) ?? MarkdownTypographySettings.defaultCodeBlockFontSize + ) + } + + private func markdownHeadingSize(_ raw: Double, fallback: Double) -> Double { + return MarkdownTypographySettings.normalizedSize( + raw, + range: MarkdownTypographySettings.headingSizeRange + ) ?? fallback + } + // MARK: - Copy actions private func copyAsMarkdown() { diff --git a/Sources/Panels/MarkdownTypographySettings.swift b/Sources/Panels/MarkdownTypographySettings.swift new file mode 100644 index 000000000000..c29f158dadc7 --- /dev/null +++ b/Sources/Panels/MarkdownTypographySettings.swift @@ -0,0 +1,161 @@ +import Foundation + +nonisolated struct MarkdownWebTypography: Equatable, Sendable { + struct HeadingSizes: Equatable, Sendable { + let h1: Double + let h2: Double + let h3: Double + let h4: Double + let h5: Double + let h6: Double + } + + let fontFamily: String + let fontSize: Double + let headingSizes: HeadingSizes + let codeBlockFontFamily: String + let codeBlockFontSize: Double + + static let `default` = MarkdownWebTypography( + fontFamily: MarkdownTypographySettings.defaultFontFamily, + fontSize: MarkdownTypographySettings.defaultFontSize, + headingSizes: HeadingSizes( + h1: MarkdownTypographySettings.defaultHeadingSizes.h1, + h2: MarkdownTypographySettings.defaultHeadingSizes.h2, + h3: MarkdownTypographySettings.defaultHeadingSizes.h3, + h4: MarkdownTypographySettings.defaultHeadingSizes.h4, + h5: MarkdownTypographySettings.defaultHeadingSizes.h5, + h6: MarkdownTypographySettings.defaultHeadingSizes.h6 + ), + codeBlockFontFamily: MarkdownTypographySettings.defaultCodeBlockFontFamily, + codeBlockFontSize: MarkdownTypographySettings.defaultCodeBlockFontSize + ) + + var cssVariables: [String: String] { + [ + "--cmux-markdown-font-family": fontFamily, + "--cmux-markdown-font-size": Self.cssPixels(fontSize), + "--cmux-markdown-h1-font-size": Self.cssPixels(headingSizes.h1), + "--cmux-markdown-h2-font-size": Self.cssPixels(headingSizes.h2), + "--cmux-markdown-h3-font-size": Self.cssPixels(headingSizes.h3), + "--cmux-markdown-h4-font-size": Self.cssPixels(headingSizes.h4), + "--cmux-markdown-h5-font-size": Self.cssPixels(headingSizes.h5), + "--cmux-markdown-h6-font-size": Self.cssPixels(headingSizes.h6), + "--cmux-markdown-code-block-font-family": codeBlockFontFamily, + "--cmux-markdown-code-block-font-size": Self.cssPixels(codeBlockFontSize), + ] + } + + private static func cssPixels(_ value: Double) -> String { + let rounded = (value * 100).rounded() / 100 + if rounded.rounded() == rounded { + return "\(Int(rounded))px" + } + return "\(rounded)px" + } +} + +nonisolated enum MarkdownTypographySettings { + static let fontFamilyKey = "markdown.typography.fontFamily" + static let fontSizeKey = "markdown.typography.fontSize" + static let headingH1SizeKey = "markdown.typography.headingSizes.h1" + static let headingH2SizeKey = "markdown.typography.headingSizes.h2" + static let headingH3SizeKey = "markdown.typography.headingSizes.h3" + static let headingH4SizeKey = "markdown.typography.headingSizes.h4" + static let headingH5SizeKey = "markdown.typography.headingSizes.h5" + static let headingH6SizeKey = "markdown.typography.headingSizes.h6" + static let codeBlockFontFamilyKey = "markdown.typography.codeBlockFontFamily" + static let codeBlockFontSizeKey = "markdown.typography.codeBlockFontSize" + + static let defaultFontFamily = "-apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\"" + static let defaultFontSize: Double = 15 + static let defaultCodeBlockFontFamily = "ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace" + static let defaultCodeBlockFontSize: Double = 13.5 + + static let fontSizeRange: ClosedRange = 8...32 + static let headingSizeRange: ClosedRange = 8...72 + static let codeBlockFontSizeRange: ClosedRange = 8...32 + static let unsetHeadingSize = Double.nan + static let defaultHeadingSizes = defaultHeadingSizes(forFontSize: defaultFontSize) + + static func resolved(defaults: UserDefaults = .standard) -> MarkdownWebTypography { + let bodyFontSize = resolvedSize( + defaults.object(forKey: fontSizeKey), + fallback: defaultFontSize, + range: fontSizeRange + ) + let derivedHeadingSizes = defaultHeadingSizes(forFontSize: bodyFontSize) + return MarkdownWebTypography( + fontFamily: resolvedFontFamily( + defaults.string(forKey: fontFamilyKey), + fallback: defaultFontFamily + ), + fontSize: bodyFontSize, + headingSizes: MarkdownWebTypography.HeadingSizes( + h1: resolvedSize(defaults.object(forKey: headingH1SizeKey), fallback: derivedHeadingSizes.h1, range: headingSizeRange), + h2: resolvedSize(defaults.object(forKey: headingH2SizeKey), fallback: derivedHeadingSizes.h2, range: headingSizeRange), + h3: resolvedSize(defaults.object(forKey: headingH3SizeKey), fallback: derivedHeadingSizes.h3, range: headingSizeRange), + h4: resolvedSize(defaults.object(forKey: headingH4SizeKey), fallback: derivedHeadingSizes.h4, range: headingSizeRange), + h5: resolvedSize(defaults.object(forKey: headingH5SizeKey), fallback: derivedHeadingSizes.h5, range: headingSizeRange), + h6: resolvedSize(defaults.object(forKey: headingH6SizeKey), fallback: derivedHeadingSizes.h6, range: headingSizeRange) + ), + codeBlockFontFamily: resolvedFontFamily( + defaults.string(forKey: codeBlockFontFamilyKey), + fallback: defaultCodeBlockFontFamily + ), + codeBlockFontSize: resolvedSize( + defaults.object(forKey: codeBlockFontSizeKey), + fallback: defaultCodeBlockFontSize, + range: codeBlockFontSizeRange + ) + ) + } + + static func defaultHeadingSizes(forFontSize fontSize: Double) -> MarkdownWebTypography.HeadingSizes { + MarkdownWebTypography.HeadingSizes( + h1: scaledHeadingSize(fontSize, scale: 2), + h2: scaledHeadingSize(fontSize, scale: 1.5), + h3: scaledHeadingSize(fontSize, scale: 1.25), + h4: scaledHeadingSize(fontSize, scale: 1), + h5: scaledHeadingSize(fontSize, scale: 0.875), + h6: scaledHeadingSize(fontSize, scale: 0.85) + ) + } + + static func normalizedFontFamily(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + trimmed.rangeOfCharacter(from: .newlines) == nil, + trimmed.rangeOfCharacter(from: CharacterSet(charactersIn: "{};")) == nil else { + return nil + } + return trimmed + } + + static func normalizedSize(_ raw: Double, range: ClosedRange) -> Double? { + guard raw.isFinite, range.contains(raw) else { return nil } + return (raw * 100).rounded() / 100 + } + + private static func scaledHeadingSize(_ fontSize: Double, scale: Double) -> Double { + let rounded = (fontSize * scale * 100).rounded() / 100 + return min(max(rounded, headingSizeRange.lowerBound), headingSizeRange.upperBound) + } + + private static func resolvedFontFamily(_ raw: String?, fallback: String) -> String { + guard let raw, + let normalized = normalizedFontFamily(raw) else { + return fallback + } + return normalized + } + + private static func resolvedSize(_ raw: Any?, fallback: Double, range: ClosedRange) -> Double { + guard let number = raw as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + let normalized = normalizedSize(number.doubleValue, range: range) else { + return fallback + } + return normalized + } +} diff --git a/Sources/Panels/MarkdownWebRenderer.swift b/Sources/Panels/MarkdownWebRenderer.swift index 00bf23b32974..950193d758fb 100644 --- a/Sources/Panels/MarkdownWebRenderer.swift +++ b/Sources/Panels/MarkdownWebRenderer.swift @@ -9,8 +9,12 @@ struct MarkdownWebTheme: Equatable { let neutralMutedBackground: String let border: String let mutedBorder: String + let typography: MarkdownWebTypography - static func resolve(backgroundColor: NSColor) -> MarkdownWebTheme { + static func resolve( + backgroundColor: NSColor, + typography: MarkdownWebTypography = .default + ) -> MarkdownWebTheme { let base = backgroundColor.markdownOpaqueSRGB let isDark = !base.isLightColor let overlayColor: NSColor = isDark ? .white : .black @@ -32,14 +36,27 @@ struct MarkdownWebTheme: Equatable { mutedBackground: muted.markdownCSSColor, neutralMutedBackground: neutralMuted.markdownCSSColor, border: border.markdownCSSColor, - mutedBorder: border.withAlphaComponent(border.alphaComponent * 0.70).markdownCSSColor + mutedBorder: border.withAlphaComponent(border.alphaComponent * 0.70).markdownCSSColor, + typography: typography ) } + + var cssVariables: [String: String] { + var payload = typography.cssVariables + payload["--bgColor-default"] = background + payload["--bgColor-muted"] = mutedBackground + payload["--bgColor-neutral-muted"] = neutralMutedBackground + payload["--borderColor-default"] = border + payload["--borderColor-muted"] = mutedBorder + payload["--borderColor-neutral-muted"] = mutedBorder + return payload + } } struct MarkdownWebRenderer: NSViewRepresentable { let markdown: String let theme: MarkdownWebTheme + let zoomFactor: CGFloat let panelId: UUID let workspaceId: UUID let filePath: String @@ -77,6 +94,7 @@ struct MarkdownWebRenderer: NSViewRepresentable { #endif } applyAppearance(to: webView, isDark: theme.isDark) + applyZoom(to: webView) context.coordinator.webView = webView context.coordinator.loadShell(theme: theme, initialMarkdown: markdown) @@ -92,6 +110,7 @@ struct MarkdownWebRenderer: NSViewRepresentable { context.coordinator.filePath = filePath (nsView as? MarkdownWebView)?.onPointerDown = onRequestPanelFocus applyAppearance(to: nsView, isDark: theme.isDark) + applyZoom(to: nsView) context.coordinator.update(markdown: markdown, theme: theme) } @@ -115,6 +134,13 @@ struct MarkdownWebRenderer: NSViewRepresentable { } } + private func applyZoom(to webView: WKWebView) { + let normalized = ViewZoomControl.normalized(zoomFactor) + if abs(webView.pageZoom - normalized) > 0.0001 { + webView.pageZoom = normalized + } + } + @MainActor final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler { weak var webView: WKWebView? @@ -198,14 +224,7 @@ struct MarkdownWebRenderer: NSViewRepresentable { private func applyTheme(_ theme: MarkdownWebTheme) { guard let webView else { return } - let payload = [ - "--bgColor-default": theme.background, - "--bgColor-muted": theme.mutedBackground, - "--bgColor-neutral-muted": theme.neutralMutedBackground, - "--borderColor-default": theme.border, - "--borderColor-muted": theme.mutedBorder, - "--borderColor-neutral-muted": theme.mutedBorder - ] + let payload = theme.cssVariables guard let data = try? JSONSerialization.data(withJSONObject: payload), let json = String(data: data, encoding: .utf8) else { return } let js = """ diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index c25aed1456f8..2c675e9b2fbc 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -168,3 +168,92 @@ struct PanelHeaderIconGlyph: View { .contentShape(Rectangle()) } } + +struct PanelHeaderViewZoomButton: View { + @Binding var zoomFactor: CGFloat + var isDisabled = false + + @State private var isPresented = false + + private var title: String { + String(localized: "view.zoom.controls", defaultValue: "View Zoom") + } + + var body: some View { + Button { + isPresented.toggle() + } label: { + PanelHeaderIconGlyph(systemName: "textformat.size") + } + .buttonStyle(.plain) + .foregroundColor(.secondary) + .disabled(isDisabled) + .help(title) + .accessibilityLabel(title) + .popover(isPresented: $isPresented, arrowEdge: .bottom) { + popoverContent + } + } + + private var popoverContent: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(title) + .font(.system(size: 13, weight: .semibold)) + Spacer() + Text(ViewZoomControl.percentText(for: zoomFactor)) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + } + + HStack(spacing: 8) { + zoomButton( + systemName: "minus", + label: String(localized: "menu.view.zoomOut", defaultValue: "Zoom Out"), + action: { apply(.zoomOut) } + ) + Button { + apply(.reset) + } label: { + Text(ViewZoomControl.percentText(for: ViewZoomControl.defaultFactor)) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .frame(width: 58, height: 26) + } + .buttonStyle(.bordered) + .help(String(localized: "menu.view.actualSize", defaultValue: "Actual Size")) + .accessibilityLabel(String(localized: "menu.view.actualSize", defaultValue: "Actual Size")) + + zoomButton( + systemName: "plus", + label: String(localized: "menu.view.zoomIn", defaultValue: "Zoom In"), + action: { apply(.zoomIn) } + ) + } + + Slider( + value: Binding( + get: { Double(ViewZoomControl.normalized(zoomFactor)) }, + set: { zoomFactor = ViewZoomControl.normalized(CGFloat($0)) } + ), + in: Double(ViewZoomControl.minimumFactor)...Double(ViewZoomControl.maximumFactor) + ) + } + .padding(14) + .frame(width: 220) + } + + private func zoomButton(systemName: String, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 12, weight: .semibold)) + .frame(width: 26, height: 26) + } + .buttonStyle(.bordered) + .help(label) + .accessibilityLabel(label) + } + + private func apply(_ command: ViewZoomCommand) { + zoomFactor = ViewZoomControl.applying(command, to: zoomFactor) + } +} diff --git a/Sources/Panels/ViewZoomControls.swift b/Sources/Panels/ViewZoomControls.swift new file mode 100644 index 000000000000..8e9153688ac2 --- /dev/null +++ b/Sources/Panels/ViewZoomControls.swift @@ -0,0 +1,93 @@ +import AppKit +import Foundation + +enum ViewZoomCommand: Equatable { + case zoomIn + case zoomOut + case reset +} + +enum ViewZoomControl { + static let defaultFactor: CGFloat = 1.0 + static let step: CGFloat = 0.10 + static let minimumFactor: CGFloat = 0.50 + static let maximumFactor: CGFloat = 3.00 + + static func normalized(_ factor: CGFloat) -> CGFloat { + guard factor.isFinite else { return defaultFactor } + return min(max(factor, minimumFactor), maximumFactor) + } + + static func applying(_ command: ViewZoomCommand, to factor: CGFloat) -> CGFloat { + switch command { + case .zoomIn: + return normalized(factor + step) + case .zoomOut: + return normalized(factor - step) + case .reset: + return defaultFactor + } + } + + static func percentText(for factor: CGFloat) -> String { + "\(Int((normalized(factor) * 100).rounded()))%" + } + + static func textEditorFontSize(for factor: CGFloat) -> CGFloat { + normalized(factor) * textEditorDefaultFontSize + } + + static func textEditorZoomFactor(forFontSize fontSize: CGFloat) -> CGFloat { + normalized(fontSize / textEditorDefaultFontSize) + } + + static func command(for event: NSEvent) -> ViewZoomCommand? { + if KeyboardShortcutSettings.shortcut(for: .browserZoomIn).matches(event: event) { + return .zoomIn + } + if KeyboardShortcutSettings.shortcut(for: .browserZoomOut).matches(event: event) { + return .zoomOut + } + if KeyboardShortcutSettings.shortcut(for: .browserZoomReset).matches(event: event) { + return .reset + } + + let defaultAction = browserZoomShortcutAction( + flags: event.modifierFlags, + chars: event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode, + literalChars: event.characters + ) + + switch defaultAction { + case .zoomIn where KeyboardShortcutSettings.shortcut(for: .browserZoomIn) == KeyboardShortcutSettings.Action.browserZoomIn.defaultShortcut: + return .zoomIn + case .zoomOut where KeyboardShortcutSettings.shortcut(for: .browserZoomOut) == KeyboardShortcutSettings.Action.browserZoomOut.defaultShortcut: + return .zoomOut + case .reset where KeyboardShortcutSettings.shortcut(for: .browserZoomReset) == KeyboardShortcutSettings.Action.browserZoomReset.defaultShortcut: + return .reset + default: + return nil + } + } + + static let textEditorDefaultFontSize: CGFloat = 13 +} + +@MainActor +protocol ViewZoomControlling: AnyObject { + var viewZoomFactor: CGFloat { get } + + @discardableResult + func setViewZoomFactor(_ factor: CGFloat) -> Bool + + @discardableResult + func performViewZoomCommand(_ command: ViewZoomCommand) -> Bool +} + +extension ViewZoomControlling { + @discardableResult + func performViewZoomCommand(_ command: ViewZoomCommand) -> Bool { + setViewZoomFactor(ViewZoomControl.applying(command, to: viewZoomFactor)) + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index d1d2b01b1eb0..68d463124c7f 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -4845,19 +4845,18 @@ class TabManager: ObservableObject { return tab.panels[panelId] as? BrowserPanel } - @discardableResult - func zoomInFocusedBrowser() -> Bool { - focusedBrowserPanel?.zoomIn() ?? false - } - - @discardableResult - func zoomOutFocusedBrowser() -> Bool { - focusedBrowserPanel?.zoomOut() ?? false + var focusedPanel: (any Panel)? { + guard let tab = selectedWorkspace, + let panelId = tab.focusedPanelId else { return nil } + return tab.panels[panelId] } @discardableResult - func resetZoomFocusedBrowser() -> Bool { - focusedBrowserPanel?.resetZoom() ?? false + func performViewZoomCommand(_ command: ViewZoomCommand) -> Bool { + guard let controller = focusedPanel as? any ViewZoomControlling else { + return false + } + return controller.performViewZoomCommand(command) } @discardableResult diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 9c6f0326da7d..e0c73da68c49 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -700,15 +700,15 @@ struct cmuxApp: App { } splitCommandButton(title: String(localized: "menu.view.zoomIn", defaultValue: "Zoom In"), shortcut: menuShortcut(for: .browserZoomIn)) { - _ = activeTabManager.zoomInFocusedBrowser() + _ = activeTabManager.performViewZoomCommand(.zoomIn) } splitCommandButton(title: String(localized: "menu.view.zoomOut", defaultValue: "Zoom Out"), shortcut: menuShortcut(for: .browserZoomOut)) { - _ = activeTabManager.zoomOutFocusedBrowser() + _ = activeTabManager.performViewZoomCommand(.zoomOut) } splitCommandButton(title: String(localized: "menu.view.actualSize", defaultValue: "Actual Size"), shortcut: menuShortcut(for: .browserZoomReset)) { - _ = activeTabManager.resetZoomFocusedBrowser() + _ = activeTabManager.performViewZoomCommand(.reset) } Button(String(localized: "menu.view.clearBrowserHistory", defaultValue: "Clear Browser History")) { diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index ecf122bee1c2..e0a46b513ae2 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -1,6 +1,7 @@ import XCTest import Combine import AppKit +import Carbon.HIToolbox import SwiftUI import UniformTypeIdentifiers import WebKit @@ -3129,6 +3130,28 @@ final class BrowserZoomShortcutActionTests: XCTestCase { .reset ) } + + @MainActor + func testViewZoomCommandRespectsUnboundZoomShortcut() throws { + KeyboardShortcutSettings.resetAll() + defer { KeyboardShortcutSettings.resetAll() } + KeyboardShortcutSettings.clearShortcut(for: .browserZoomIn) + + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: "+", + charactersIgnoringModifiers: "=", + isARepeat: false, + keyCode: UInt16(kVK_ANSI_Equal) + )) + + XCTAssertNil(ViewZoomControl.command(for: event)) + } } diff --git a/cmuxTests/KeyboardShortcutContextTests.swift b/cmuxTests/KeyboardShortcutContextTests.swift index 6c3d29e2deb8..9fff49797e4b 100644 --- a/cmuxTests/KeyboardShortcutContextTests.swift +++ b/cmuxTests/KeyboardShortcutContextTests.swift @@ -141,6 +141,12 @@ final class KeyboardShortcutContextTests: XCTestCase { XCTAssertEqual(KeyboardShortcutSettings.Action.toggleReactGrab.shortcutContext, .application) } + func testViewZoomShortcutsAreApplicationScoped() { + XCTAssertEqual(KeyboardShortcutSettings.Action.browserZoomIn.shortcutContext, .application) + XCTAssertEqual(KeyboardShortcutSettings.Action.browserZoomOut.shortcutContext, .application) + XCTAssertEqual(KeyboardShortcutSettings.Action.browserZoomReset.shortcutContext, .application) + } + func testShortcutSettingsFilePreservesConfiguredShortcutWithoutGlobalConflictLookup() throws { let directoryURL = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: directoryURL) } diff --git a/cmuxTests/MarkdownPanelTests.swift b/cmuxTests/MarkdownPanelTests.swift index 16b6800b044b..50f7ad3156cb 100644 --- a/cmuxTests/MarkdownPanelTests.swift +++ b/cmuxTests/MarkdownPanelTests.swift @@ -42,6 +42,38 @@ final class MarkdownPanelTests: XCTestCase { XCTAssertEqual(overlay.alphaComponent, 1, accuracy: 0.0001) } + func testMarkdownThemeIncludesConfiguredTypographyVariables() { + let typography = MarkdownWebTypography( + fontFamily: "Avenir Next, sans-serif", + fontSize: 16.25, + headingSizes: MarkdownWebTypography.HeadingSizes( + h1: 34, + h2: 26, + h3: 21, + h4: 17, + h5: 14, + h6: 12 + ), + codeBlockFontFamily: "JetBrains Mono, ui-monospace, monospace", + codeBlockFontSize: 14.5 + ) + let theme = MarkdownWebTheme.resolve( + backgroundColor: NSColor(srgbRed: 0.10, green: 0.12, blue: 0.14, alpha: 1), + typography: typography + ) + + XCTAssertEqual(theme.cssVariables["--cmux-markdown-font-family"], "Avenir Next, sans-serif") + XCTAssertEqual(theme.cssVariables["--cmux-markdown-font-size"], "16.25px") + XCTAssertEqual(theme.cssVariables["--cmux-markdown-h1-font-size"], "34px") + XCTAssertEqual(theme.cssVariables["--cmux-markdown-h2-font-size"], "26px") + XCTAssertEqual(theme.cssVariables["--cmux-markdown-h3-font-size"], "21px") + XCTAssertEqual(theme.cssVariables["--cmux-markdown-h4-font-size"], "17px") + XCTAssertEqual(theme.cssVariables["--cmux-markdown-h5-font-size"], "14px") + XCTAssertEqual(theme.cssVariables["--cmux-markdown-h6-font-size"], "12px") + XCTAssertEqual(theme.cssVariables["--cmux-markdown-code-block-font-family"], "JetBrains Mono, ui-monospace, monospace") + XCTAssertEqual(theme.cssVariables["--cmux-markdown-code-block-font-size"], "14.5px") + } + func testFileOpenRoutesMarkdownFilesToPreviewMarkdownPanel() throws { let fileManager = FileManager.default let directoryURL = fileManager.temporaryDirectory diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 1ce14e3316b0..98ac1bae3cc3 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -2330,6 +2330,117 @@ final class FilePreviewPanelTextSavingTests: XCTestCase { XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "original") } + func testSavingTextViewHandlesCommandPlusViewZoom() async throws { + let url = try temporaryTextFile(contents: "original", encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + let panel = FilePreviewPanel(workspaceId: UUID(), filePath: url.path) + await panel.loadTextContent().value + + let textView = SavingTextView() + textView.panel = panel + panel.attachTextView(textView) + + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: "+", + charactersIgnoringModifiers: "=", + isARepeat: false, + keyCode: UInt16(kVK_ANSI_Equal) + )) + + XCTAssertTrue(textView.performKeyEquivalent(with: event)) + XCTAssertGreaterThan(panel.viewZoomFactor, ViewZoomControl.defaultFactor) + XCTAssertEqual(textView.font?.pointSize ?? 0, ViewZoomControl.textEditorFontSize(for: panel.viewZoomFactor), accuracy: 0.001) + } + + func testSavingTextViewFontSizeTracksViewZoomBounds() async throws { + let url = try temporaryTextFile(contents: "original", encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + let panel = FilePreviewPanel(workspaceId: UUID(), filePath: url.path) + await panel.loadTextContent().value + + let textView = SavingTextView() + textView.panel = panel + panel.attachTextView(textView) + + let zoomOutEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: "-", + charactersIgnoringModifiers: "-", + isARepeat: false, + keyCode: UInt16(kVK_ANSI_Minus) + )) + for _ in 0..<10 { + XCTAssertTrue(textView.performKeyEquivalent(with: zoomOutEvent)) + } + XCTAssertEqual(panel.viewZoomFactor, ViewZoomControl.minimumFactor, accuracy: 0.001) + XCTAssertEqual(textView.font?.pointSize ?? 0, ViewZoomControl.textEditorFontSize(for: ViewZoomControl.minimumFactor), accuracy: 0.001) + + let zoomInEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: "+", + charactersIgnoringModifiers: "=", + isARepeat: false, + keyCode: UInt16(kVK_ANSI_Equal) + )) + for _ in 0..<40 { + XCTAssertTrue(textView.performKeyEquivalent(with: zoomInEvent)) + } + XCTAssertEqual(panel.viewZoomFactor, ViewZoomControl.maximumFactor, accuracy: 0.001) + XCTAssertEqual(textView.font?.pointSize ?? 0, ViewZoomControl.textEditorFontSize(for: ViewZoomControl.maximumFactor), accuracy: 0.001) + } + + func testFilePreviewViewZoomOnlyHandlesTextMode() async throws { + let url = try temporaryTextFile(contents: "original", encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + let panel = FilePreviewPanel(workspaceId: UUID(), filePath: url.path) + await panel.loadTextContent().value + + XCTAssertTrue(panel.performViewZoomCommand(.zoomIn)) + XCTAssertEqual(panel.viewZoomFactor, 1.1, accuracy: 0.001) + XCTAssertTrue(panel.performViewZoomCommand(.reset)) + XCTAssertEqual(panel.viewZoomFactor, ViewZoomControl.defaultFactor, accuracy: 0.001) + + let imageURL = url.deletingLastPathComponent().appendingPathComponent("image.png") + let imagePanel = FilePreviewPanel(workspaceId: UUID(), filePath: imageURL.path) + XCTAssertFalse(imagePanel.performViewZoomCommand(.zoomIn)) + } + + func testMarkdownPanelViewZoomPersistsAcrossPreviewAndTextModes() throws { + let url = try temporaryTextFile(contents: "# Title", encoding: .utf8, pathExtension: "md") + defer { try? FileManager.default.removeItem(at: url) } + + let panel = MarkdownPanel(workspaceId: UUID(), filePath: url.path) + defer { panel.close() } + + XCTAssertEqual(panel.displayMode, .preview) + XCTAssertTrue(panel.performViewZoomCommand(.zoomIn)) + XCTAssertEqual(panel.viewZoomFactor, 1.1, accuracy: 0.001) + + panel.setDisplayMode(.text) + XCTAssertEqual(panel.displayMode, .text) + XCTAssertTrue(panel.performViewZoomCommand(.zoomOut)) + XCTAssertEqual(panel.viewZoomFactor, ViewZoomControl.defaultFactor, accuracy: 0.001) + } + func testSaveTextContentPreservesLoadedEncoding() async throws { let url = try temporaryTextFile(contents: "original", encoding: .utf16) defer { try? FileManager.default.removeItem(at: url) } diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 2da648f3895e..350239ba3e5c 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -690,6 +690,18 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { private var originalSettingsFileStore: KeyboardShortcutSettingsFileStore! private let settingsFileBackupsDefaultsKey = "cmux.settingsFile.backups.v1" + private let markdownTypographyManagedKeys = [ + MarkdownTypographySettings.fontFamilyKey, + MarkdownTypographySettings.fontSizeKey, + MarkdownTypographySettings.headingH1SizeKey, + MarkdownTypographySettings.headingH2SizeKey, + MarkdownTypographySettings.headingH3SizeKey, + MarkdownTypographySettings.headingH4SizeKey, + MarkdownTypographySettings.headingH5SizeKey, + MarkdownTypographySettings.headingH6SizeKey, + MarkdownTypographySettings.codeBlockFontFamilyKey, + MarkdownTypographySettings.codeBlockFontSizeKey, + ] func testShortcutConfigStringCanonicalizesNumberedDigitsWhenRequested() { let stroke = ShortcutStroke( @@ -1598,6 +1610,151 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { XCTAssertNil(defaults.data(forKey: settingsFileBackupsDefaultsKey)) } + func testSettingsFileStoreAppliesMarkdownTypographySettings() throws { + let defaults = UserDefaults.standard + let managedKeys = markdownTypographyManagedKeys + var previousValues: [String: Any] = [:] + for key in managedKeys { + if let value = defaults.object(forKey: key) { + previousValues[key] = value + } + } + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + for key in managedKeys { + if let value = previousValues[key] { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + + managedKeys.forEach { defaults.removeObject(forKey: $0) } + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("cmux.json", isDirectory: false) + try writeSettingsFile( + """ + { + "markdown": { + "fontFamily": "Avenir Next, sans-serif", + "fontSize": 16.25, + "headingSizes": { + "h1": 34, + "h2": 26, + "h3": 21, + "h4": 17, + "h5": 14, + "h6": 12 + }, + "codeBlockFontFamily": "JetBrains Mono, ui-monospace, monospace", + "codeBlockFontSize": 14.5 + } + } + """, + to: settingsFileURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + let typography = MarkdownTypographySettings.resolved(defaults: defaults) + XCTAssertEqual(typography.fontFamily, "Avenir Next, sans-serif") + XCTAssertEqual(typography.fontSize, 16.25) + XCTAssertEqual(typography.headingSizes.h1, 34) + XCTAssertEqual(typography.headingSizes.h2, 26) + XCTAssertEqual(typography.headingSizes.h3, 21) + XCTAssertEqual(typography.headingSizes.h4, 17) + XCTAssertEqual(typography.headingSizes.h5, 14) + XCTAssertEqual(typography.headingSizes.h6, 12) + XCTAssertEqual(typography.codeBlockFontFamily, "JetBrains Mono, ui-monospace, monospace") + XCTAssertEqual(typography.codeBlockFontSize, 14.5) + } + + func testSettingsFileStoreIgnoresInvalidMarkdownTypographySettings() throws { + let defaults = UserDefaults.standard + let managedKeys = markdownTypographyManagedKeys + var previousValues: [String: Any] = [:] + for key in managedKeys { + if let value = defaults.object(forKey: key) { + previousValues[key] = value + } + } + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + for key in managedKeys { + if let value = previousValues[key] { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + + managedKeys.forEach { defaults.removeObject(forKey: $0) } + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("cmux.json", isDirectory: false) + try writeSettingsFile( + """ + { + "markdown": { + "fontFamily": "Body; color: red", + "fontSize": 16.5, + "headingSizes": { + "h1": 96, + "h2": 24 + }, + "codeBlockFontFamily": "", + "codeBlockFontSize": 14.25 + } + } + """, + to: settingsFileURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + let typography = MarkdownTypographySettings.resolved(defaults: defaults) + let derivedHeadingSizes = MarkdownTypographySettings.defaultHeadingSizes(forFontSize: 16.5) + XCTAssertEqual(typography.fontFamily, MarkdownTypographySettings.defaultFontFamily) + XCTAssertEqual(typography.fontSize, 16.5) + XCTAssertEqual(typography.headingSizes.h1, derivedHeadingSizes.h1) + XCTAssertEqual(typography.headingSizes.h2, 24) + XCTAssertEqual(typography.headingSizes.h3, derivedHeadingSizes.h3) + XCTAssertEqual(typography.headingSizes.h4, derivedHeadingSizes.h4) + XCTAssertEqual(typography.headingSizes.h5, derivedHeadingSizes.h5) + XCTAssertEqual(typography.headingSizes.h6, derivedHeadingSizes.h6) + XCTAssertEqual(typography.codeBlockFontFamily, MarkdownTypographySettings.defaultCodeBlockFontFamily) + XCTAssertEqual(typography.codeBlockFontSize, 14.25) + } + @MainActor func testReloadConfigurationReloadsManagedAppSettingsFromSettingsFile() throws { let defaults = UserDefaults.standard diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 8e92175e59f3..3b5d7108f9c2 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -45,6 +45,7 @@ const sectionOrder = [ "ui", "commands", "browser", + "markdown", "shortcuts", ] as const; @@ -71,6 +72,18 @@ const settingsFileExample = `{ // "hostsToOpenInEmbeddedBrowser": ["localhost", "*.internal.example"] // }, + // "markdown": { + // "fontFamily": "Avenir Next", + // "fontSize": 16, + // "headingSizes": { + // "h1": 34, + // "h2": 26, + // "h3": 21 + // }, + // "codeBlockFontFamily": "JetBrains Mono, ui-monospace, monospace", + // "codeBlockFontSize": 14 + // }, + // "workspaceColors": { // "colors": { // "Red": "#C0392B", @@ -303,8 +316,8 @@ working-directory = ~/code`}

Schema reference

This reference covers every supported global settings key in cmux.json. The embedded - browser, terminal, sidebar, notifications, automation, and cmux-owned keyboard shortcuts - all live here. Actions and workspace commands are documented on the{" "} + browser, markdown, terminal, sidebar, notifications, automation, and cmux-owned keyboard + shortcuts all live here. Actions and workspace commands are documented on the{" "} custom commands page.

diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 74b00371abf3..d6f8a2680ad1 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -755,6 +755,81 @@ } } }, + "markdown": { + "title": "markdown", + "description": "Markdown preview typography settings.", + "type": "object", + "additionalProperties": false, + "properties": { + "fontFamily": { + "type": "string", + "default": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\"", + "description": "CSS font-family stack for markdown body text. Use a single family name or a full CSS stack." + }, + "fontSize": { + "type": "number", + "minimum": 8, + "maximum": 32, + "default": 15, + "description": "Markdown body font size in pixels. Heading defaults scale from this size when headingSizes are omitted." + }, + "headingSizes": { + "type": "object", + "additionalProperties": false, + "description": "Markdown heading font sizes in pixels. Omitted heading levels scale from fontSize using the default GitHub ratios.", + "properties": { + "h1": { + "type": "number", + "minimum": 8, + "maximum": 72, + "default": 30 + }, + "h2": { + "type": "number", + "minimum": 8, + "maximum": 72, + "default": 22.5 + }, + "h3": { + "type": "number", + "minimum": 8, + "maximum": 72, + "default": 18.75 + }, + "h4": { + "type": "number", + "minimum": 8, + "maximum": 72, + "default": 15 + }, + "h5": { + "type": "number", + "minimum": 8, + "maximum": 72, + "default": 13.13 + }, + "h6": { + "type": "number", + "minimum": 8, + "maximum": 72, + "default": 12.75 + } + } + }, + "codeBlockFontFamily": { + "type": "string", + "default": "ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace", + "description": "CSS font-family stack for fenced code blocks and frontmatter blocks." + }, + "codeBlockFontSize": { + "type": "number", + "minimum": 8, + "maximum": 32, + "default": 13.5, + "description": "Fenced code block font size in pixels." + } + } + }, "shortcuts": { "title": "shortcuts", "description": "Keyboard shortcut settings from Settings > Keyboard Shortcuts.",