diff --git a/WINDOWS_PORT.md b/WINDOWS_PORT.md new file mode 100644 index 000000000..cc9c21a58 --- /dev/null +++ b/WINDOWS_PORT.md @@ -0,0 +1,112 @@ +# term244 — ネイティブ Windows 移植ロードマップ + +term244(Ghostty フォーク)をネイティブ Windows で動かすための実装計画。 +macOS 版(リブランド + HTML/Markdown ビューアタブ/ペイン)は完成済み。 +Windows 版はここから着手する。 + +> このドキュメントは Windows マシン側で作業を再開するための土台。 +> macOS のチャットセッションでは Windows のビルド・検証ができないため、 +> 実装そのものは Windows 環境で進めること。 + +## 現状サマリ + +- **コア(プラットフォーム非依存)**: `src/terminal/`(VT パース・画面バッファ)、 + `src/Surface.zig`、`src/App.zig`。Windows でもそのまま動く。 +- **apprt(アプリ層)**: `src/apprt/` に `gtk`(Linux)・`embedded`(macOS の + Swift アプリが利用)・`none` のみ。**Windows 用 apprt は存在しない** ← 最大の不足。 +- **レンダラ**: `src/renderer/` に Metal(macOS)と OpenGL(Linux/GTK)。 + +## 既に Windows で動く / 再利用できるもの + +下回りはかなり揃っている。足りないのは「ガワ(GUI)」だけ。 + +| 要素 | 状態 | 場所 | +|---|---|---| +| PTY (ConPTY) | ✅ 実装済み | `src/pty.zig` の `WindowsPty`(`CreatePseudoConsole` 等) | +| プロセス起動 | ✅ 実装済み | `src/Command.zig` の `startWindows()`(`CreateProcessW` + ConPTY) | +| Win32 API シム | ✅ あり | `src/os/windows.zig` | +| MSVC ABI | ✅ ビルド設定済み | `src/build/Config.zig`(Windows で `abi = .msvc` 強制) | +| ターミナルコア | ✅ 非依存 | `src/terminal/`、`src/Surface.zig`、`src/App.zig` | +| Windows リソース雛形 | ✅ あり | `dist/windows/ghostty.rc` | + +## 不足しているもの(新規実装) + +1. **Windows apprt** — ウィンドウ生成・メッセージループ・入力・DPI・ + クリップボード・IPC。`src/apprt/gtk/` が参照実装(Surface だけで ~1500 行)。 +2. **OpenGL の WGL コンテキスト経路** — `src/renderer/OpenGL.zig` の + `surfaceInit`/`threadEnter`/`threadExit`/`displayRealized` が現状 + GTK/embedded 専用 switch。Windows(WGL)ケースを追加。 +3. **apprt 選択の配線** — `src/apprt.zig` / `src/apprt/runtime.zig` / + `build.zig` に `.windows` を追加。 +4. **フォント探索** — fontconfig は Linux 専用。Windows は DirectWrite で + フォント列挙(`src/font/`)。ラスタライズは FreeType を流用可。 +5. **単一インスタンス IPC** — D-Bus の代わりに名前付きパイプ or Mutex。 + +## 実装計画(フェーズ順) + +### W1. apprt 選択の配線(小) +- `src/apprt.zig` の runtime 選択 switch に `.windows => windows` を追加。 +- `src/apprt/runtime.zig` の `Runtime.default()` で Windows を `.windows` に。 +- `build.zig`: Windows ターゲットで `exe` アーティファクトを許可。 +- まず空の `src/apprt/windows.zig` を作り、コンパイルが通る骨格にする。 + +### W2. Windows apprt の骨格(大 — 本丸) +- 新規: `src/apprt/windows.zig`、`src/apprt/windows/App.zig`、 + `src/apprt/windows/Surface.zig`。 +- `App`: Win32 メッセージループ(`GetMessage`/`DispatchMessage`)、 + ウィンドウクラス登録。 +- `Surface`: `CreateWindowExW` でウィンドウ生成、`WndProc` で入力 + (`WM_KEYDOWN`/`WM_CHAR`/`WM_MOUSE*`)・リサイズ(`WM_SIZE`)・ + 描画(`WM_PAINT`)・DPI(`WM_DPICHANGED`)を処理しコアへ転送。 +- 参照: `src/apprt/gtk/App.zig` / `src/apprt/gtk/Surface.zig` と + `src/apprt/embedded.zig`(コアが apprt に求めるメソッド一覧の把握に最適)。 +- apprt が実装すべき最小メソッド: `deinit`、`core()`、`getTitle()`、 + `getContentScale()`、`getSize()`、`getCursorPos()`、`supportsClipboard()`、 + `clipboardRequest()`、`setClipboard()`、`defaultTermioEnv()` ほかコールバック群。 + +### W3. レンダラ(OpenGL + WGL) +- `src/renderer/OpenGL.zig` の apprt 別 switch に `.windows` ケースを追加。 +- Surface の `HWND` → `HDC` 取得 → `wglCreateContext`(または + `WGL_ARB_create_context` で 3.3+ コア)→ レンダラスレッドで `wglMakeCurrent`。 +- `displayRealized` 相当を Windows apprt 側から呼んで GL ロード + (`gl.glad.load`、proc は `wglGetProcAddress`)。 +- D3D 新規バックエンドは不要。OpenGL で足りる。 + +### W4. フォント(DirectWrite) +- `src/font/backend.zig` に Windows 用ディスカバリを追加。`IDWriteFactory` + でシステムフォント列挙。ラスタライズは既存 FreeType を流用可。 + +### W5. クリップボード / IPC / 仕上げ +- クリップボード: `OpenClipboard`/`GetClipboardData`/`SetClipboardData` + (`CF_UNICODETEXT`)。 +- 単一インスタンス: 名前付き Mutex + 名前付きパイプで2個目の起動を転送。 +- `dist/windows/ghostty.rc` を term244 用に調整。アイコン。パッケージング。 + +## 推奨着手順とマイルストーン + +W1(配線・骨格)→ W2(空ウィンドウが出る所まで)→ W3(GL でターミナル描画) +→ **ここで「文字が出るターミナル」= 最初のマイルストーン** → W4/W5 で実用度向上。 + +## ビルド & 実行(Windows) + +- Zig 0.15.2(`build.zig.zon` の `minimum_zig_version`)。 +- W1 で `windows` apprt を追加後: + `zig build -Dtarget=x86_64-windows-msvc -Dapp-runtime=windows` +- 当面は `zig build` で `.exe` を生成 → 直接実行。 + +## 参考資料 + +- **ConPTY の使い方**: Microsoft Windows Terminal リポジトリ(C++)。 + `CreatePseudoConsole` とパイプ管理の正典。term244 側は実装済みなので + 主に「考え方」の参照。 +- **apprt が実装すべき契約**: `src/apprt/embedded.zig`(C-ABI 版・境界が明快)、 + `src/apprt/gtk/`(フル apprt の実例)。 +- **OpenGL on Windows**: `WGL_ARB_create_context`、glad のロード。 + +## 難所・リスク + +- Win32 のメッセージループとコアのイベントループ(libxev)の統合。 +- IME(`WM_IME_*`)対応 — 日本語入力に必須、地味に重い。 +- DPI スケーリング(per-monitor v2)。 +- `src/os/` に残る Unix 前提コードの個別対応(都度 `builtin.os.tag` で分岐)。 +- 規模: W2 が最大。GTK apprt 同等で数千行。全体で数週間〜の独立プロジェクト。 diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 304dce4bb..36363607b 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -212,7 +212,7 @@ class AppDelegate: NSObject, // Initial config loading ghosttyConfigDidChange(config: ghostty.config) - // Add the "Open Viewer Tab…" item to the File menu, right after "New Tab". + // Add the viewer items to the File menu, right after "New Tab". if let newTabItem = menuNewTab, let fileMenu = newTabItem.menu { let viewerItem = NSMenuItem( title: "Open Viewer Tab…", @@ -220,6 +220,13 @@ class AppDelegate: NSObject, keyEquivalent: "") viewerItem.target = self fileMenu.insertItem(viewerItem, at: fileMenu.index(of: newTabItem) + 1) + + let splitItem = NSMenuItem( + title: "Open Viewer in Split…", + action: #selector(openViewerSplit(_:)), + keyEquivalent: "") + splitItem.target = self + fileMenu.insertItem(splitItem, at: fileMenu.index(of: viewerItem) + 1) } // Start our update checker. @@ -468,13 +475,17 @@ class AppDelegate: NSObject, var isDirectory = ObjCBool(true) guard FileManager.default.fileExists(atPath: filename, isDirectory: &isDirectory) else { return false } - // HTML and Markdown files open in a viewer tab rather than executing. + // HTML and Markdown files open in a viewer rather than executing. + // If a terminal window is open, render in a split pane; otherwise open + // a standalone viewer tab/window. if !isDirectory.boolValue { let url = URL(fileURLWithPath: filename) if ViewerController.supportedExtensions.contains(url.pathExtension.lowercased()) { - ViewerController.open( - fileURL: url, - from: TerminalController.preferredParent?.window) + if let controller = TerminalController.preferredParent { + controller.showViewer(url: url) + } else { + ViewerController.open(fileURL: url, from: nil) + } return true } } @@ -998,6 +1009,25 @@ class AppDelegate: NSObject, } } + /// Opens a file picker and renders the chosen HTML/Markdown file in a split + /// pane beside the focused terminal. + @IBAction func openViewerSplit(_ sender: Any?) { + guard let controller = TerminalController.preferredParent else { + // No terminal window to split; fall back to a viewer tab. + openViewerTab(sender) + return + } + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.message = "Choose an HTML or Markdown file to view in a split" + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + controller.showViewer(url: url) + } + } + @IBAction func closeAllWindows(_ sender: Any?) { TerminalController.closeAllWindows() AboutController.shared.hide() diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 5fa12edeb..fe0b7e244 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -7,6 +7,7 @@ import SwiftUI enum TerminalSplitOperation { case resize(Resize) case drop(Drop) + case close(Ghostty.SurfaceView) struct Resize { let node: SplitTree.Node @@ -95,6 +96,26 @@ private struct TerminalSplitLeaf: View { @State private var isSelfDragging: Bool = false var body: some View { + if let viewerURL = surfaceView.viewerFileURL { + // Non-terminal viewer pane: render the HTML/Markdown file. + ViewerWebView(fileURL: viewerURL, persistentHost: surfaceView) + .overlay(alignment: .topTrailing) { + Button { + action(.close(surfaceView)) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(.secondary) + .padding(6) + .background(.regularMaterial, in: Circle()) + } + .buttonStyle(.plain) + .padding(8) + .help("Close viewer pane") + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Viewer pane") + } else { GeometryReader { geometry in Ghostty.InspectableSurface( surfaceView: surfaceView, @@ -128,6 +149,7 @@ private struct TerminalSplitLeaf: View { .accessibilityElement(children: .contain) .accessibilityLabel("Terminal pane") } + } } private enum DropState: Equatable { diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index cc631eb72..68d8f795a 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -269,6 +269,51 @@ class BaseTerminalController: NSWindowController, return newView } + /// Create a new non-terminal viewer split rendering the HTML/Markdown file + /// at `url`, beside `oldView`. + @discardableResult + func newViewerSplit( + at oldView: Ghostty.SurfaceView, + direction: SplitTree.NewDirection = .right, + url: URL + ) -> Ghostty.SurfaceView? { + // We can only create new splits for surfaces in our tree. + guard surfaceTree.root?.node(view: oldView) != nil else { return nil } + + // Create a viewer surface (no terminal/pty is created). + let newView = Ghostty.SurfaceView(viewerFile: url) + + let newTree: SplitTree + do { + newTree = try surfaceTree.inserting( + view: newView, + at: oldView, + direction: direction) + } catch { + Ghostty.logger.warning("failed to insert viewer split: \(error)") + return nil + } + + replaceSurfaceTree( + newTree, + moveFocusTo: newView, + moveFocusFrom: oldView, + undoAction: "New Viewer Split") + + return newView + } + + /// Show `url` in this window's viewer pane: reuse an existing viewer pane + /// if there is one, otherwise create a new viewer split. + func showViewer(url: URL) { + if let existing = surfaceTree.first(where: { $0.viewerFileURL != nil }) { + existing.setViewerFile(url) + return + } + guard let target = focusedSurface ?? surfaceTree.root?.leftmostLeaf() else { return } + newViewerSplit(at: target, url: url) + } + /// Move focus to a surface view. func focusSurface(_ view: Ghostty.SurfaceView) { // Check if target surface is in our tree @@ -879,6 +924,8 @@ class BaseTerminalController: NSWindowController, splitDidResize(node: resize.node, to: resize.ratio) case .drop(let drop): splitDidDrop(source: drop.payload, destination: drop.destination, zone: drop.zone) + case .close(let view): + closeSurface(view, withConfirmation: false) } } diff --git a/macos/Sources/Features/Viewer/ViewerView.swift b/macos/Sources/Features/Viewer/ViewerView.swift index 09d063710..1e67bc637 100644 --- a/macos/Sources/Features/Viewer/ViewerView.swift +++ b/macos/Sources/Features/Viewer/ViewerView.swift @@ -16,7 +16,17 @@ struct ViewerView: View { struct ViewerWebView: NSViewRepresentable { let fileURL: URL + /// When set (split-pane usage), the web view is cached on the surface so it + /// survives SwiftUI rebuilds of the split tree. nil for standalone tabs. + var persistentHost: Ghostty.SurfaceView? = nil + func makeNSView(context: Context) -> WKWebView { + // Reuse a cached web view so content does not reload (flicker) when the + // split tree rebuilds, e.g. after another pane is closed. + if let cached = persistentHost?.viewerWebView as? WKWebView { + return cached + } + let config = WKWebViewConfiguration() // A viewer never needs cookies or localStorage; a non-persistent store // skips disk I/O on init. @@ -24,7 +34,8 @@ struct ViewerWebView: NSViewRepresentable { let webView = WKWebView(frame: .zero, configuration: config) webView.allowsMagnification = true - load(into: webView) + ViewerWebView.load(url: fileURL, into: webView) + persistentHost?.viewerWebView = webView return webView } @@ -32,19 +43,21 @@ struct ViewerWebView: NSViewRepresentable { // The file URL is fixed for the lifetime of the view; nothing to do. } - private func load(into webView: WKWebView) { - let ext = fileURL.pathExtension.lowercased() - let dir = fileURL.deletingLastPathComponent() + /// Renders `url` into `webView`: Markdown is converted to HTML, everything + /// else (HTML, etc.) is loaded directly. + static func load(url: URL, into webView: WKWebView) { + let ext = url.pathExtension.lowercased() + let dir = url.deletingLastPathComponent() switch ext { case "md", "markdown", "mdown", "mkd", "mkdn": - let text = (try? String(contentsOf: fileURL, encoding: .utf8)) - ?? "# Could not read file\n\n`\(fileURL.path)`" + let text = (try? String(contentsOf: url, encoding: .utf8)) + ?? "# Could not read file\n\n`\(url.path)`" webView.loadHTMLString(ViewerHTML.markdownPage(markdown: text), baseURL: dir) default: // html, htm, or anything else: render the file directly. Grant // read access to the directory so relative assets resolve. - webView.loadFileURL(fileURL, allowingReadAccessTo: dir) + webView.loadFileURL(url, allowingReadAccessTo: dir) } } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index b1920f170..22e7bad38 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -3,6 +3,7 @@ import Combine import SwiftUI import CoreText import UserNotifications +import WebKit import GhosttyKit extension Ghostty { @@ -167,6 +168,17 @@ extension Ghostty { override var surface: ghostty_surface_t? { surfaceModel?.unsafeCValue } + + /// When non-nil, this is a non-terminal "viewer" pane that renders the + /// HTML/Markdown file at this URL instead of a terminal. Viewer panes + /// have no `surfaceModel` / pty; terminal code paths that guard on + /// `surface` simply no-op for them. + private(set) var viewerFileURL: URL? + + /// Persistent web view for a viewer pane, retained here so the rendered + /// content survives SwiftUI rebuilds of the split tree (e.g. when + /// another pane is closed). Typed as `NSView` to avoid a WebKit import. + var viewerWebView: NSView? /// Current scrollbar state, cached here for persistence across rebuilds /// of the SwiftUI view hierarchy, for example when changing splits var scrollbar: Ghostty.Action.Scrollbar? @@ -358,6 +370,38 @@ extension Ghostty { registerForDraggedTypes(Array(Self.dropTypes)) } + /// Creates a non-terminal "viewer" surface that renders the HTML or + /// Markdown file at `url`. No terminal surface or pty is created, so + /// this is a lightweight leaf for the split tree. + init(viewerFile url: URL, uuid: UUID? = nil) { + self.viewerFileURL = url + self.markedText = NSMutableAttributedString() + + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + self.derivedConfig = DerivedConfig(appDelegate.ghostty.config) + } else { + self.derivedConfig = DerivedConfig() + } + + self.cachedScreenContents = .init(duration: .milliseconds(500)) { "" } + self.cachedVisibleContents = self.cachedScreenContents + + super.init(id: uuid, frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + + // The pane/tab title is the file name. + self.title = url.lastPathComponent + } + + /// Updates this viewer pane to render a different file, reloading the + /// cached web view in place (no new pane is created). + func setViewerFile(_ url: URL) { + self.viewerFileURL = url + self.title = url.lastPathComponent + if let webView = viewerWebView as? WKWebView { + ViewerWebView.load(url: url, into: webView) + } + } + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported for this view") } @@ -1799,6 +1843,7 @@ extension Ghostty { case uuid case title case isUserSetTitle + case viewerURL } required convenience init(from decoder: Decoder) throws { @@ -1811,6 +1856,13 @@ extension Ghostty { let container = try decoder.container(keyedBy: CodingKeys.self) let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid)) + + // Viewer panes are non-terminal; recreate them directly. + if let viewerPath = try container.decodeIfPresent(String.self, forKey: .viewerURL) { + self.init(viewerFile: URL(fileURLWithPath: viewerPath), uuid: uuid) + return + } + var config = Ghostty.SurfaceConfiguration() config.workingDirectory = try container.decode(String?.self, forKey: .pwd) let savedTitle = try container.decodeIfPresent(String.self, forKey: .title) @@ -1830,6 +1882,7 @@ extension Ghostty { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(viewerFileURL?.path, forKey: .viewerURL) try container.encode(pwd, forKey: .pwd) try container.encode(id.uuidString, forKey: .uuid) try container.encode(title, forKey: .title)