term244: viewer split pane + Windows port roadmap

Integrate the HTML/Markdown viewer into the terminal split tree as a
pane. SurfaceView gains a viewer mode (no pty/surface), so a viewer
rides in the existing SplitTree<SurfaceView> with no leaf-type change.

- Open Viewer in Split via menu and CLI (open -a term244 file.md)
- reuse an existing viewer pane instead of creating new ones
- close button on viewer panes
- persistent WKWebView so closing one pane does not flicker others

Also adds WINDOWS_PORT.md, a roadmap for the native Windows port.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pull/12772/head
244t 2026-05-22 19:53:28 +09:00
parent b8a3e24cae
commit ee28ddd56e
6 changed files with 289 additions and 12 deletions

112
WINDOWS_PORT.md Normal file
View File

@ -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 同等で数千行。全体で数週間〜の独立プロジェクト。

View File

@ -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()

View File

@ -7,6 +7,7 @@ import SwiftUI
enum TerminalSplitOperation {
case resize(Resize)
case drop(Drop)
case close(Ghostty.SurfaceView)
struct Resize {
let node: SplitTree<Ghostty.SurfaceView>.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 {

View File

@ -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<Ghostty.SurfaceView>.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<Ghostty.SurfaceView>
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)
}
}

View File

@ -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)
}
}
}

View File

@ -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)