feat: 修复终端换行错乱根因 + 顶带重设计 + 移除移动端分屏
- 换行错乱根因:tmux 客户端格子改以 ghostty 实测为权威(像素除法 偏大 1 行/列 → 远端按更宽排版、本地自动换行掉字符),单 pane 实测 格子直发 refresh-client;kickoff 用 raw surface 真实格子;顶栏 格子数 tmux 模式读 clientGrid(模拟器连本机 sshd 端到端验证) - 顶带落地为真实 chrome 行:✕/⌄ 独立按钮组 + 弱化标题 + TmuxWindowTabGroup window tab 组,pane 不再被悬浮胶囊遮内容 - 放弃移动端分屏:删 SplitMenu/分屏入口,tmux window 切换上移顶部 tab,侧边栏改列活动会话;底部动作条合并状态行 - Info.plist 补 NSLocalNetworkUsageDescription(LAN 直连必需, 缺失时 socket 静默超时) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -196,7 +196,7 @@ struct TerminalScreen: View {
|
||||
// 两态共用**同一个**视图实例(见 TerminalSidebar 的动画模型):开合就是它自己的
|
||||
// 宽度动画(56 ⇄ 288),直接参与布局 —— 内容淡出的同时终端从右侧盖上来(收起),
|
||||
// 反之展开。开合各触发一次终端 resize。
|
||||
TerminalSidebar(manager: manager, router: router, session: session,
|
||||
TerminalSidebar(manager: manager, router: router,
|
||||
tailscaleStore: tailscaleStore,
|
||||
railWidth: railWidth, safeBottom: safeBottom,
|
||||
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost,
|
||||
@@ -204,7 +204,7 @@ struct TerminalScreen: View {
|
||||
stage(safeTop: safeTop, safeBottom: safeBottom)
|
||||
}
|
||||
.background(p.bgDeep)
|
||||
// 关闭确认(3c)与分屏菜单(3b):自绘浮层,不用系统 alert/sheet —— 设计给的是精确尺寸的卡片。
|
||||
// 关闭确认(3c):自绘浮层,不用系统 alert/sheet —— 设计给的是精确尺寸的卡片。
|
||||
.overlay {
|
||||
switch router.modal {
|
||||
case .closeConfirm:
|
||||
@@ -213,13 +213,6 @@ struct TerminalScreen: View {
|
||||
onDetach: { router.dismissModal(); closeSession(kill: false) },
|
||||
onKill: { router.dismissModal(); closeSession(kill: true) },
|
||||
onCancel: { router.dismissModal() })
|
||||
case .splitMenu(let vertical):
|
||||
SplitMenuView(session: session, vertical: vertical,
|
||||
onPick: { target in
|
||||
router.dismissModal()
|
||||
performSplit(target, vertical: vertical)
|
||||
},
|
||||
onCancel: { router.dismissModal() })
|
||||
case nil:
|
||||
EmptyView()
|
||||
}
|
||||
@@ -258,47 +251,48 @@ struct TerminalScreen: View {
|
||||
// MARK: - 舞台
|
||||
|
||||
private func stage(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
|
||||
ZStack(alignment: .top) {
|
||||
VStack(spacing: 0) {
|
||||
// 无 tmux 时的行内引导横幅(设计 2c):占一行,把 pane 往下推,不遮内容。
|
||||
if session.tmuxUnavailable {
|
||||
NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } },
|
||||
onDismiss: {
|
||||
withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() }
|
||||
})
|
||||
.padding(.top, TX.Layout.capsuleInset)
|
||||
.transition(.opacity)
|
||||
}
|
||||
// 沉浸:pane 容器**从舞台顶部就开始**(底色 + 边框铺满),胶囊悬浮其上;
|
||||
// 只把 surface 内容下移 86pt 让出预留带 —— 预留带铺的是终端底色,故视觉连续。
|
||||
paneArea
|
||||
// 横幅已占掉预留带时不再重复留白(设计 2c:多一条横幅时预留 122pt)。
|
||||
.padding(.top, session.tmuxUnavailable ? TX.Space.hairline : TX.Layout.capsuleInset)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(p.terminalBackground)
|
||||
.clipShape(paneShape)
|
||||
.overlay(
|
||||
paneShape.strokeBorder(p.border, lineWidth: 1)
|
||||
// 顶边/右边与屏幕边缘齐平,那两条描边推出屏外:留着就是贴在屏幕
|
||||
// 边上的一圈多余框线,还会在屏幕圆角处被切出斜口。
|
||||
.padding(EdgeInsets(top: -1, leading: 0, bottom: 0, trailing: -1))
|
||||
)
|
||||
// 只在**朝轨道那一侧**留 2pt 缝;顶边/右边直接铺到屏幕物理边缘(沉浸)。
|
||||
// 底边不留缝:pane 底边即与侧边栏脚同高的那条分割线。
|
||||
.padding(.leading, TX.Layout.paneGap)
|
||||
TerminalActionBar(context: barContext, actions: barActions, safeBottom: safeBottom)
|
||||
VStack(spacing: 0) {
|
||||
// 顶带(真实布局行,用户定稿):✕/⌄ 按钮组 + 弱化标题 + tmux window tab 组,
|
||||
// 全在终端**外**的 chrome 带上 —— 终端 pane 从带下方开始,不再被悬浮胶囊遮内容。
|
||||
TerminalTopBar(session: session, onClose: requestClose, onMinimize: minimizeSession)
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
// iPad 隐藏状态栏后顶边无遮挡 → 设计值 14;灵动岛/刘海机型按硬件安全区下移。
|
||||
.padding(.top, TX.Layout.capsuleTop(safeTop))
|
||||
.padding(.bottom, TX.Space.s)
|
||||
// 无 tmux 时的行内引导横幅(设计 2c):占一行,把 pane 往下推,不遮内容。
|
||||
if session.tmuxUnavailable {
|
||||
NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } },
|
||||
onDismiss: {
|
||||
withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() }
|
||||
})
|
||||
.transition(.opacity)
|
||||
}
|
||||
capsules(safeTop: safeTop)
|
||||
paneArea
|
||||
// 内容离 pane 顶边一点呼吸位(预留带已随悬浮胶囊废除)。
|
||||
.padding(.top, TX.Space.s)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(p.terminalBackground)
|
||||
.clipShape(paneShape)
|
||||
.overlay(
|
||||
paneShape.strokeBorder(p.border, lineWidth: 1)
|
||||
// 右边与屏幕边缘齐平,那条描边推出屏外:留着就是贴在屏幕边上的
|
||||
// 多余框线,还会在屏幕圆角处被切出斜口。顶边现在朝 chrome,描边保留。
|
||||
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: -1))
|
||||
)
|
||||
// 只在**朝轨道那一侧**留 2pt 缝;右边直接铺到屏幕物理边缘(沉浸)。
|
||||
// 底边不留缝:pane 底边即与侧边栏脚同高的那条分割线。
|
||||
.padding(.leading, TX.Layout.paneGap)
|
||||
TerminalActionBar(session: session, actions: barActions, safeBottom: safeBottom)
|
||||
}
|
||||
.background(p.bgDeep)
|
||||
}
|
||||
|
||||
/// pane 容器的形状:**只在朝 app chrome 的那几个角收圆角**。
|
||||
///
|
||||
/// 终端右上角贴着屏幕物理边缘,而 iPad/iPhone 的屏幕本身是大圆角 —— 在那儿再画一个 6pt 小圆角,
|
||||
/// 终端右侧贴着屏幕物理边缘,而 iPad/iPhone 的屏幕本身是大圆角 —— 在那儿再画一个 6pt 小圆角,
|
||||
/// 两种曲率必然打架:小圆角整段落在屏幕圆角之外,被"吃掉",只在切口处露出一块底色三角。
|
||||
/// 故顶边/右边**不收角、不描边**,由屏幕自己的圆角收口;左边(挨轨道)、底边(挨提示条)
|
||||
/// 才是真正需要圆角把 pane 和 chrome 分开的地方。Stage Manager 的小窗同理(窗圆角接管)。
|
||||
/// 故右边**不收角、不描边**,由屏幕自己的圆角收口;左边(挨轨道)、顶边(挨顶带)、
|
||||
/// 底边(挨提示条)才是真正需要圆角把 pane 和 chrome 分开的地方。
|
||||
private var paneShape: UnevenRoundedRectangle {
|
||||
UnevenRoundedRectangle(
|
||||
topLeadingRadius: TX.Radius.pane,
|
||||
@@ -328,17 +322,6 @@ struct TerminalScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func capsules(safeTop: CGFloat) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
TerminalTitleCapsule(session: session, onClose: requestClose, onMinimize: minimizeSession)
|
||||
Spacer(minLength: TX.Space.m)
|
||||
TerminalStatusCapsule(session: session)
|
||||
}
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
// iPad 隐藏状态栏后顶边无遮挡 → 设计值 14;灵动岛/刘海机型按硬件安全区下移。
|
||||
.padding(.top, TX.Layout.capsuleTop(safeTop))
|
||||
}
|
||||
|
||||
/// 红点:**必须**先确认(设计要求)。确认后的动作分两套,见 `closeSession(kill:)`。
|
||||
private func requestClose() {
|
||||
router.present(.closeConfirm)
|
||||
@@ -365,47 +348,11 @@ struct TerminalScreen: View {
|
||||
router.goHome()
|
||||
}
|
||||
|
||||
/// 分屏菜单选择后立即分屏(设计:选完不再问方向)。
|
||||
private func performSplit(_ target: SplitMenuView.SplitTarget, vertical: Bool) {
|
||||
guard let tmux = session.tmuxController else { return }
|
||||
switch target {
|
||||
case .currentSession:
|
||||
tmux.splitWindow(vertical: vertical)
|
||||
case .otherSession(let name):
|
||||
tmux.splitWindow(vertical: vertical, joinSession: name)
|
||||
case .newSession:
|
||||
// 名字用会话数递增,避免与既有会话撞名(tmux 撞名会报错)。
|
||||
tmux.splitIntoNewSession(vertical: vertical, name: "tx\(tmux.sessions.count + 1)")
|
||||
case .nativeWindow:
|
||||
break // 首版不可用(菜单里已灰显),见 HANDOFF §12.4
|
||||
}
|
||||
}
|
||||
|
||||
/// ⌘D / ⌘⇧D 的入口:先拉一次 list-sessions 再弹菜单(菜单要列 detached 会话)。
|
||||
private func requestSplit(vertical: Bool) {
|
||||
session.tmuxController?.listSessions()
|
||||
router.present(.splitMenu(vertical: vertical))
|
||||
}
|
||||
|
||||
/// 动作条左侧那句话:说**当前会话**的状态(此前写死「已连接妙控键盘 · 附加行已收起」——
|
||||
/// 没接键盘时那是假话;侧边栏三态的说明属于实现细节,用户不需要)。
|
||||
private var barContext: String {
|
||||
if !session.isLive { return session.phaseText }
|
||||
if let t = session.tmuxSummary {
|
||||
let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)"
|
||||
return "\(session.displayName) · \(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
|
||||
}
|
||||
if session.tmuxSkipped { return "\(session.displayName) · 原生窗口" }
|
||||
return session.displayName
|
||||
}
|
||||
|
||||
/// 底部动作(**触控可点**,不是快捷键提示)。tmux 相关的两个在原生窗口下灰显。
|
||||
/// 底部动作(**触控可点**,不是快捷键提示)。新建 window 在原生窗口下灰显。
|
||||
/// (分屏入口已随「放弃移动端分屏」移除;左侧状态行见 `TerminalActionBar.statusLine`。)
|
||||
private var barActions: [TerminalActionBar.Action] {
|
||||
let tmux = session.tmuxController
|
||||
return [
|
||||
.init(id: "split", icon: "square.split.2x1", title: "竖分屏", enabled: tmux != nil) {
|
||||
requestSplit(vertical: true)
|
||||
},
|
||||
.init(id: "window", icon: "plus.rectangle.on.rectangle", title: "新建 window",
|
||||
enabled: tmux != nil) {
|
||||
tmux?.newWindow()
|
||||
@@ -489,12 +436,16 @@ struct TmuxWindowView: View {
|
||||
let root = layout.root.rect
|
||||
let cw = geo.size.width / CGFloat(max(1, root.width))
|
||||
let ch = geo.size.height / CGFloat(max(1, root.height))
|
||||
let items = leaves(layout.root)
|
||||
ZStack(alignment: .topLeading) {
|
||||
ForEach(leaves(layout.root), id: \.pane.raw) { item in
|
||||
ForEach(items, id: \.pane.raw) { item in
|
||||
if let surface = window.panes[item.pane] {
|
||||
TmuxPaneView(
|
||||
surface: surface,
|
||||
isActive: window.activePane == item.pane,
|
||||
// 单 pane:surface 帧必须与容器完全一致(其实测格子是
|
||||
// 客户端尺寸的权威,见 paneViewportChanged),不留缝。
|
||||
solo: items.count == 1,
|
||||
onTap: { controller.selectPane(item.pane, in: window.id) },
|
||||
onReady: { controller.markPaneReady(item.pane) }
|
||||
)
|
||||
@@ -651,6 +602,8 @@ struct PaneSplitter: View {
|
||||
struct TmuxPaneView: View {
|
||||
@ObservedObject var surface: TmuxPaneSurface
|
||||
let isActive: Bool
|
||||
/// window 里只有这一个 pane:不留 pane 缝,surface 像素帧 == 容器帧(尺寸权威所需)。
|
||||
let solo: Bool
|
||||
let onTap: () -> Void
|
||||
let onReady: () -> Void
|
||||
@FocusState private var focused: Bool
|
||||
@@ -672,7 +625,7 @@ struct TmuxPaneView: View {
|
||||
Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() }
|
||||
}
|
||||
}
|
||||
.padding(TX.Layout.paneGap / 2) // 相邻 pane 各让 1pt → 缝 2pt
|
||||
.padding(solo ? 0 : TX.Layout.paneGap / 2) // 相邻 pane 各让 1pt → 缝 2pt;单 pane 不缩
|
||||
.onChange(of: isActive) { _, active in focused = active }
|
||||
.task {
|
||||
for _ in 0 ..< 300 where surface.state.surfaceSize == nil {
|
||||
|
||||
Reference in New Issue
Block a user