diff --git a/.gitignore b/.gitignore index 5a2bb33..4a6330b 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,16 @@ desktop.ini # Scratch notes and locally downloaded font archives body.txt JetBrainsMono-*.zip + +# Local scratch checkouts, prototypes, and one-off repro scripts +.build-*/ +target-aligned/ +_nebula-front-stage/ +message-queue-prototype.html +package.json +package-lock.json +node_modules/ +scripts/drawer_repro.ps1 +scripts/ghost_repro/ +scripts/icon-build/ +scripts/render_shell_icon.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc4686..fbfb80b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,29 @@ Every release entry is provided in English and Simplified Chinese. 每个版本条目均同时提供英文和简体中文说明。 -## Unreleased / 未发布 +## 0.7.0 - 2026-07-24 ### English #### Added +- **Windows Terminal-style background image controls** — Appearance settings now provide independent wallpaper path, stretch mode, nine-position alignment, and image-opacity controls. Wallpapers stay inside terminal content by default; extending them beneath the title bar and sidebar is an explicit persisted option guarded by a visibility warning. - **Configurable startup directory** — Settings now provides a native directory picker for choosing the working directory used by new terminal tabs. The selection is persisted, can be cleared, and otherwise falls back to inheriting the current directory. - **Window-state persistence** — Nebula now restores the last normal window size and maximized state on launch. Logical dimensions are stored so the restored size remains consistent across displays with different DPI scaling. +- **Live appearance preview** — the Appearance page now leads with a terminal preview card that immediately reflects the current colors, font family and size, cursor shape, and cursor blinking as they are changed. +- **Inline dropdown option lists** — every multi-choice setting (default shell, terminal font, wallpaper stretch and alignment, interface language, completion accept key, cursor shape) now uses one shared Windows 11-style combobox whose floating list shows all options, replacing click-to-cycle rows. +- **Font size controls** — a numeric spinner in Profiles and Ctrl+mouse-wheel zoom adjust the terminal font size, and the value persists across restarts. Zooming affects only the terminal grid (and document viewers that follow it): chrome, sidebar, and Settings text stay anchored to the configured font size. +- **Background color palette with hex input** — the background-color row opens a floating picker (same combobox pattern as other multi-choice settings) with a 12-swatch palette and a `#RRGGBB` hex field (Enter applies, Esc closes) instead of cycling colors on click. +- **Cursor shape and blinking** — the default cursor shape (bar │, underscore _, filled box █, empty box □) is selectable and cursor blinking is on by default; DECSCUSR escapes from programs such as vim still override the shape. +- **Interaction settings with copy-on-select** — a new Interaction section adds Windows Terminal-style copy-on-select, on by default. With it off, right-click still copies the selection, or pastes when there is none. +- **SSH keepalive** — managed SSH sessions send application-level keepalives (30s interval, 6 retries) so long-idle remote sessions (claude/codex runs) no longer drop behind NAT/firewall idle timeouts. #### Fixed +- **Wallpaper formats and opacity controls** — background files accepted by the picker now render consistently for PNG, JPG/JPEG, WebP, and BMP. Terminal-content and wallpaper opacity controls are continuous draggable sliders without plus/minus steppers; values update live and persist once on release. +- **CLI math input isolation** — native math overlays now ignore the complete wrapped logical line owned by the active terminal cursor. Manually typing TeX in Codex, Claude Code, or another supported AI CLI remains editable source; only committed terminal content is eligible for rendering, without tool-specific exceptions. +- **Unified window-surface opacity and one-layer shell** — the window now clears fully transparent and every region is painted exactly once: the shell (title bar, sidebar, and the frame around the terminal card) is one continuous layer built from edge strips plus concave corner blocks that wrap the card's rounded corners, so no part of the frame can ever render more solid than another. Without a wallpaper the opacity slider drives the whole window including the terminal area; with a wallpaper the terminal area's translucency comes from the wallpaper's own opacity slider and the main slider shapes only the shell around it. +- **Caption restore state and modal layering** — caption-button hover fills now reach the physical top edge; maximized/fullscreen windows show the overlapping restore icon and return to a normal window when clicked. Command-palette panels are composited after Settings text, eliminating text ghosting through the overlay. - **Persistent terminal math rendering** — terminal formulas remain rendered while scrolling instead of exposing their original TeX source. Display formulas are also recovered when the opening `$$` has already moved into scrollback before the closing delimiter arrives, and partially visible formulas keep the geometry of the same complete layout. - **Unified Markdown and CLI math compilation** — Markdown documents and terminal overlays now use one shared source-normalization, TeX parsing, and layout entry point. Named and numeric HTML entities are decoded generically before parsing, so standard matrices, `cases`, and aligned formulas no longer expose transport text such as ` `; missing TeX row separators are never guessed. - **Theme-switch surface colors** — terminal and text-input backgrounds now update with the selected light or dark theme instead of retaining colors from the previous theme. Stale application-provided dynamic palette values are cleared when the host theme changes. @@ -24,6 +36,17 @@ Every release entry is provided in English and Simplified Chinese. - **Top-bar control alignment** — Files and Git remain on the shared title-bar centerline, while minimize, maximize, and close now form a contiguous 46px Windows-style caption band aligned flush with the right edge. Fixed vector strokes replace font-dependent glyphs, the close hover uses a solid red block, and its hit target still reaches the physical top-right corner in maximized windows. - **New-tab affordance sizing** — The sidebar `+` now keeps a fixed 14px visual size instead of growing and shrinking with its 20px/28px hit areas. The adjacent three-dot menu uses larger dots and balanced spacing so both controls retain consistent visual weight across sidebar states and DPI scales. - **Continuous working indicator** — the running-state spinner now uses a monotonic normalized phase and refreshes continuously while active, eliminating visible pauses and jumps between rotations. +- **Wallpaper corner rounding** — a background image no longer paints square corners over the terminal card: the image pass clips to the same rounded rectangle as the card (no rounding in full-window mode), so the shell radius survives setting a wallpaper. +- **Slider gesture and visuals** — opacity sliders drop the gray hover plate, use a Windows Terminal-style ringed thumb whose inner dot grows on hover/drag, and show a normal pointer instead of the resize cursor that made dragging read like a window resize. +- **Optical icon centering** — the gear, Files, and Git glyph icons now center on their real rasterized ink inside hover pills (measured from the glyph cache) instead of grid metrics; Nerd Font marks ink outside their nominal column and sat visibly off-center. Maximize/restore caption marks use rounded squares and all caption glyphs center on the visible button band. +- **Settings value alignment** — the startup-directory value is right-aligned like every other row value. +- **Command-palette row hover** — palette rows light up under the pointer again: hover updates were silently starved by earlier settings/chrome hover branches, so the palette is now handled first as a pointer-modal overlay. +- **Hint hover feedback** — hover feedback on links and file paths is a consistent single underline (already-underlined text keeps a straight line instead of upgrading to a double underline), and the "Ctrl+click" tooltip is now a compact small-type bubble anchored to the link's first cell — it no longer wanders with the pointer (read as flicker), and long paths are capped at 48 columns keeping the tail. +- **Cursor settings apply immediately** — choosing a cursor shape or toggling blinking now clears any style a shell pinned at startup via DECSCUSR (PSReadLine, starship), so the change is visible at once; programs like vim can still override afterwards. +- **Preview card wallpaper** — the Appearance live-preview card now renders the configured background image with the real fit, alignment, and opacity, clipped to the card and the settings viewport. +- **Confirm dialogs** — capped at 520 logical px with the body wrapping over multiple lines instead of stretching the card into a banner; buttons are a plain "Yes / No" pair with a minimum width, and Enter/Esc still work. +- **Caption minimize alignment** — the minimize dash sits on the same horizontal centerline as the maximize and close marks instead of Windows' slightly lowered dash. +- **Clipboard write resilience** — Windows clipboard writes retry with short backoff while another process briefly holds the clipboard (OSError 5 拒绝访问) and only log after persistent failure, instead of warning on the first transient race. #### Improved @@ -31,17 +54,29 @@ Every release entry is provided in English and Simplified Chinese. - **Low-overhead font fallback** — missing-glyph decisions are cached with the glyph rasterization result, so the Maple fallback is consulted only after the primary face rejects a character rather than on every frame. This preserves font customization and native glyph quality without maintaining a separate SVG icon pipeline. - **Animation scheduling efficiency** — smooth running indicators redraw at approximately 60 FPS only while work is active; finite interface animations use a lower cadence and idle windows fall back to a one-second cadence, without adding a worker thread or per-frame allocations. - **Internal module boundaries** — display state, size calculations, terminal color and math handling, file-dialog logic, event actions, window models, and terminal damage/renderable content now live in focused modules to reduce coupling while preserving existing behavior. -- **Regression coverage** — the no-default-features test suite now contains 384 passing tests, including streamed multiline formulas, window-state compatibility, theme palette reset, custom-font fallback, warning bounds, and chrome alignment. The default-feature check and release build also pass. +- **Reusable UI widget layer** — vector icons (`display/icons.rs`) and interactive controls — slider, toggle, combobox, spinner (`display/widgets.rs`) — are now shared components with one geometry source for layout, drawing, and hit-testing, replacing per-page hand-rolled quads so controls can no longer drift from their hit areas. ### 简体中文 #### 新增 +- **Windows Terminal 风格背景图设置** — 外观设置现已提供独立的背景图路径、拉伸模式、九宫格对齐和图片不透明度控制。背景图默认只覆盖终端正文;扩展到标题栏和侧边栏属于显式持久化选项,并会先显示操作可见度警告。 - **可配置启动目录** — 设置中新增原生目录选择器,可以指定新建终端标签页使用的工作目录。该配置会持久保存,也可以随时清除;未配置时继续继承当前目录。 - **窗口状态持久化** — Nebula 启动时会恢复上次关闭前的普通窗口大小和最大化状态。窗口尺寸按逻辑像素保存,在不同 DPI 缩放的显示器之间也能保持一致的视觉大小。 +- **外观实时预览** — 外观页顶部新增终端预览卡片,颜色、字体、字号、光标形状与闪烁的改动立即在预览中可见。 +- **内嵌下拉框选项列表** — 所有多选项设置(默认 Shell、终端字体、背景图拉伸模式与对齐、界面语言、补全接受键、光标形状)统一使用同一个 Windows 11 风格下拉组件,浮层列出全部选项,取代"点击循环切换"。 +- **字号控件** — 配置页新增字号数字步进器,终端区域支持 Ctrl+滚轮缩放字号;数值持久保存,重启后保持。缩放只作用于终端网格(以及跟随它的文档查看器):侧边栏、顶部栏与设置页文字锚定在配置字号上不受影响。 +- **背景色色盘与 16 进制输入** — 背景色行改为打开浮层选择器(与其他多选项设置同一 combobox 范式):12 格预设色板 + `#RRGGBB` 手动输入框(回车应用、Esc 关闭),不再点击循环切换。 +- **光标形状与闪烁** — 默认光标形状可选(条形 │、下划线 _、实心框 █、空心框 □),光标闪烁默认开启;vim 等程序通过 DECSCUSR 设置的形状仍然优先。 +- **交互设置与选中即复制** — 新增"交互"设置节,提供 Windows Terminal 风格的"自动将所选内容复制到剪贴板",默认开启;关闭后右键仍可复制选区、无选区时粘贴。 +- **SSH 保活** — 托管 SSH 会话发送应用层保活(30 秒间隔、6 次重试),长时间空闲的远程会话(claude/codex 运行中)不再被 NAT/防火墙空闲超时掐断。 #### 修复 +- **背景图格式与透明度控件修复** — 文件选择器允许的 PNG、JPG/JPEG、WebP 和 BMP 现在均可实际渲染。终端正文与背景图不透明度改为连续可拖拽滑块,移除加减按钮;拖动时实时预览,松手时集中持久化一次。 +- **CLI 公式输入隔离** — 原生公式覆盖层现在会忽略活动终端光标所属的整条换行逻辑行。在 Codex、Claude Code 或其他受支持 AI CLI 中手动输入 TeX 时始终保留可编辑源码;只有已经提交的终端内容才可能渲染,不再按具体工具特调。 +- **窗口表面透明度统一与外壳单层化** — 窗口清屏改为完全透明,每个区域只被绘制一次:外壳(标题栏、侧边栏与终端卡四周的边框)由边缘条带加环抱卡片圆角的凹角块拼成连续的一层,任何透明度下都不会再出现某块比邻块更实的"相框"分裂。无背景图时透明度滑块驱动整个窗口(包括终端区域);设置背景图后终端区域的透明感由背景图自身的不透明度决定,主滑块只调节终端以外的外壳。 +- **窗口还原状态与浮层层级修复** — 窗口按钮的悬停背景现在延伸到物理顶边;最大化或全屏时显示重叠方框还原图标,点击后回到普通窗口。命令面板改为在设置文字之后完整合成,避免底层文字透入浮层形成重影。 - **终端公式持久显示修复** — 滚动终端时,已经渲染的公式会持续覆盖原始 TeX 源码,不再重新露出原型。即使开头的 `$$` 在结尾到达前已经进入回滚历史,也能从历史行恢复完整块级公式;公式只显示一部分时仍沿用同一份完整布局,不会发生几何跳变。 - **Markdown 与 CLI 公式编译统一** — Markdown 文档和终端公式覆盖层现在统一经过同一个源码规范化、TeX 解析与排版入口;命名和数字 HTML 实体会在解析前按通用规则解码,使标准矩阵、`cases` 和多行对齐公式不再显示 ` ` 等传输文本,同时不会在缺少 TeX 行分隔符时猜测矩阵结构。 - **主题切换表面颜色修复** — 终端和文本输入框背景会随浅色、深色主题同步更新,不再残留上一个主题的底色;宿主主题变化时也会清理应用曾写入的动态调色板覆盖值。 @@ -51,6 +86,17 @@ Every release entry is provided in English and Simplified Chinese. - **顶部按钮对齐修复** — Files 和 Git 保持标题栏统一中心线,最小化、最大化和关闭按钮改为连续无缝的 46px Windows 风格按钮带,并完整贴齐右边缘。三个按钮使用固定矢量线条替代受字体影响的字形,关闭悬停显示完整红色矩形;窗口最大化时,其点击热区仍延伸到屏幕物理右上角。 - **新建标签控件尺寸修复** — 侧栏 `+` 固定为 14px 视觉尺寸,不再随 20px/28px 命中区域和侧栏状态忽大忽小;相邻三点菜单增大圆点并重新平衡间距,使两个控件在不同 DPI 下保持一致的视觉重量。 - **工作状态转圈修复** — 运行状态指示器改用单调时钟生成连续且归一化的旋转相位,消除每轮之间的停顿、回跳和不连贯。 +- **背景图圆角修复** — 设置背景图后终端卡片的圆角不再被矩形图片盖掉:图片绘制按卡片同一圆角矩形裁剪(全窗口模式不裁剪)。 +- **滑块手势与外观修复** — 不透明度滑块去掉灰色悬停底块,改为 Windows Terminal 风格圆环把手(悬停/拖动时内圆放大),指针改为普通箭头,不再显示会被误读成调整窗口大小的双向箭头。 +- **图标光学居中修复** — 齿轮、Files、Git 字形图标改按真实栅格墨迹在悬停背景内居中(从字形缓存量取),修复 Nerd 字形墨迹超出名义列宽导致的可见偏移;最大化/还原按钮改为圆角方框,三大键图标在完整按钮带内垂直居中。 +- **设置值对齐修复** — "启动目录"的值与其他行一致右对齐。 +- **命令面板行悬停修复** — 面板候选行重新跟随指针高亮:此前悬停更新被设置/Chrome 的悬停分支提前拦截,现在命令面板作为指针模态浮层最先处理。 +- **链接悬停反馈修复** — 链接与文件路径的 hover 反馈统一为单下划线(已带下划线的文本保持直线,不再升级双下划线);"Ctrl+点击"提示改为锚定在链接起始格的小字号圆角气泡——不再跟随指针逐格跳动(此前被感知为闪烁),路径最长 48 列并保留尾部文件名。 +- **光标设置即时生效修复** — 在设置里选择光标形状或切换闪烁时,会先清除 shell 启动时通过 DECSCUSR 钉住的样式(PSReadLine、starship 常见),修改立即可见;vim 等程序之后仍可正常覆盖。 +- **预览卡背景图修复** — 外观页实时预览卡现在会按真实的拉伸模式、对齐与不透明度渲染已配置的背景图,并裁剪在预览卡与设置视口内。 +- **确认对话框修复** — 卡片宽度上限 520 逻辑像素,超长正文自动换行而不再把对话框拉成横幅;按钮统一为"是 / 否"两个短按钮(保留最小宽度),Enter / Esc 快捷键继续生效。 +- **三大键减号对齐修复** — 最小化"─"与最大化、关闭图标处于同一水平中线,不再沿用 Windows 略微下沉的画法。 +- **剪贴板写入韧性修复** — Windows 剪贴板被其他进程短暂占用(OSError(5) 拒绝访问)时改为短退避重试,只有持续失败才记录日志,不再对瞬时竞争弹警告。 #### 改进 @@ -58,7 +104,7 @@ Every release entry is provided in English and Simplified Chinese. - **低开销字体回退** — 缺失字形的选择结果会和光栅化结果一起缓存,只有主字体确实缺少字符时才查询 Maple,而不是每帧重复判断。在保留自定义字体和原生字形质量的同时,也不需要维护额外的 SVG 图标管线。 - **动画调度效率改进** — 只有工作状态活跃时才以约 60 FPS 刷新连续转圈;有限时长的界面动画使用更低频率,空闲窗口降到一秒一次,并且没有增加工作线程或逐帧内存分配。 - **内部模块边界改进** — 显示状态、尺寸计算、终端颜色与公式、文件选择器、事件动作、窗口模型以及终端损伤与可渲染内容已经拆分到职责集中的模块,在保留现有行为的同时降低后续修改的耦合风险。 -- **回归覆盖改进** — 无默认特性测试套件目前共 384 项通过,覆盖流式多行公式、窗口状态兼容、主题调色板重置、自定义字体回退、警告范围和顶部控件对齐;默认特性检查与 Release 构建也已通过。 +- **通用 UI 组件层** — 矢量图标(`display/icons.rs`)与交互控件(滑块、开关、下拉框、步进器,`display/widgets.rs`)成为共享组件:布局、绘制与命中测试共用同一几何来源,取代各页面手搓 quad,控件与点击区域从此不会漂移。 ## 0.6.0 - 2026-07-19 diff --git a/Cargo.lock b/Cargo.lock index 7983b63..503142e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -373,6 +373,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -1678,6 +1684,32 @@ dependencies = [ "cc", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "2.11.4" @@ -2091,6 +2123,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "naga" version = "25.0.1" @@ -2157,7 +2199,7 @@ dependencies = [ [[package]] name = "nebula" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ab_glyph_rasterizer", "ahash", @@ -2177,6 +2219,7 @@ dependencies = [ "glutin", "home", "html-escape", + "image", "libc", "log", "mlua", @@ -2189,7 +2232,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", - "png", + "png 0.17.16", "polling", "pollster", "pulldown-cmark", @@ -2219,7 +2262,7 @@ dependencies = [ [[package]] name = "nebula-completions" -version = "0.6.0" +version = "0.7.0" dependencies = [ "lscolors", "nu-ansi-term", @@ -2230,7 +2273,7 @@ dependencies = [ [[package]] name = "nebula_config" -version = "0.6.0" +version = "0.7.0" dependencies = [ "log", "nebula_config_derive", @@ -2240,7 +2283,7 @@ dependencies = [ [[package]] name = "nebula_config_derive" -version = "0.6.0" +version = "0.7.0" dependencies = [ "log", "nebula_config", @@ -2257,7 +2300,7 @@ version = "0.6.0" [[package]] name = "nebula_terminal" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64", "bitflags 2.13.0", @@ -2911,6 +2954,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -3036,6 +3092,18 @@ dependencies = [ "bumpalo", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.37.5" @@ -5294,3 +5362,18 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/nebula-completions/Cargo.toml b/nebula-completions/Cargo.toml index 98900b4..7d674e8 100644 --- a/nebula-completions/Cargo.toml +++ b/nebula-completions/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula-completions" -version = "0.6.0" +version = "0.7.0" edition.workspace = true [features] diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index bbf03e1..b65c207 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula" -version = "0.6.0" +version = "0.7.0" license = "GPL-3.0-or-later" description = "Nebula Terminal — a premium GPU-accelerated terminal emulator" readme = "README.md" @@ -15,19 +15,19 @@ path = "src/main.rs" [dependencies.nebula_terminal] path = "../nebula_terminal" -version = "0.6.0" +version = "0.7.0" [dependencies.nebula_config_derive] path = "../nebula_config_derive" -version = "0.6.0" +version = "0.7.0" [dependencies.nebula_config] path = "../nebula_config" -version = "0.6.0" +version = "0.7.0" [dependencies.nebula-completions] path = "../nebula-completions" -version = "0.6.0" +version = "0.7.0" [dependencies] ahash = { version = "0.8.6", features = ["no-rng"] } @@ -86,6 +86,7 @@ xdg = "3.0.0" rfd = "0.17.2" [target.'cfg(not(target_os = "macos"))'.dependencies] +image = { version = "0.25.8", default-features = false, features = ["bmp", "jpeg", "png", "webp"] } png = { version = "0.17.5", default-features = false, optional = true } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/nebula_app/res/image.f.glsl b/nebula_app/res/image.f.glsl index 4822d32..eb65dfe 100644 --- a/nebula_app/res/image.f.glsl +++ b/nebula_app/res/image.f.glsl @@ -3,6 +3,12 @@ // The terminal background is drawn as straight-alpha RGBA so it can blend with // the existing transparent-window path instead of making the whole window // opaque when a wallpaper is configured. +// +// `uClipRect`/`uClipRadius` clip the wallpaper to the terminal card's ROUNDED +// rect (same SDF as the UI quads): without it the rectangular image painted +// square corners on top of the card and the shell radius disappeared. +// Coordinates are framebuffer px with the GL bottom-left origin; radius 0 +// disables rounding (full-window "cover chrome" mode). #if defined(GLES2_RENDERER) #define float_t mediump float @@ -10,16 +16,21 @@ uniform sampler2D uTexture; uniform float_t uOpacity; +uniform mediump vec4 uClipRect; +uniform float_t uClipRadius; varying mediump vec2 uv; #else #define float_t float +#define vec2_t vec2 out vec4 FragColor; #define FRAG_COLOR FragColor uniform sampler2D uTexture; uniform float_t uOpacity; +uniform vec4 uClipRect; +uniform float_t uClipRadius; in vec2 uv; #endif @@ -31,5 +42,17 @@ void main() { vec4 col = texture(uTexture, uv); #endif - FRAG_COLOR = vec4(col.rgb, col.a * clamp(uOpacity, 0.0, 1.0)); + float_t coverage = 1.0; + if (uClipRadius > 0.0) { + // Signed distance to the rounded rect; 1px feather anti-aliases the + // clipped edge so the card corner stays as crisp as the UI quads. + mediump vec2 half_size = uClipRect.zw * 0.5; + mediump vec2 center = uClipRect.xy + half_size; + mediump vec2 from_center = abs(gl_FragCoord.xy - center); + mediump vec2 corner = from_center - (half_size - vec2(uClipRadius, uClipRadius)); + float_t dist = length(max(corner, vec2(0.0, 0.0))) - uClipRadius; + coverage = clamp(0.5 - dist, 0.0, 1.0); + } + + FRAG_COLOR = vec4(col.rgb, col.a * clamp(uOpacity, 0.0, 1.0) * coverage); } diff --git a/nebula_app/res/ui.f.glsl b/nebula_app/res/ui.f.glsl index 7aeff31..5f04438 100644 --- a/nebula_app/res/ui.f.glsl +++ b/nebula_app/res/ui.f.glsl @@ -1,94 +1,105 @@ -// Nebula chrome UI shader: rounded-rect SDF + linear gradient fill. -// -// Antialiasing is derivative-free: the SDF is expressed in pixels, so a 1px -// edge falloff `clamp(0.5 - d)` gives clean AA on both GLES2 and GLSL3 without -// requiring the standard-derivatives extension. - -#if defined(GLES2_RENDERER) -#define float_t mediump float -#define FRAG_COLOR gl_FragColor - -varying mediump vec2 uv; -varying mediump vec2 quadSize; -varying mediump float quadRadius; -varying mediump float quadFeather; -varying mediump vec2 grad; -varying mediump vec4 corners; -varying mediump vec4 color0; -varying mediump vec4 color1; -#else -#define float_t float - -out vec4 FragColor; -#define FRAG_COLOR FragColor - -in vec2 uv; -in vec2 quadSize; -in float quadRadius; -in float quadFeather; -in vec2 grad; -in vec4 corners; -in vec4 color0; -in vec4 color1; -#endif - -// Signed distance to a rounded box centered at the origin, with per-corner -// radii. `radii` = [top-left, top-right, bottom-right, bottom-left]; the corner -// facing `p`'s quadrant is selected so join corners can stay square while the -// outer ones curve (the connected top-bar/sidebar L-frame). -float_t sdRoundedBox(vec2 p, vec2 halfSize, vec4 radii) { - // p.x > 0 -> right half (top-right / bottom-right), else left half. - float_t r = (p.x > 0.0) ? ((p.y > 0.0) ? radii.z : radii.y) - : ((p.y > 0.0) ? radii.w : radii.x); - vec2 q = abs(p) - halfSize + r; - return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; -} - -void main() { - // Pixel-space position within the quad, centered at its middle. - vec2 p = (uv - 0.5) * quadSize; - vec2 halfSize = quadSize * 0.5; - - float_t coverage; - if (quadFeather > 0.5) { - // Soft radial glow: smooth falloff from the center outward. - float_t maxr = min(halfSize.x, halfSize.y); - float_t dc = length(p) / max(maxr, 1.0); - coverage = clamp(1.0 - dc, 0.0, 1.0); - coverage = coverage * coverage; - } else if (quadFeather < -0.5) { - // Rounded-rectangle outer shadow. The quad is expanded by `blur` on - // every edge; evaluate the original inset card and fade only through - // the expansion band. The opaque interior is covered by the card drawn - // immediately afterwards. - float_t blur = -quadFeather; - vec2 innerHalf = max(halfSize - vec2(blur), vec2(1.0)); - float_t lim = min(innerHalf.x, innerHalf.y); - vec4 radii = min(corners, vec4(lim)); - float_t d = sdRoundedBox(p, innerHalf, radii); - float_t edge = clamp(max(d, 0.0) / blur, 0.0, 1.0); - coverage = 1.0 - smoothstep(0.0, 1.0, edge); - } else if (quadRadius < 0.0) { - // Flat fill: the shape comes from the geometry (e.g. slanted powerline - // parallelograms), so cover every fragment. - coverage = 1.0; - } else { - // Crisp rounded box with 1px antialiasing from the signed distance. - // Each corner is clamped independently so a large outer radius and a - // square (0) join corner can coexist on the same quad. - float_t lim = min(halfSize.x, halfSize.y); - vec4 radii = min(corners, vec4(lim)); - float_t d = sdRoundedBox(p, halfSize, radii); - coverage = clamp(0.5 - d, 0.0, 1.0); - } - - if (coverage <= 0.0) { - discard; - } - - // Linear gradient along the `grad` axis in uv space. - float_t t = clamp(dot(uv, grad), 0.0, 1.0); - vec4 col = mix(color0, color1, t); - - FRAG_COLOR = vec4(col.rgb, col.a * coverage); -} +// Nebula chrome UI shader: rounded-rect SDF + linear gradient fill. +// +// Antialiasing is derivative-free: the SDF is expressed in pixels, so a 1px +// edge falloff `clamp(0.5 - d)` gives clean AA on both GLES2 and GLSL3 without +// requiring the standard-derivatives extension. + +#if defined(GLES2_RENDERER) +#define float_t mediump float +#define FRAG_COLOR gl_FragColor + +varying mediump vec2 uv; +varying mediump vec2 quadSize; +varying mediump float quadRadius; +varying mediump float quadFeather; +varying mediump vec2 grad; +varying mediump vec4 corners; +varying mediump vec4 color0; +varying mediump vec4 color1; +#else +#define float_t float + +out vec4 FragColor; +#define FRAG_COLOR FragColor + +in vec2 uv; +in vec2 quadSize; +in float quadRadius; +in float quadFeather; +in vec2 grad; +in vec4 corners; +in vec4 color0; +in vec4 color1; +#endif + +// Signed distance to a rounded box centered at the origin, with per-corner +// radii. `radii` = [top-left, top-right, bottom-right, bottom-left]; the corner +// facing `p`'s quadrant is selected so join corners can stay square while the +// outer ones curve (the connected top-bar/sidebar L-frame). +float_t sdRoundedBox(vec2 p, vec2 halfSize, vec4 radii) { + // p.x > 0 -> right half (top-right / bottom-right), else left half. + float_t r = (p.x > 0.0) ? ((p.y > 0.0) ? radii.z : radii.y) + : ((p.y > 0.0) ? radii.w : radii.x); + vec2 q = abs(p) - halfSize + r; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; +} + +void main() { + // Pixel-space position within the quad, centered at its middle. + vec2 p = (uv - 0.5) * quadSize; + vec2 halfSize = quadSize * 0.5; + + float_t coverage; + if (quadFeather > 1.5) { + // Concave corner filler: paint OUTSIDE the quarter circle of radius + // corners.z centered at corners.xy (quad-local px). This is the piece + // of shell frame that hugs one rounded card corner from the outside; + // its circular AA is the exact complement of the card quad's convex + // corner (same center, same radius), so the two meet at uniform + // coverage with no seam. The quad's straight edges stay hard (full + // coverage) to butt flush against the shell's edge strips. + vec2 pc = uv * quadSize - corners.xy; + float_t dc = length(pc) - corners.z; + coverage = clamp(dc + 0.5, 0.0, 1.0); + } else if (quadFeather > 0.5) { + // Soft radial glow: smooth falloff from the center outward. + float_t maxr = min(halfSize.x, halfSize.y); + float_t dc = length(p) / max(maxr, 1.0); + coverage = clamp(1.0 - dc, 0.0, 1.0); + coverage = coverage * coverage; + } else if (quadFeather < -0.5) { + // Rounded-rectangle outer shadow. The quad is expanded by `blur` on + // every edge; evaluate the original inset card and fade only through + // the expansion band. The opaque interior is covered by the card drawn + // immediately afterwards. + float_t blur = -quadFeather; + vec2 innerHalf = max(halfSize - vec2(blur), vec2(1.0)); + float_t lim = min(innerHalf.x, innerHalf.y); + vec4 radii = min(corners, vec4(lim)); + float_t d = sdRoundedBox(p, innerHalf, radii); + float_t edge = clamp(max(d, 0.0) / blur, 0.0, 1.0); + coverage = 1.0 - smoothstep(0.0, 1.0, edge); + } else if (quadRadius < 0.0) { + // Flat fill: the shape comes from the geometry (e.g. slanted powerline + // parallelograms), so cover every fragment. + coverage = 1.0; + } else { + // Crisp rounded box with 1px antialiasing from the signed distance. + // Each corner is clamped independently so a large outer radius and a + // square (0) join corner can coexist on the same quad. + float_t lim = min(halfSize.x, halfSize.y); + vec4 radii = min(corners, vec4(lim)); + float_t d = sdRoundedBox(p, halfSize, radii); + coverage = clamp(0.5 - d, 0.0, 1.0); + } + + if (coverage <= 0.0) { + discard; + } + + // Linear gradient along the `grad` axis in uv space. + float_t t = clamp(dot(uv, grad), 0.0, 1.0); + vec4 col = mix(color0, color1, t); + + FRAG_COLOR = vec4(col.rgb, col.a * coverage); +} diff --git a/nebula_app/src/clipboard.rs b/nebula_app/src/clipboard.rs index 831b495..88daf94 100644 --- a/nebula_app/src/clipboard.rs +++ b/nebula_app/src/clipboard.rs @@ -62,18 +62,25 @@ impl Clipboard { _ => &mut self.clipboard, }; - // Windows: OpenClipboard races with clipboard listeners (IMEs, cloud - // clipboard, managers) and fails with OSError(5) — one short retry - // resolves virtually all of them, so don't alarm the user unless the - // retry fails too. + // Windows: OpenClipboard races with clipboard listeners (IMEs, the + // Win+V cloud clipboard, managers, remote-desktop tools) and fails + // with OSError(5) 拒绝访问. The holder releases within milliseconds, + // so short backoff retries resolve virtually all of these; only a + // persistent failure is worth logging. let text = text.into(); - if clipboard.set_contents(text.clone()).is_ok() { - return; + let mut last_err = None; + for delay_ms in [0u64, 15, 40, 80] { + if delay_ms > 0 { + std::thread::sleep(std::time::Duration::from_millis(delay_ms)); + } + match clipboard.set_contents(text.clone()) { + Ok(()) => return, + Err(err) => last_err = Some(err), + } } - std::thread::sleep(std::time::Duration::from_millis(15)); - clipboard.set_contents(text).unwrap_or_else(|err| { + if let Some(err) = last_err { warn!("Unable to store text in clipboard: {err}"); - }); + } } pub fn load(&mut self, ty: ClipboardType) -> String { diff --git a/nebula_app/src/config/cursor.rs b/nebula_app/src/config/cursor.rs index e2401ce..f57ac74 100644 --- a/nebula_app/src/config/cursor.rs +++ b/nebula_app/src/config/cursor.rs @@ -31,7 +31,9 @@ impl Default for Cursor { thickness: Percentage::new(0.15), unfocused_hollow: true, blink_interval: 750, - blink_timeout: 5, + // 0 = never stop blinking. The old 5s pause read as "闪烁失效" — + // the cursor silently froze mid-session (user report 2026-07-23). + blink_timeout: 0, style: Default::default(), vi_mode_style: Default::default(), } diff --git a/nebula_app/src/display/chrome.rs b/nebula_app/src/display/chrome.rs index f2b867b..b724cfc 100644 --- a/nebula_app/src/display/chrome.rs +++ b/nebula_app/src/display/chrome.rs @@ -215,9 +215,9 @@ fn window_control_hit_rect( (center_x - s(23.0), 0.0, s(46.0), center_y + s(20.0)) } -/// Visible caption-button band. It shares the hit rect's horizontal geometry -/// (46px, no gaps, close edge at `width`) while staying vertically centred in -/// the painted title bar; the invisible hit extension alone reaches `y = 0`. +/// Visible caption-button band. It reaches the physical top edge while the +/// glyph remains centred on the inset title bar. This removes the detached +/// strip visible above Chrome-style caption buttons in a borderless window. #[inline] fn window_control_visual_rect( center_x: f32, @@ -226,7 +226,7 @@ fn window_control_visual_rect( scale_factor: f32, ) -> (f32, f32, f32, f32) { let width = 46.0 * scale_factor; - (center_x - width * 0.5, top, width, bar_h) + (center_x - width * 0.5, 0.0, width, top + bar_h) } /// Top-left settings trigger. It occupies the old product-mark slot beside the @@ -343,11 +343,8 @@ pub(super) fn chrome_tab_layout( // The empty SSH section still draws a two-line onboarding hint below its // header. Reserve that real content height so it cannot paint through the // bottom-docked message queue when many tabs consume the elastic budget. - let empty_hosts_hint_h = if queue_reserved > 0.0 && host_count == 0 && model.hosts_open { - s(38.0) - } else { - 0.0 - }; + let empty_hosts_hint_h = + if queue_reserved > 0.0 && host_count == 0 && model.hosts_open { s(38.0) } else { 0.0 }; // "+" square plus its dropdown chevron, vertically centred in the header // band, pinned to the right. The chevron is the rightmost element; the @@ -366,16 +363,11 @@ pub(super) fn chrome_tab_layout( // starve the other below half of the budget, and every overflowing // section scrolls behind its own scrollbar. let avail_rows = - (((panel_h - - queue_reserved - - header - - hosts_header_h - - empty_hosts_hint_h - - bottom_pad + (((panel_h - queue_reserved - header - hosts_header_h - empty_hosts_hint_h - bottom_pad + gap) / pitch) - .floor() - .max(0.0)) as usize; + .floor() + .max(0.0)) as usize; let tabs_want = if model.tabs_open { count } else { 0 }; let hosts_want = if model.hosts_open { host_count } else { 0 }; let (tabs_show, hosts_show) = if tabs_want + hosts_want <= avail_rows { @@ -473,177 +465,9 @@ pub(super) fn chrome_tab_layout( } } -/// Draw the new-tab mark from the same physical rectangle used for its hit and -/// hover area. Maple's Codicon `add` glyph has asymmetric outline bounds, so -/// centering it by terminal-cell metrics makes the ink look offset even though -/// Maple itself is a monospaced text font. A two-stroke primitive keeps this -/// one control optically and geometrically centered at every DPI. -fn push_centered_add_icon( - quads: &mut Vec, - rect: (f32, f32, f32, f32), - scale: f32, - color: Rgb, -) { - let (x, y, width, height) = rect; - if width <= 0.0 || height <= 0.0 { - return; - } - - let center_x = x + width * 0.5; - let center_y = y + height * 0.5; - // 图标墨迹不能跟随命中区缩放:侧栏收起时按钮是 28px,展开时是 - // 20px,若从 rect 推导尺寸,同一个“新建 Tab”图标会在两种状态间 - // 忽大忽小。固定为 14px 视觉尺寸,同时保留原来的舒适命中区域。 - let stroke = (1.5 * scale).max(1.0); - let arm = 7.0 * scale; - let ink = Rgba::new(color.r, color.g, color.b, 235); - - quads.push(UiQuad::solid( - center_x - arm, - center_y - stroke * 0.5, - arm * 2.0, - stroke, - stroke * 0.5, - ink, - )); - quads.push(UiQuad::solid( - center_x - stroke * 0.5, - center_y - arm, - stroke, - arm * 2.0, - stroke * 0.5, - ink, - )); -} - -/// Native three-dot menu mark. Its geometry comes from the same control rect -/// as hover/hit-testing, so Maple private-use glyph metrics cannot skew it. -fn push_centered_more_icon( - quads: &mut Vec, - rect: (f32, f32, f32, f32), - scale: f32, - color: Rgb, -) { - let (x, y, width, height) = rect; - if width <= 0.0 || height <= 0.0 { - return; - } - - // 三点与 14px 加号使用同一视觉规格:稍大的圆点负责补足视觉重量, - // 4.2px 中心距让整枚图标保持紧凑,但不再像原来的 2.2px 圆点那样发虚。 - let diameter = (2.8 * scale).max(2.0); - let gap = 4.2 * scale; - let center_x = x + width * 0.5; - let center_y = y + height * 0.5; - let ink = Rgba::new(color.r, color.g, color.b, 235); - for offset in [-gap, 0.0, gap] { - quads.push(UiQuad::solid( - center_x - diameter * 0.5, - center_y + offset - diameter * 0.5, - diameter, - diameter, - diameter * 0.5, - ink, - )); - } -} - -fn push_icon_segment( - quads: &mut Vec, - from: (f32, f32), - to: (f32, f32), - thickness: f32, - color: Rgba, -) { - let dx = to.0 - from.0; - let dy = to.1 - from.1; - let len = dx.hypot(dy); - if len <= f32::EPSILON { - return; - } - let px = -dy / len * thickness * 0.5; - let py = dx / len * thickness * 0.5; - quads.push(UiQuad::poly( - [ - [from.0 - px, from.1 - py], - [from.0 + px, from.1 + py], - [to.0 - px, to.1 - py], - [to.0 + px, to.1 + py], - ], - color, - color, - Gradient::None, - )); -} - -/// Draw Windows-style caption marks as native vector strokes. Font glyphs use -/// different outline boxes for minus / square / close, which made the three -/// buttons look unrelated and drift at fractional DPI. -fn push_window_control_icon( - quads: &mut Vec, - hit: ChromeHit, - center_x: f32, - center_y: f32, - scale: f32, - color: Rgb, -) { - let ink = Rgba::new(color.r, color.g, color.b, 245); - let half = 5.0 * scale; - let stroke = (1.25 * scale).max(1.0); - match hit { - ChromeHit::Minimize => { - // Windows places the minus slightly below the mathematical centre. - let y = center_y + 3.0 * scale; - quads.push(UiQuad::solid( - center_x - half, - y - stroke * 0.5, - half * 2.0, - stroke, - stroke * 0.5, - ink, - )); - }, - ChromeHit::Maximize => { - let left = center_x - half; - let top = center_y - half; - quads.push(UiQuad::solid(left, top, half * 2.0, stroke, 0.0, ink)); - quads.push(UiQuad::solid( - left, - top + half * 2.0 - stroke, - half * 2.0, - stroke, - 0.0, - ink, - )); - quads.push(UiQuad::solid(left, top, stroke, half * 2.0, 0.0, ink)); - quads.push(UiQuad::solid( - left + half * 2.0 - stroke, - top, - stroke, - half * 2.0, - 0.0, - ink, - )); - }, - ChromeHit::Close => { - push_icon_segment( - quads, - (center_x - half, center_y - half), - (center_x + half, center_y + half), - stroke, - ink, - ); - push_icon_segment( - quads, - (center_x + half, center_y - half), - (center_x - half, center_y + half), - stroke, - ink, - ); - }, - _ => {}, - } -} +// Icon geometry (add / more / caption marks / sidebar toggle / chevrons) +// lives in `display::icons` — a pure rectangle-in, quads-out layer with no +// Display state, so drawing can never drift from hit-testing. pub(super) fn chrome_hit_with_tabs( size_info: &SizeInfo, @@ -673,11 +497,8 @@ pub(super) fn chrome_hit_with_tabs( if contains_rect(layout.plus, x, y) { return ChromeHit::NewTab; } - if message_queue_entry::contains( - message_queue_entry::layout(layout.panel, scale_factor), - x, - y, - ) { + if message_queue_entry::contains(message_queue_entry::layout(layout.panel, scale_factor), x, y) + { return ChromeHit::MessageQueue; } if contains_rect(layout.hosts_add, x, y) { @@ -847,6 +668,7 @@ pub(super) fn draw_chrome(d: &mut Display) { // smoke on the light themes); close-hover red stays semantic. let palette = d.nebula_theme.palette(); let sk = d.nebula_theme.skin(); + let restore_window = d.window.is_maximized() || d.window.is_fullscreen(); let language = d.ui_language(); #[allow(non_snake_case)] let (HOVER_FILL, HOVER_FILL_STRONG) = (sk.hover, sk.hover_strong); @@ -854,7 +676,9 @@ pub(super) fn draw_chrome(d: &mut Display) { // translucent red reads like a generic pill instead of a window control. const CLOSE_HOVER_FILL: Rgba = Rgba::new(232, 17, 35, 242); - let size = d.size_info; + // UI 布局用 ui 版 SizeInfo:cell 尺寸按 UI 锚定比率补偿,与按同一比 + // 率栅格化的 chrome 文本同源;终端缩放因此不影响侧栏/顶栏/设置布局。 + let size = d.ui_size_info(); let scale = d.window.scale_factor as f32; let w = size.width(); let h = size.height(); @@ -872,9 +696,82 @@ pub(super) fn draw_chrome(d: &mut Display) { let mut quads: Vec = Vec::new(); - // ---- Background ambient light (very subtle, drawn first) ---- + // ---- Unified shell frame (一体化外壳,2026-07-23 用户裁定) ---- + // 顶栏、侧栏与终端卡四周的边框是同一块玻璃:卡以外的整个窗口区域由 + // 4 条硬边条带 + 4 个凹角块铺出恰好一层壳色,每个像素只涂一次。旧 + // 分层感的根源(不透明清屏底上又叠半透明面板,叠加区更实、gap 区 + // 更透)从结构上消除——清屏现在全透明,见 `draw_window_backdrop`。 + // + // 壳色 = panel 在 shell_bg 上的预合成(保住面板 token 的调子), + // alpha 直接取用户透明度:滑块驱动整个外壳作为一体变化;cover 壁纸 + // 时壳浮在壁纸上,半透明壳整体透出壁纸。文字与图标保持不透明。 + let shell_r = s(UI_SHELL_RADIUS_LOGICAL); + let sidebar_expand = d.left_sidebar_progress(); + let top_y = margin; + let shell_alpha = surface_opacity::SurfaceOpacityPolicy::new(d.nebula_window_opacity).chrome; + let shell_bg = palette.shell_bg; + let pa = palette.panel.a as f32 / 255.0; + let comp = |p: u8, b: u8| (p as f32 * pa + b as f32 * (1.0 - pa)).round() as u8; + let shell_color = Rgba::new( + comp(palette.panel.r, shell_bg.r), + comp(palette.panel.g, shell_bg.g), + comp(palette.panel.b, shell_bg.b), + (shell_alpha * 255.0).round().clamp(0.0, 255.0) as u8, + ); + let (card_x, card_y, card_w, card_h) = d.terminal_card_rect(); + let card_r = shell_r.round().min(card_w * 0.5).min(card_h * 0.5); + // 条带贴卡的直边;凹角块叠在卡矩形四角的 r×r 区域上,只涂圆弧以外 + // 的部分。凹角的圆与卡片自身圆角同心同径,弧线两侧 coverage 互补相 + // 加为 1,半透明壳下也不会出现更实或漏底的接缝。 + quads.push(UiQuad::solid(0.0, 0.0, w, card_y, 0.0, shell_color)); + quads.push(UiQuad::solid(0.0, card_y, card_x, card_h, 0.0, shell_color)); + quads.push(UiQuad::solid( + card_x + card_w, + card_y, + (w - card_x - card_w).max(0.0), + card_h, + 0.0, + shell_color, + )); + quads.push(UiQuad::solid( + 0.0, + card_y + card_h, + w, + (h - card_y - card_h).max(0.0), + 0.0, + shell_color, + )); + if card_r > 0.0 && card_w > 0.0 && card_h > 0.0 { + quads.push(UiQuad::concave_corner(card_x, card_y, card_r, 0, shell_color)); + quads.push(UiQuad::concave_corner( + card_x + card_w - card_r, + card_y, + card_r, + 1, + shell_color, + )); + quads.push(UiQuad::concave_corner( + card_x + card_w - card_r, + card_y + card_h - card_r, + card_r, + 2, + shell_color, + )); + quads.push(UiQuad::concave_corner( + card_x, + card_y + card_h - card_r, + card_r, + 3, + shell_color, + )); + } + + // ---- Background ambient light ---- // Purple bloom in the lower-left, cool blue in the upper-right, giving // the flat backdrop a sense of depth without competing with content. + // Drawn ON TOP of the shell frame: the shell is exactly one layer now, + // so ambience is added over it instead of shining through from a layer + // below (which would vanish at 100% opacity anyway). // Light themes ship zero-alpha glows (8-bit banding on pale ground) — // skip the fill-rate cost entirely. let glow_r = w * 0.62; @@ -897,26 +794,6 @@ pub(super) fn draw_chrome(d: &mut Display) { )); } - // ---- Top title / tab bar ---- - // The top bar and the left sidebar form one connected chrome shell (an - // L-frame): the top bar's bottom-left corner is squared so the sidebar - // abuts it seamlessly, while the three outer corners keep the larger shell - // radius. Collapsed (no sidebar) the bar is a standalone card, all four - // corners shell-rounded. - let shell_r = s(UI_SHELL_RADIUS_LOGICAL); - let sidebar_expand = d.left_sidebar_progress(); - let top_y = margin; - // The bottom-left corner closes toward square as the sidebar slides in, so - // the join seals smoothly instead of the corner popping from round to square - // the instant the panel becomes visible. `expand` is 0 collapsed → 1 open. - let join_r = shell_r * (1.0 - sidebar_expand.clamp(0.0, 1.0)); - // [top-left, top-right, bottom-right, bottom-left] - let top_bar_corners = [shell_r, shell_r, shell_r, join_r]; - quads.push( - UiQuad::solid(margin, top_y, w - 2.0 * margin, bar_h, radius, palette.panel) - .with_corners(top_bar_corners), - ); - let tab_layout = chrome_tab_layout(&size, scale, d.sidebar_model(), sidebar_expand); // Sidebar toggle at the far left of the top bar folds the tab sidebar @@ -927,30 +804,13 @@ pub(super) fn draw_chrome(d: &mut Display) { quads.push(UiQuad::solid(tog_x, tog_y, tog_w, tog_h, pill_r, HOVER_FILL_STRONG)); } let icon_c = if toggle_hovered { sk.icon_hover } else { sk.icon }; - let line = Rgba::new(icon_c.r, icon_c.g, icon_c.b, 185); - let cutout = Rgba::new(palette.panel.r, palette.panel.g, palette.panel.b, 255); - let ix = tog_x + (tog_w - s(15.0)) * 0.5; - let iy = tog_y + (tog_h - s(15.0)) * 0.5; - let iw = s(15.0); - let ih = s(15.0); - let stroke = s(1.35).max(1.0); - quads.push(UiQuad::solid(ix, iy, iw, ih, s(3.2), line)); - quads.push(UiQuad::solid( - ix + stroke, - iy + stroke, - iw - 2.0 * stroke, - ih - 2.0 * stroke, - s(2.2), - cutout, - )); - quads.push(UiQuad::solid( - ix + s(5.3), - iy + stroke, - stroke, - ih - 2.0 * stroke, - stroke * 0.5, - line, - )); + icons::push_sidebar_toggle( + &mut quads, + tab_layout.toggle, + scale, + Rgba::new(icon_c.r, icon_c.g, icon_c.b, 185), + Rgba::new(palette.panel.r, palette.panel.g, palette.panel.b, 255), + ); // Settings moved into the old product-mark slot. let (set_x, set_y, set_w, set_h) = chrome_settings_button_rect(&size, scale); @@ -959,28 +819,10 @@ pub(super) fn draw_chrome(d: &mut Display) { quads.push(UiQuad::solid(set_x, set_y, set_w, set_h, pill_r, HOVER_FILL_STRONG)); } - // Sidebar panel background — the left leg of the chrome shell. Its top - // corners are squared so it abuts the top bar's squared bottom-left into - // one continuous L; only the two outer bottom corners keep the shell - // radius. Draws through slide-out so folding stays visible. - // - // Seam seal: the sidebar is painted AFTER the top bar and in the same - // panel color, so we extend its top edge UP by a couple of physical pixels - // to tuck under the top bar. Without this, the top bar's bottom edge and - // the sidebar's top edge each carry a 1px antialiasing falloff; where two - // translucent edges merely abut, both are under-covered and the dark - // background bleeds through as a hairline "black edge". Overlapping turns - // that transition into panel-over-panel (≈1.5% delta, invisible) and the - // gap is gone. - if d.left_sidebar_visible() { - let (pnl_x, pnl_y, pnl_w, pnl_h) = tab_layout.panel; - let seam = s(2.0).max(1.0); - quads.push( - UiQuad::solid(pnl_x, pnl_y - seam, pnl_w, pnl_h + seam, radius, palette.panel) - // [top-left, top-right, bottom-right, bottom-left] - .with_corners([0.0, 0.0, shell_r, shell_r]), - ); - } + // Sidebar/top-bar panel fills are gone: the unified shell frame above + // already covers everything outside the terminal card in exactly one + // layer. A per-panel fill here would stack a second alpha pass and + // re-create the "solid panel on translucent border" split it replaced. // Dock preview: while a dragged tab hovers the terminal area, glow the // half where dropping would split the active tab (VS Code edge dock). @@ -1222,18 +1064,25 @@ pub(super) fn draw_chrome(d: &mut Display) { if tabs_plus_visible { let plus_ink = if d.nebula_chrome_hover == ChromeHit::NewTab { sk.icon_hover } else { sk.icon }; - push_centered_add_icon(&mut quads, tab_layout.plus, scale, plus_ink); + icons::push_add( + &mut quads, + tab_layout.plus, + scale, + Rgba::new(plus_ink.r, plus_ink.g, plus_ink.b, 235), + ); } // The dropdown chevron beside "+": same hover-only lift. let (menu_x, menu_y, menu_w, menu_h) = tab_layout.menu; if d.nebula_chrome_hover == ChromeHit::NewTabMenu { quads.push(UiQuad::solid(menu_x, menu_y, menu_w, menu_h, pill_r, HOVER_FILL_STRONG)); } - push_centered_more_icon( + let more_ink = + if d.nebula_chrome_hover == ChromeHit::NewTabMenu { sk.icon_hover } else { sk.icon }; + icons::push_more( &mut quads, tab_layout.menu, scale, - if d.nebula_chrome_hover == ChromeHit::NewTabMenu { sk.icon_hover } else { sk.icon }, + Rgba::new(more_ink.r, more_ink.g, more_ink.b, 235), ); // SSH HOSTS section (quad layer): hover fill per row + the per-section @@ -1252,15 +1101,16 @@ pub(super) fn draw_chrome(d: &mut Display) { quads.push(UiQuad::solid(ax, ay, aw, ah, s(6.0), HOVER_FILL_STRONG)); } if matches!(d.nebula_chrome_hover, ChromeHit::HostsSection | ChromeHit::AddSshHost) { - push_centered_add_icon( + let add_ink = if d.nebula_chrome_hover == ChromeHit::AddSshHost { + sk.icon_hover + } else { + sk.icon + }; + icons::push_add( &mut quads, tab_layout.hosts_add, scale, - if d.nebula_chrome_hover == ChromeHit::AddSshHost { - sk.icon_hover - } else { - sk.icon - }, + Rgba::new(add_ink.r, add_ink.g, add_ink.b, 235), ); } let thumb_c = sk.scrollbar_thumb; @@ -1270,6 +1120,35 @@ pub(super) fn draw_chrome(d: &mut Display) { } } + // Directory/Git drawer controls remain compact pills and keep their hover + // feedback separate from the contiguous Windows caption-button band. + for (hit, cx, cy) in chrome_control_centers(w, top_y, bar_h, scale) { + if !matches!(hit, ChromeHit::PanelFiles | ChromeHit::PanelGit) { + continue; + } + let active = match hit { + ChromeHit::PanelFiles => { + d.nebula_side_panel.open && d.nebula_side_panel.view == side_panel::PanelView::Files + }, + ChromeHit::PanelGit => { + d.nebula_side_panel.open && d.nebula_side_panel.view == side_panel::PanelView::Git + }, + _ => false, + }; + if d.nebula_chrome_hover == hit || active { + let button_w = s(34.0); + let button_h = pill_h; + quads.push(UiQuad::solid( + cx - button_w / 2.0, + cy - button_h / 2.0, + button_w, + button_h, + pill_r, + if active { sk.accent_soft } else { HOVER_FILL_STRONG }, + )); + } + } + // The three caption buttons form one continuous Windows-style band: no // inset pill and no dead strip at the right edge. Hit-testing separately // extends the same horizontal rects to the physical top edge. @@ -1277,31 +1156,46 @@ pub(super) fn draw_chrome(d: &mut Display) { if !is_window_control(hit) { continue; } + let _ = cy; let hovered = d.nebula_chrome_hover == hit; + let visual = window_control_visual_rect(cx, top_y, bar_h, scale); + let hover_fill = + if hit == ChromeHit::Close { CLOSE_HOVER_FILL } else { HOVER_FILL_STRONG }; if hovered { - let (x, y, width, height) = window_control_visual_rect(cx, top_y, bar_h, scale); - quads.push(UiQuad::solid( - x, - y, - width, - height, - 0.0, - if hit == ChromeHit::Close { CLOSE_HOVER_FILL } else { HOVER_FILL_STRONG }, - )); + quads.push(UiQuad::solid(visual.0, visual.1, visual.2, visual.3, 0.0, hover_fill)); } - push_window_control_icon( - &mut quads, - hit, - cx, - cy, - scale, - if hit == ChromeHit::Close && hovered { - Rgb::new(255, 255, 255) - } else if hovered { - sk.icon_hover - } else { - sk.icon + // The glyph centres on the VISIBLE band (physical top edge → bar + // bottom), not on the inset title bar — the old inset centre left the + // marks riding ~4px low inside the full-height buttons. + let icon_cy = visual.1 + visual.3 * 0.5; + let ink_rgb = if hit == ChromeHit::Close && hovered { + Rgb::new(255, 255, 255) + } else if hovered { + sk.icon_hover + } else { + sk.icon + }; + // The rounded caption squares hollow their interior with the button's + // effective surface color. Composited opaquely: on a translucent shell + // the tiny interior reads as the shell tint, which is invisible at + // everyday opacities and far cheaper than a stroked-quad shader. + let base = Rgba::new(palette.panel.r, palette.panel.g, palette.panel.b, 255); + let cutout = if hovered { icons::blend_over(base, hover_fill) } else { base }; + let kind = match hit { + ChromeHit::Minimize => icons::WindowControlIcon::Minimize, + ChromeHit::Maximize => { + icons::WindowControlIcon::Maximize { restore: restore_window } }, + _ => icons::WindowControlIcon::Close, + }; + icons::push_window_control( + &mut quads, + kind, + cx, + icon_cy, + scale, + Rgba::new(ink_rgb.r, ink_rgb.g, ink_rgb.b, 245), + cutout, ); } @@ -1369,15 +1263,6 @@ pub(super) fn draw_chrome(d: &mut Display) { settings::push_quads(&d.settings_view(), &mut quads, &size, scale); } - // Nebula command palette floats above the chrome; add its quads last. - command_palette::push_quads( - &d.nebula_palette, - &d.nebula_theme, - &mut quads, - &d.size_info, - d.window.scale_factor as f32, - ); - // Paint the panels and pills first. d.renderer.draw_ui(&size, &quads); @@ -1405,6 +1290,22 @@ pub(super) fn draw_chrome(d: &mut Display) { fg: Rgb, icon: &str, ) { + let mut chars = icon.chars(); + let (first, second) = (chars.next(), chars.next()); + // Single-glyph marks center on the REAL rasterized ink: Nerd icons + // ink well outside their 1-column advance, which is why grid-based + // centering left the gear/folder/git marks visibly off inside their + // hover pills. + if let (Some(character), None) = (first, second) { + if let Some((left, top, width, height)) = + renderer.chrome_glyph_ink(glyph_cache, size, character) + { + let x = rect.0 + (rect.2 - width) / 2.0 - left; + let y = rect.1 + (rect.3 - height) / 2.0 - top; + renderer.draw_chrome_text(size, x, y, fg, icon, glyph_cache); + return; + } + } let cols = icon.chars().map(|ch| ch.width().unwrap_or(1)).sum::().max(1); let x = rect.0 + (rect.2 - cols as f32 * cell_w) / 2.0; let y = rect.1 + (rect.3 - cell_h) / 2.0; @@ -1839,6 +1740,34 @@ pub(super) fn draw_chrome(d: &mut Display) { // Settings tab text labels, above its quads. if d.nebula_settings_open { let view = d.settings_view(); + // Appearance 预览卡回显真实壁纸(同一 fit / 对齐 / 不透明度):预 + // 览底色 quad 已在主 pass 画过,这里在示例文字之前叠图——文字仍 + // 浮在壁纸上,所见即所得。 + if view.section == settings::NebulaSettingsSection::Appearance { + if let Some(wallpaper) = d.nebula_background_image.clone() { + let trimmed = wallpaper.trim().trim_matches('"'); + if !trimmed.is_empty() { + if let Some((target, clip)) = settings::appearance_preview_wallpaper_rects( + &size, + scale, + view.area, + view.scroll, + view.hidden_hosts.len(), + ) { + d.renderer.draw_background_image( + &size, + std::path::Path::new(trimmed), + d.nebula_background_image_opacity, + d.nebula_background_image_fit, + d.nebula_background_image_alignment, + target, + clip, + s(10.0), + ); + } + } + } + } let settings_shell_icons = settings::draw_text(&view, &mut d.renderer, &mut d.glyph_cache, &size, scale); for (shell_id, rect) in settings_shell_icons { @@ -1846,6 +1775,26 @@ pub(super) fn draw_chrome(d: &mut Display) { d.nebula_chrome_logo_draws.push((id, rgba, px, rect)); } } + // The expanded combobox floats ABOVE the page text: popup quads + // first, then its option labels — the same base → base-text → + // overlay → overlay-text layering the command palette needs. + let mut popup_quads = Vec::new(); + settings::push_popup_quads(&view, &mut popup_quads, &size, scale); + if !popup_quads.is_empty() { + d.renderer.draw_ui(&size, &popup_quads); + let popup_icons = settings::draw_popup_text( + &view, + &mut d.renderer, + &mut d.glyph_cache, + &size, + scale, + ); + for (shell_id, rect) in popup_icons { + if let Some((id, rgba, px)) = d.shell_icon_pixels(&shell_id) { + d.nebula_chrome_logo_draws.push((id, rgba, px, rect)); + } + } + } } if d.left_sidebar_visible() { @@ -1861,7 +1810,6 @@ pub(super) fn draw_chrome(d: &mut Display) { ); } - // Palette text (query + result rows) sits on top of every chrome label. // Drawer text remains in its own shell region beside the Settings page. if d.side_panel_visible() { if let Some(panel) = d.nebula_sftp_panel.as_ref() { @@ -1900,6 +1848,21 @@ pub(super) fn draw_chrome(d: &mut Display) { ); } } + + // A modal must be composited as a complete layer after every base label: + // base quads → base text → palette quads → palette text. Painting the + // palette panel in the first quad batch allowed Settings text to overwrite + // it and produced the reported two-layer ghosting. + let mut palette_quads = Vec::new(); + command_palette::push_quads( + &d.nebula_palette, + &d.nebula_theme, + &mut palette_quads, + &d.size_info, + d.window.scale_factor as f32, + ); + d.renderer.draw_ui(&size, &palette_quads); + let shell_icon_draws = command_palette::draw_text( &d.nebula_palette, &d.nebula_theme, @@ -1969,7 +1932,7 @@ mod resize_edge_tests { assert_eq!(rects[0].0 + rects[0].2, rects[1].0); assert_eq!(rects[1].0 + rects[1].2, rects[2].0); assert_eq!(rects[2].0 + rects[2].2, width); - assert!(rects.iter().all(|rect| rect.1 == 8.0 && rect.3 == 40.0)); + assert!(rects.iter().all(|rect| rect.1 == 0.0 && rect.3 == 48.0)); } #[test] diff --git a/nebula_app/src/display/command_palette.rs b/nebula_app/src/display/command_palette.rs index d0c94df..b60e818 100644 --- a/nebula_app/src/display/command_palette.rs +++ b/nebula_app/src/display/command_palette.rs @@ -489,9 +489,14 @@ impl CommandPalette { } /// Update hover based on mouse position. `row` is the index within the - /// visible window (`0..max_rows`), or `None` when the mouse left. - pub fn set_hover(&mut self, row: Option) { + /// visible window (`0..max_rows`), or `None` when the mouse left. Returns + /// whether the hover changed, so the caller only redraws on transitions. + pub fn set_hover(&mut self, row: Option) -> bool { + if self.hover == row { + return false; + } self.hover = row; + true } /// The number of filtered results currently shown. diff --git a/nebula_app/src/display/content.rs b/nebula_app/src/display/content.rs index f0987a1..567fad7 100644 --- a/nebula_app/src/display/content.rs +++ b/nebula_app/src/display/content.rs @@ -351,6 +351,9 @@ impl RenderableCell { (colors.hints.end.foreground, colors.hints.end.background); Self::compute_cell_rgb(&mut fg, &mut bg, &mut bg_alpha, config_fg, config_bg); } else { + // 2026-07-23 用户裁定:hover 反馈统一用单下划线——已带下 + // 划线的链接 hover 时保持直线,不再升级双下划线。 + flags.remove(Flags::ALL_UNDERLINES); flags.insert(Flags::UNDERLINE); } diff --git a/nebula_app/src/display/icons.rs b/nebula_app/src/display/icons.rs new file mode 100644 index 0000000..203ab57 --- /dev/null +++ b/nebula_app/src/display/icons.rs @@ -0,0 +1,298 @@ +//! Vector icon geometry for the chrome and settings surfaces. +//! +//! Every function here is a pure geometry producer: rectangle/center in, +//! [`UiQuad`]s out. No `Display` state, no hover logic, no theme lookups — +//! the caller decides colors (including hover ink) and passes them in. This +//! keeps drawing, hit-testing and theming in separate layers, so an icon can +//! never drift from its hit rect or secretly depend on renderer state. +//! +//! Font glyphs are deliberately NOT used for these marks: private-use glyph +//! outlines differ per font and drift at fractional DPI, which is what made +//! the caption buttons look unrelated before the vector rewrite. + +use crate::renderer::ui::{Gradient, Rgba, UiQuad}; + +/// Blend `top` over `base` with straight alpha and return the resulting +/// opaque color. Used to compute the *effective* surface color under an icon +/// so cutout-style marks (rounded outlines) can "erase" their interior even +/// though the quad pipeline has no stencil or stroke primitive. +pub(crate) fn blend_over(base: Rgba, top: Rgba) -> Rgba { + let alpha = top.a as f32 / 255.0; + let mix = |b: u8, t: u8| (b as f32 * (1.0 - alpha) + t as f32 * alpha).round() as u8; + Rgba::new(mix(base.r, top.r), mix(base.g, top.g), mix(base.b, top.b), base.a.max(top.a)) +} + +/// One straight stroke between two points, rendered as a thin quad. The +/// building block for X marks, chevrons and check marks. +pub(crate) fn push_segment( + quads: &mut Vec, + from: (f32, f32), + to: (f32, f32), + thickness: f32, + color: Rgba, +) { + let dx = to.0 - from.0; + let dy = to.1 - from.1; + let len = dx.hypot(dy); + if len <= f32::EPSILON { + return; + } + let px = -dy / len * thickness * 0.5; + let py = dx / len * thickness * 0.5; + quads.push(UiQuad::poly( + [ + [from.0 - px, from.1 - py], + [from.0 + px, from.1 + py], + [to.0 - px, to.1 - py], + [to.0 + px, to.1 + py], + ], + color, + color, + Gradient::None, + )); +} + +/// Draw the "+" (new tab) mark centered in `rect`. +/// +/// 图标墨迹不能跟随命中区缩放:侧栏收起时按钮是 28px,展开时是 20px,若从 +/// rect 推导尺寸,同一个"新建 Tab"图标会在两种状态间忽大忽小。固定视觉 +/// 尺寸,同时保留调用方的舒适命中区域。 +pub(crate) fn push_add(quads: &mut Vec, rect: (f32, f32, f32, f32), scale: f32, ink: Rgba) { + let (x, y, width, height) = rect; + if width <= 0.0 || height <= 0.0 { + return; + } + let center_x = x + width * 0.5; + let center_y = y + height * 0.5; + let stroke = (1.5 * scale).max(1.0); + let arm = 7.0 * scale; + quads.push(UiQuad::solid( + center_x - arm, + center_y - stroke * 0.5, + arm * 2.0, + stroke, + stroke * 0.5, + ink, + )); + quads.push(UiQuad::solid( + center_x - stroke * 0.5, + center_y - arm, + stroke, + arm * 2.0, + stroke * 0.5, + ink, + )); +} + +/// Vertical three-dot "more" mark centered in `rect`. Shares the "+" mark's +/// visual weight so the pair reads as one control family. +pub(crate) fn push_more(quads: &mut Vec, rect: (f32, f32, f32, f32), scale: f32, ink: Rgba) { + let (x, y, width, height) = rect; + if width <= 0.0 || height <= 0.0 { + return; + } + let diameter = (2.8 * scale).max(2.0); + let gap = 4.2 * scale; + let center_x = x + width * 0.5; + let center_y = y + height * 0.5; + for offset in [-gap, 0.0, gap] { + quads.push(UiQuad::solid( + center_x - diameter * 0.5, + center_y + offset - diameter * 0.5, + diameter, + diameter, + diameter * 0.5, + ink, + )); + } +} + +/// A rounded-rectangle outline built as an ink plate with a `cutout`-colored +/// interior. The quad pipeline has no stroke primitive, so the caller must +/// pass the effective surface color behind the icon (see [`blend_over`]). +fn push_rounded_outline( + quads: &mut Vec, + (x, y, size): (f32, f32, f32), + radius: f32, + stroke: f32, + ink: Rgba, + cutout: Rgba, +) { + quads.push(UiQuad::solid(x, y, size, size, radius, ink)); + quads.push(UiQuad::solid( + x + stroke, + y + stroke, + size - 2.0 * stroke, + size - 2.0 * stroke, + (radius - stroke).max(0.0), + cutout, + )); +} + +/// Which caption mark to draw. `Maximize { restore: true }` is the two +/// offset squares shown while the window is maximized or fullscreen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WindowControlIcon { + Minimize, + Maximize { restore: bool }, + Close, +} + +/// Windows 11-style caption marks: gray strokes, and the maximize/restore +/// squares carry soft rounded corners (the reference caption buttons), not +/// hard 90° outlines. `cutout` must be the button's effective background so +/// the square interiors stay "empty" on hover fills too. +pub(crate) fn push_window_control( + quads: &mut Vec, + icon: WindowControlIcon, + center_x: f32, + center_y: f32, + scale: f32, + ink: Rgba, + cutout: Rgba, +) { + let half = 5.0 * scale; + let stroke = (1.25 * scale).max(1.0); + match icon { + WindowControlIcon::Minimize => { + // 与最大化/关闭图标同一水平中线(2026-07-23 用户裁定:三大键 + // 水平对齐;不再模仿 Windows 的下沉减号)。 + let y = center_y; + quads.push(UiQuad::solid( + center_x - half, + y - stroke * 0.5, + half * 2.0, + stroke, + stroke * 0.5, + ink, + )); + }, + WindowControlIcon::Maximize { restore: false } => { + push_rounded_outline( + quads, + (center_x - half, center_y - half, half * 2.0), + 2.0 * scale, + stroke, + ink, + cutout, + ); + }, + WindowControlIcon::Maximize { restore: true } => { + // Chrome/Windows restore mark: the back square is shifted up and + // right, the front square down and left. Painting back-then-front + // lets the front square's cutout erase the overlap, which is + // exactly the native two-pane silhouette. + let size = 8.0 * scale; + let radius = 1.6 * scale; + push_rounded_outline( + quads, + (center_x - 2.0 * scale, center_y - 5.0 * scale, size), + radius, + stroke, + ink, + cutout, + ); + push_rounded_outline( + quads, + (center_x - 5.0 * scale, center_y - 2.0 * scale, size), + radius, + stroke, + ink, + cutout, + ); + }, + WindowControlIcon::Close => { + push_segment( + quads, + (center_x - half, center_y - half), + (center_x + half, center_y + half), + stroke, + ink, + ); + push_segment( + quads, + (center_x + half, center_y - half), + (center_x - half, center_y + half), + stroke, + ink, + ); + }, + } +} + +/// Sidebar fold toggle: a rounded window outline with a vertical divider. +/// `cutout` hollows the frame (same technique as the caption squares). +pub(crate) fn push_sidebar_toggle( + quads: &mut Vec, + rect: (f32, f32, f32, f32), + scale: f32, + line: Rgba, + cutout: Rgba, +) { + let (x, y, width, height) = rect; + let iw = 15.0 * scale; + let ih = 15.0 * scale; + let ix = x + (width - iw) * 0.5; + let iy = y + (height - ih) * 0.5; + let stroke = (1.35 * scale).max(1.0); + quads.push(UiQuad::solid(ix, iy, iw, ih, 3.2 * scale, line)); + quads.push(UiQuad::solid( + ix + stroke, + iy + stroke, + iw - 2.0 * stroke, + ih - 2.0 * stroke, + 2.2 * scale, + cutout, + )); + quads.push(UiQuad::solid( + ix + 5.3 * scale, + iy + stroke, + stroke, + ih - 2.0 * stroke, + stroke * 0.5, + line, + )); +} + +/// Dropdown chevron pointing down (`up = false`) or up, centered on +/// (`center_x`, `center_y`). Used by comboboxes and the font-size spinner. +pub(crate) fn push_chevron( + quads: &mut Vec, + center_x: f32, + center_y: f32, + scale: f32, + ink: Rgba, + up: bool, +) { + let arm = 3.9 * scale; + let drop = 2.1 * scale; + let stroke = (1.4 * scale).max(1.0); + let (from_y, mid_y) = if up { (center_y + drop, center_y - drop) } else { (center_y - drop, center_y + drop) }; + push_segment(quads, (center_x - arm, from_y), (center_x, mid_y), stroke, ink); + push_segment(quads, (center_x, mid_y), (center_x + arm, from_y), stroke, ink); +} + +/// Check mark for selected dropdown rows, centered on (`center_x`, `center_y`). +pub(crate) fn push_check( + quads: &mut Vec, + center_x: f32, + center_y: f32, + scale: f32, + ink: Rgba, +) { + let stroke = (1.5 * scale).max(1.0); + push_segment( + quads, + (center_x - 4.2 * scale, center_y + 0.2 * scale), + (center_x - 1.2 * scale, center_y + 3.2 * scale), + stroke, + ink, + ); + push_segment( + quads, + (center_x - 1.2 * scale, center_y + 3.2 * scale), + (center_x + 4.6 * scale, center_y - 3.4 * scale), + stroke, + ink, + ); +} diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 1582b8c..1cb4afc 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -58,6 +58,7 @@ use crate::display::window::Window; use crate::event::{Event, EventType, Mouse, SearchState}; use crate::message_bar::{self, MessageBuffer, MessageType}; use crate::renderer::Rasterizer; +use crate::renderer::image::{BackgroundImageAlignment, BackgroundImageFit}; use crate::renderer::rects::{RenderLine, RenderLines, RenderRect}; use crate::renderer::ui::{Gradient, Rgba, UiQuad}; use crate::renderer::{self, GlyphCache, Renderer, platform}; @@ -79,10 +80,13 @@ pub mod markdown_view; mod message_queue_entry; pub mod sftp_panel; pub mod side_panel; +mod icons; mod size_info; mod state; +mod surface_opacity; mod terminal_color; mod terminal_math; +mod widgets; pub(crate) use chrome::chrome_settings_button_rect; pub use chrome::{ChromeHit, TabDropAction, in_chrome_bar, resize_edge}; @@ -123,7 +127,37 @@ pub(crate) fn caret_blink_on() -> bool { .unwrap_or(0); (millis / 500) % 2 == 0 } -pub use settings::{NebulaSettingsSection, SettingsHit, settings_hit}; +pub use settings::{NebulaSettingsSection, SettingsDropdown, SettingsHit, settings_hit}; +pub(crate) use settings::SettingsOpacityTarget; + +/// 按显示列宽贪心断行(确认框正文等 UI 段落用):CJK 逐字可断,行首空 +/// 格吞掉;零宽字符跟随前一个字。不做拉丁连词回退——正文以中文为主, +/// 偶发的英文单词被折断可接受。 +fn wrap_display_cols(text: &str, max_cols: usize) -> Vec { + let mut lines = Vec::new(); + let mut line = String::new(); + let mut cols = 0usize; + for ch in text.chars() { + let w = ch.width().unwrap_or(0); + if w == 0 { + line.push(ch); + continue; + } + if cols + w > max_cols && cols > 0 { + lines.push(std::mem::take(&mut line)); + cols = 0; + if ch == ' ' { + continue; + } + } + line.push(ch); + cols += w; + } + if !line.is_empty() || lines.is_empty() { + lines.push(line); + } + lines +} mod bell; mod damage; @@ -1069,6 +1103,9 @@ pub struct Display { /// geometry or input contracts again. nebula_message_queue_entry: message_queue_entry::MessageQueueEntry, nebula_settings_hover: SettingsHit, + /// Active settings opacity drag: target plus the screen-space track used + /// for pointer-to-value mapping. Values persist only when the drag ends. + pub nebula_settings_opacity_drag: Option<(settings::SettingsOpacityTarget, f32, f32)>, /// Unified native right-click menu shared by tab and SSH rows. The menu /// owns its short open/close animation so no input path needs timers. nebula_context_menu: Option, @@ -1149,11 +1186,16 @@ pub struct Display { pub nebula_keep_session: bool, /// Runtime window opacity controlled from Nebula settings. pub nebula_window_opacity: f32, - /// Whether the default-shell picker is expanded (inline list below the row). - pub nebula_shell_picker_open: bool, - /// Inline terminal-font picker; imported faces live in Nebula's private - /// DirectWrite collection and do not require system installation. - pub nebula_font_picker_open: bool, + /// Which settings combobox (floating option list) is expanded, if any. + /// One field for every dropdown: shell, font, wallpaper fit/alignment, + /// language, accept key and cursor shape all share the widget. + pub nebula_settings_dropdown: Option, + /// Default cursor shape/blink from settings. Programs may still override + /// the shape with DECSCUSR escapes (vim's mode cursor keeps working). + pub nebula_cursor_shape: CursorShape, + pub nebula_cursor_blink: bool, + /// 交互: WT-style copyOnSelect. Off = right-click copies / pastes instead. + pub nebula_copy_on_select: bool, pub nebula_font_family: String, nebula_font_families: Vec, nebula_font_notice: Option, @@ -1163,8 +1205,17 @@ pub struct Display { pub nebula_background_image: Option, /// Wallpaper alpha, separate from the window opacity to preserve text contrast. pub nebula_background_image_opacity: f32, + /// Windows Terminal-compatible wallpaper sizing and anchor settings. + pub nebula_background_image_fit: BackgroundImageFit, + pub nebula_background_image_alignment: BackgroundImageAlignment, + /// Off by default: wallpapers stay inside terminal content. Enabling this + /// requires an explicit warning confirmation because it reduces chrome contrast. + pub nebula_background_image_cover_chrome: bool, nebula_settings_mtime: Option, nebula_bg_palette_index: usize, + /// 背景色浮层的 16 进制草稿与聚焦态(浮层关闭时归零)。 + nebula_bg_hex_input: String, + pub(crate) nebula_bg_hex_active: bool, /// Tab rename state: when `Some(index, text)`, a text input is shown over /// tab `index` with the current edit buffer `text`. The user types to edit, @@ -1216,6 +1267,12 @@ pub struct Display { /// Font size used by the window. pub font_size: FontSize, + /// UI 基准字号(配置字号 × 当前 DPI 缩放后的 px)。Ctrl+滚轮 / 设置 + /// spinner 只改 `font_size`(终端网格与跟随它的文档查看器);chrome、 + /// 设置页、侧栏等 UI 的文字与布局通过 [`Self::ui_text_scale`] 锚定在 + /// 这个基准上,不随终端缩放。 + nebula_ui_font_px: f32, + // Mouse point position when highlighting hints. hint_mouse_point: Option, @@ -1292,7 +1349,14 @@ impl Display { let rasterizer = Rasterizer::new()?; crate::boot_trace("rasterizer ready"); - let font_size = config.font.size().scale(scale_factor); + // 设置里保存过字号则优先生效(逻辑 px × 缩放),否则跟随配置文件; + // Ctrl+滚轮 / 设置 spinner 改过的字号因此在重启后保持。 + let font_size = settings_init + .font_size + .map(|px| FontSize::from_px(px * scale_factor)) + .unwrap_or_else(|| config.font.size().scale(scale_factor)); + // UI 锚定字号始终取配置字号:终端字号的持久化缩放不影响 chrome。 + let ui_font_px = config.font.size().scale(scale_factor).as_px(); #[cfg(windows)] let (rasterizer, required_font_install) = { let mut rasterizer = rasterizer; @@ -1504,6 +1568,7 @@ impl Display { hint_state, size_info, font_size, + nebula_ui_font_px: ui_font_px, window, pending_renderer_update: Default::default(), vi_highlighted_hint_age: Default::default(), @@ -1560,9 +1625,12 @@ impl Display { nebula_chrome_hover: ChromeHit::None, nebula_message_queue_entry: message_queue_entry::MessageQueueEntry::default(), nebula_settings_hover: SettingsHit::None, + nebula_settings_opacity_drag: None, nebula_context_menu: None, - nebula_shell_picker_open: false, - nebula_font_picker_open: false, + nebula_settings_dropdown: None, + nebula_cursor_shape: settings_init.cursor_shape, + nebula_cursor_blink: settings_init.cursor_blink, + nebula_copy_on_select: settings_init.copy_on_select, nebula_font_family: settings_init.font_family, nebula_font_families, nebula_font_notice: None, @@ -1611,8 +1679,13 @@ impl Display { }, nebula_background_image: settings_init.background_image, nebula_background_image_opacity: settings_init.background_image_opacity, + nebula_background_image_fit: settings_init.background_image_fit, + nebula_background_image_alignment: settings_init.background_image_alignment, + nebula_background_image_cover_chrome: settings_init.background_image_cover_chrome, nebula_settings_mtime: settings::nebula_settings_mtime(), nebula_bg_palette_index: 0, + nebula_bg_hex_input: String::new(), + nebula_bg_hex_active: false, meter: Default::default(), ime: Default::default(), }) @@ -1654,10 +1727,6 @@ impl Display { self.window.scale_factor as f32, area, self.nebula_settings_section, - self.nebula_shell_picker_open, - self.nebula_detected_shells.as_ref().map_or(0, Vec::len), - self.nebula_font_picker_open, - self.font_picker_count(), self.nebula_hidden_hosts.len(), ); let next = (self.nebula_settings_scroll + delta).clamp(0.0, max); @@ -2391,7 +2460,7 @@ impl Display { let scale = self.window.scale_factor as f32; let sidebar_expand = self.left_sidebar_progress(); let layout = - chrome_tab_layout(&self.size_info, scale, self.sidebar_model(), sidebar_expand); + chrome_tab_layout(&self.ui_size_info(), scale, self.sidebar_model(), sidebar_expand); // Tabs stack vertically now: count rows whose vertical centre the // pointer has passed to get the remove-then-insert target slot. let passed = layout @@ -2571,7 +2640,7 @@ impl Display { ); format!("{icon} {name}") }, - shell_picker_open: self.nebula_shell_picker_open, + dropdown: self.nebula_settings_dropdown, shells: self .nebula_detected_shells .as_ref() @@ -2588,7 +2657,7 @@ impl Display { .as_ref() .map(|path| path.to_string_lossy().into_owned()), font_family: self.nebula_font_family.clone(), - font_picker_open: self.nebula_font_picker_open, + font_size_px: self.font_size.as_px() / self.window.scale_factor as f32, fonts: self.nebula_font_families.clone(), font_notice: self.nebula_font_notice.clone(), hidden_hosts: self.nebula_hidden_hosts.clone(), @@ -2596,9 +2665,25 @@ impl Display { powerline: self.nebula_powerline_enabled, keep_session: self.nebula_keep_session, opacity: self.nebula_window_opacity, + dragging_opacity: self.nebula_settings_opacity_drag.map(|(target, _, _)| target), + cursor_shape: self.nebula_cursor_shape, + cursor_blink: self.nebula_cursor_blink, + copy_on_select: self.nebula_copy_on_select, + preview_bg: self.preview_terminal_bg(), + preview_fg: { + let bg = self.preview_terminal_bg(); + // 亮底配深字、暗底配浅字:预览要在任何自定义背景色上可读。 + let luma = 0.299 * bg.r as f32 + 0.587 * bg.g as f32 + 0.114 * bg.b as f32; + if luma > 140.0 { Rgb::new(40, 44, 52) } else { Rgb::new(225, 228, 240) } + }, background: self.nebula_background, + bg_hex_input: self.nebula_bg_hex_input.clone(), + bg_hex_active: self.nebula_bg_hex_active, background_image: self.nebula_background_image.clone(), background_image_opacity: self.nebula_background_image_opacity, + background_image_fit: self.nebula_background_image_fit, + background_image_alignment: self.nebula_background_image_alignment, + background_image_cover_chrome: self.nebula_background_image_cover_chrome, scroll: self.nebula_settings_scroll, } } @@ -2610,8 +2695,7 @@ impl Display { self.nebula_settings_open = active; self.nebula_special_tab_active = active; if !active { - self.nebula_shell_picker_open = false; - self.nebula_font_picker_open = false; + self.nebula_settings_dropdown = None; self.nebula_settings_hover = SettingsHit::None; } else { // Each explicit visit starts at a predictable page origin. @@ -2744,22 +2828,113 @@ impl Display { /// Open the "默认 Shell" picker (the settings row click): the same /// Toggle the inline shell picker in settings (expand/collapse the list). - pub fn toggle_shell_picker(&mut self) { - if !self.nebula_shell_picker_open { - // Ensure shells are detected before opening. - let _ = - self.nebula_detected_shells.get_or_insert_with(crate::shell_detect::detect_shells); + /// Toggle a settings combobox. All dropdowns share one field so opening + /// one always closes the others. + pub fn toggle_settings_dropdown(&mut self, dropdown: settings::SettingsDropdown) { + if self.nebula_settings_dropdown == Some(dropdown) { + self.nebula_settings_dropdown = None; + } else { + if dropdown == settings::SettingsDropdown::Shell { + // Ensure shells are detected before opening. + let _ = self + .nebula_detected_shells + .get_or_insert_with(crate::shell_detect::detect_shells); + } + if dropdown == settings::SettingsDropdown::Font { + self.nebula_font_notice = None; + } + self.nebula_settings_dropdown = Some(dropdown); } - self.nebula_font_picker_open = false; - self.nebula_shell_picker_open = !self.nebula_shell_picker_open; self.pending_update.dirty = true; } + pub fn close_settings_dropdown(&mut self) -> bool { + if self.nebula_settings_dropdown.take().is_none() { + return false; + } + self.nebula_bg_hex_active = false; + self.pending_update.dirty = true; + self.window.request_redraw(); + true + } + + pub fn set_background_image_fit_option(&mut self, index: usize) { + if let Some(fit) = settings::BACKGROUND_FIT_OPTIONS.get(index) { + self.nebula_background_image_fit = *fit; + self.persist_nebula_settings(); + } + self.nebula_settings_dropdown = None; + self.pending_update.dirty = true; + } + + pub fn set_background_image_alignment_option(&mut self, index: usize) { + if let Some(alignment) = settings::BACKGROUND_ALIGNMENT_OPTIONS.get(index) { + self.nebula_background_image_alignment = *alignment; + self.persist_nebula_settings(); + } + self.nebula_settings_dropdown = None; + self.pending_update.dirty = true; + } + + pub fn set_accept_option(&mut self, index: usize) { + if let Some(accept) = settings::ACCEPT_OPTIONS.get(index) { + self.nebula_accept = *accept; + self.persist_nebula_settings(); + } + self.nebula_settings_dropdown = None; + self.pending_update.dirty = true; + } + + /// Returns true when the default cursor style changed (the caller then + /// pushes the new default into every live terminal). + pub fn set_cursor_shape_option(&mut self, index: usize) -> bool { + self.nebula_settings_dropdown = None; + self.pending_update.dirty = true; + let Some(shape) = settings::CURSOR_SHAPE_OPTIONS.get(index).copied() else { + return false; + }; + if self.nebula_cursor_shape == shape { + return false; + } + self.nebula_cursor_shape = shape; + self.persist_nebula_settings(); + true + } + + pub fn toggle_cursor_blink(&mut self) { + self.nebula_cursor_blink = !self.nebula_cursor_blink; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + pub fn toggle_copy_on_select(&mut self) { + self.nebula_copy_on_select = !self.nebula_copy_on_select; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + /// The default cursor style (shape + blink) every terminal should fall + /// back to when no escape has overridden it. + pub fn nebula_default_cursor_style(&self) -> nebula_terminal::vte::ansi::CursorStyle { + nebula_terminal::vte::ansi::CursorStyle { + shape: self.nebula_cursor_shape, + blinking: self.nebula_cursor_blink, + } + } + + /// Terminal background color the live settings preview should show: the + /// custom background wins, else the active theme's terminal background. + fn preview_terminal_bg(&self) -> Rgb { + self.nebula_background.unwrap_or(self.nebula_theme.palette().term_bg) + } + + pub fn toggle_shell_picker(&mut self) { + self.toggle_settings_dropdown(settings::SettingsDropdown::Shell); + } + pub fn close_shell_picker(&mut self) { - if self.nebula_shell_picker_open { - self.nebula_shell_picker_open = false; - self.pending_update.dirty = true; - self.window.request_redraw(); + if self.nebula_settings_dropdown == Some(settings::SettingsDropdown::Shell) { + self.close_settings_dropdown(); } } @@ -2790,17 +2965,12 @@ impl Display { } pub fn toggle_font_picker(&mut self) { - self.nebula_shell_picker_open = false; - self.nebula_font_notice = None; - self.nebula_font_picker_open = !self.nebula_font_picker_open; - self.pending_update.dirty = true; + self.toggle_settings_dropdown(settings::SettingsDropdown::Font); } pub fn close_font_picker(&mut self) { - if self.nebula_font_picker_open { - self.nebula_font_picker_open = false; - self.pending_update.dirty = true; - self.window.request_redraw(); + if self.nebula_settings_dropdown == Some(settings::SettingsDropdown::Font) { + self.close_settings_dropdown(); } } @@ -2820,7 +2990,7 @@ impl Display { pub fn set_terminal_font_by_index(&mut self, index: usize, base: &Font) { if let Some(family) = self.nebula_font_families.get(index).cloned() { self.apply_font_family(family, base); - self.nebula_font_picker_open = false; + self.nebula_settings_dropdown = None; return; } if index != self.nebula_font_families.len() { @@ -2834,7 +3004,7 @@ impl Display { Ok(stored) => stored, Err(error) => { self.nebula_font_notice = Some(error); - self.nebula_font_picker_open = false; + self.nebula_settings_dropdown = None; self.pending_update.dirty = true; return; }, @@ -2860,7 +3030,7 @@ impl Display { self.pending_update.dirty = true; }, } - self.nebula_font_picker_open = false; + self.nebula_settings_dropdown = None; } #[cfg(not(windows))] self.open_user_config_file(); @@ -2895,7 +3065,7 @@ impl Display { if let Some(shell) = shell { self.set_default_shell(&shell); } - self.nebula_shell_picker_open = false; + self.nebula_settings_dropdown = None; self.pending_update.dirty = true; } @@ -2945,30 +3115,114 @@ impl Display { self.pending_update.dirty = true; } - pub fn adjust_window_opacity(&mut self, delta: f32) { - self.nebula_window_opacity = (self.nebula_window_opacity + delta).clamp(0.35, 1.0); - self.window.set_transparent(self.nebula_window_opacity < 1.0); - #[cfg(target_os = "macos")] - self.window.set_has_shadow(self.nebula_window_opacity >= 1.0); + pub fn begin_settings_opacity_drag(&mut self, target: SettingsOpacityTarget, pointer_x: f32) { + let slider = settings::opacity_slider_rect( + &self.ui_size_info(), + self.window.scale_factor as f32, + self.terminal_card_rect(), + self.nebula_settings_scroll, + target, + ); + self.nebula_settings_opacity_drag = Some((target, slider.0, slider.2)); + self.update_settings_opacity_drag(pointer_x); + } + + pub fn update_settings_opacity_drag(&mut self, pointer_x: f32) -> bool { + let Some((target, track_x, track_width)) = self.nebula_settings_opacity_drag else { + return false; + }; + let value = settings::opacity_from_pointer(pointer_x, (track_x, 0.0, track_width, 0.0)); + match target { + SettingsOpacityTarget::Terminal => { + if (self.nebula_window_opacity - value).abs() <= f32::EPSILON { + return true; + } + self.nebula_window_opacity = value; + self.update_window_transparency(); + }, + SettingsOpacityTarget::BackgroundImage => { + if (self.nebula_background_image_opacity - value).abs() <= f32::EPSILON { + return true; + } + self.nebula_background_image_opacity = value; + self.update_window_transparency(); + }, + } + self.pending_update.dirty = true; + self.window.request_redraw(); + true + } + + pub fn finish_settings_opacity_drag(&mut self) -> bool { + if self.nebula_settings_opacity_drag.take().is_none() { + return false; + } + // 拖动过程只刷新画面,松手后集中落盘,避免连续写设置文件。 + self.persist_nebula_settings(); + self.pending_update.dirty = true; + true + } + + /// 键盘快捷键仍可循环预设背景色(与色盘同一色板)。 + pub fn cycle_background_color(&mut self) { + self.nebula_bg_palette_index = + (self.nebula_bg_palette_index + 1) % settings::BACKGROUND_SWATCHES.len(); + self.nebula_background = Some(settings::BACKGROUND_SWATCHES[self.nebula_bg_palette_index]); self.persist_nebula_settings(); self.pending_update.dirty = true; } - pub fn cycle_background_color(&mut self) { - const BACKGROUNDS: [Rgb; 6] = [ - Rgb::new(8, 10, 24), - Rgb::new(0, 43, 54), - Rgb::new(24, 24, 37), - Rgb::new(12, 16, 28), - Rgb::new(18, 14, 32), - Rgb::new(6, 26, 28), - ]; - self.nebula_bg_palette_index = (self.nebula_bg_palette_index + 1) % BACKGROUNDS.len(); - self.nebula_background = Some(BACKGROUNDS[self.nebula_bg_palette_index]); - self.persist_nebula_settings(); + /// 打开/关闭背景色浮层(色板 + 16 进制输入),草稿预填当前生效色。 + pub fn open_background_color_picker(&mut self) { + let current = self.nebula_background.unwrap_or(self.colors[NamedColor::Background]); + self.nebula_bg_hex_input = format!("#{:02X}{:02X}{:02X}", current.r, current.g, current.b); + self.nebula_bg_hex_active = false; + self.toggle_settings_dropdown(settings::SettingsDropdown::BackgroundColor); + } + + /// 点选色板某格:应用、落盘并收起浮层。 + pub fn set_background_color_option(&mut self, index: usize) { + if let Some(color) = settings::BACKGROUND_SWATCHES.get(index) { + self.nebula_bg_palette_index = index; + self.nebula_background = Some(*color); + self.persist_nebula_settings(); + } + self.close_settings_dropdown(); self.pending_update.dirty = true; } + pub fn focus_bg_hex_input(&mut self) { + self.nebula_bg_hex_active = true; + self.pending_update.dirty = true; + } + + /// 追加 hex 字符:只收 `#` 与 16 进制位,总长 ≤ 7(`#RRGGBB`)。 + pub fn bg_hex_push(&mut self, ch: char) { + let ok = ch == '#' || ch.is_ascii_hexdigit(); + if ok && self.nebula_bg_hex_input.chars().count() < 7 { + self.nebula_bg_hex_input.push(ch); + self.pending_update.dirty = true; + } + } + + pub fn bg_hex_backspace(&mut self) { + if self.nebula_bg_hex_input.pop().is_some() { + self.pending_update.dirty = true; + } + } + + /// 回车应用 16 进制草稿;解析失败保持浮层与草稿原样。 + pub fn bg_hex_commit(&mut self) -> bool { + let Some(color) = settings::parse_hex_rgb(self.nebula_bg_hex_input.trim()) else { + return false; + }; + self.nebula_background = Some(color); + self.persist_nebula_settings(); + self.close_settings_dropdown(); + self.pending_update.dirty = true; + true + } + /// Pick a background image through the OS file dialog, then persist it and /// refresh the renderer's cached wallpaper. On non-Windows platforms the /// native dialog isn't wired up, so we fall back to opening the settings @@ -2980,6 +3234,7 @@ impl Display { self.nebula_background_image = Some(path); self.persist_nebula_settings(); self.renderer.invalidate_background_image(); + self.update_window_transparency(); self.pending_update.dirty = true; } } @@ -2989,6 +3244,32 @@ impl Display { } } + pub fn clear_background_image(&mut self) { + if self.nebula_background_image.take().is_some() { + self.persist_nebula_settings(); + self.renderer.invalidate_background_image(); + self.update_window_transparency(); + self.pending_update.dirty = true; + } + } + + pub fn request_toggle_background_image_cover_chrome(&mut self) { + if self.nebula_background_image_cover_chrome { + self.nebula_background_image_cover_chrome = false; + self.persist_nebula_settings(); + } else { + self.nebula_confirm = Some(NebulaConfirm::EnableBackgroundImageCoverChrome); + } + self.pending_update.dirty = true; + } + + pub fn confirm_background_image_cover_chrome(&mut self) { + self.nebula_confirm = None; + self.nebula_background_image_cover_chrome = true; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + pub fn open_user_config_file(&mut self) { self.persist_nebula_settings(); let active_lua = self.nebula_config_paths.first().filter(|path| { @@ -3041,6 +3322,9 @@ impl Display { self.nebula_background = None; self.nebula_background_image = None; self.nebula_background_image_opacity = 0.38; + self.nebula_background_image_fit = BackgroundImageFit::default(); + self.nebula_background_image_alignment = BackgroundImageAlignment::default(); + self.nebula_background_image_cover_chrome = false; self.window.set_transparent(false); self.persist_nebula_settings(); self.pending_update.dirty = true; @@ -3155,9 +3439,12 @@ impl Display { /// Update palette hover state. `row` is the visual row index, or `None` when /// the mouse left the palette area. - pub fn palette_hover(&mut self, row: Option) { - self.nebula_palette.set_hover(row); - self.pending_update.dirty = true; + pub fn palette_hover(&mut self, row: Option) -> bool { + if self.nebula_palette.set_hover(row) { + self.pending_update.dirty = true; + return true; + } + false } /// The number of visible palette results (for hover boundary checking). @@ -3518,6 +3805,39 @@ impl Display { self.nebula_ui_anims.left_sidebar.visible(!self.nebula_sidebar_collapsed) } + /// DPI 变化时按同一比例重标 UI 基准字号(等价于配置字号 × 新缩放)。 + pub(crate) fn rescale_ui_font(&mut self, factor: f32) { + if factor.is_finite() && factor > 0.0 { + self.nebula_ui_font_px *= factor; + } + } + + /// UI 文本相对当前终端字号的补偿比率 = UI 基准字号 / 当前字号。 + /// Ctrl+滚轮与设置里的字号只属于终端网格(以及跟随它的 md/txt 查看 + /// 器);chrome、设置页、侧栏、浮层的文字与布局乘此比率反向抵消, + /// 视觉上锚定在配置字号。 + pub(crate) fn ui_text_scale(&self) -> f32 { + let cur = self.font_size.as_px(); + if cur <= 0.0 || self.nebula_ui_font_px <= 0.0 { + return 1.0; + } + self.nebula_ui_font_px / cur + } + + /// [`Self::size_info`] 的 UI 版本:cell 尺寸按 [`Self::ui_text_scale`] + /// 补偿。chrome / 设置 / 浮层的布局与命中测试统一用它,与按同一比率 + /// 栅格化的 UI 文本严格同源;终端网格、damage、光标继续用原 + /// `size_info`。 + pub(crate) fn ui_size_info(&self) -> SizeInfo { + let ratio = self.ui_text_scale(); + let mut ui = self.size_info; + if (ratio - 1.0).abs() > 0.001 { + ui.cell_width = self.size_info.cell_width * ratio; + ui.cell_height = self.size_info.cell_height * ratio; + } + ui + } + /// Geometry of the rounded terminal card in physical pixels `(x, y, w, h)`. /// The card floats on the shell backdrop: flush-ish against the sidebar on /// the left and the top bar above (they share the shell color, so no seam @@ -3647,7 +3967,7 @@ impl Display { self.pending_update.dirty = true; } - fn persist_nebula_settings(&mut self) { + pub(crate) fn persist_nebula_settings(&mut self) { settings::nebula_settings_write(&settings::NebulaRuntimeSettings { language: self.nebula_language_preference, ghost: self.nebula_ghost_enabled, @@ -3663,6 +3983,13 @@ impl Display { background: self.nebula_background, background_image: self.nebula_background_image.clone(), background_image_opacity: self.nebula_background_image_opacity, + background_image_fit: self.nebula_background_image_fit, + background_image_alignment: self.nebula_background_image_alignment, + background_image_cover_chrome: self.nebula_background_image_cover_chrome, + font_size: Some(self.font_size.as_px() / self.window.scale_factor as f32), + cursor_shape: self.nebula_cursor_shape, + cursor_blink: self.nebula_cursor_blink, + copy_on_select: self.nebula_copy_on_select, theme: self.nebula_theme_preference, follow_system_theme: self.nebula_follow_system_theme, pinned_hosts: self.nebula_pinned_hosts.clone(), @@ -3741,6 +4068,9 @@ impl Display { }; self.nebula_background_image = settings.background_image; self.nebula_background_image_opacity = settings.background_image_opacity; + self.nebula_background_image_fit = settings.background_image_fit; + self.nebula_background_image_alignment = settings.background_image_alignment; + self.nebula_background_image_cover_chrome = settings.background_image_cover_chrome; // Sync the host lists too: another window shares the settings file, // and skipping this would let this window's next persist overwrite a // host that window just saved or pinned. @@ -3756,9 +4086,7 @@ impl Display { self.renderer.invalidate_background_image(); } self.nebula_settings_mtime = mtime; - self.window.set_transparent(self.nebula_window_opacity < 1.0); - #[cfg(target_os = "macos")] - self.window.set_has_shadow(self.nebula_window_opacity >= 1.0); + self.update_window_transparency(); self.pending_update.dirty = true; } @@ -3774,8 +4102,85 @@ impl Display { // Keep PNG wallpaper loading in the renderer cache. The setting stores a // user path verbatim (usually `D:\...` on Windows); `cover` scaling and // alpha are handled by the image renderer. - let opacity = self.nebula_background_image_opacity * self.nebula_window_opacity; - self.renderer.draw_background_image(&self.size_info, Path::new(path), opacity); + let target = if self.nebula_background_image_cover_chrome { + (0.0, 0.0, self.size_info.width(), self.size_info.height()) + } else { + self.terminal_card_rect() + }; + // 卡片模式必须携带卡片圆角:矩形壁纸盖上去会吃掉终端卡的圆角。 + let clip_radius = if self.nebula_background_image_cover_chrome { + 0.0 + } else { + (UI_SHELL_RADIUS_LOGICAL * self.window.scale_factor as f32).round() + }; + self.renderer.draw_background_image( + &self.size_info, + Path::new(path), + self.nebula_background_image_opacity, + self.nebula_background_image_fit, + self.nebula_background_image_alignment, + target, + target, + clip_radius, + ); + } + + /// Compose the stable frame backdrop once per frame. + /// + /// 层模型(2026-07-24 修订):清屏完全透明,终端卡底永远先铺主题底 + /// 色(用户透明度),壁纸再以自身不透明度叠在其上——降低壁纸不透 + /// 明度时图片淡向主题底色(浅色主题→白、深色主题→黑),而不是透出 + /// 窗口后面的桌面(旧模型有壁纸时不画卡底,深色主题下低不透明度会 + /// 透出刺眼的白)。卡以外的壳由 chrome pass 的一体化壳层负责(同一 + /// 用户透明度)。 + fn draw_window_backdrop(&mut self, terminal_background: Rgb) { + // rgb 保留壳色:不透明窗口下 DWM 忽略 alpha,尚未被壳/卡覆盖的 + // 像素以壳色兜底而不是黑底。 + self.renderer.clear(self.nebula_theme.palette().shell_bg, 0.0); + { + let (card_x, card_y, card_w, card_h) = self.terminal_card_rect(); + let scale = self.window.scale_factor as f32; + let alpha = (self.nebula_window_opacity * 255.0).round().clamp(0.0, 255.0) as u8; + // 与 chrome 壳层的凹角同径同 round:半径差出小数像素会让两侧 + // 的圆弧 AA 错位成一圈细缝。 + let card = UiQuad::solid( + card_x, + card_y, + card_w, + card_h, + (UI_SHELL_RADIUS_LOGICAL * scale).round(), + Rgba::new( + terminal_background.r, + terminal_background.g, + terminal_background.b, + alpha, + ), + ); + self.renderer.draw_ui(&self.size_info, &[card]); + } + + // The image is intentionally independent of the terminal tint: its own + // opacity means image strength, not a value that disappears at 100% + // terminal opacity. + self.draw_background_image(); + } + + /// Whether a wallpaper path is configured for the terminal card. + fn has_background_image(&self) -> bool { + self.nebula_background_image + .as_deref() + .map(|p| !p.trim().trim_matches('"').is_empty()) + .unwrap_or(false) + } + + /// Sync the OS transparency flag with the user opacity slider. 壁纸不 + /// 透明度不再参与:卡底永远先铺主题底色,壁纸变淡是淡向主题色而非 + /// 透出窗口后面的桌面。 + fn update_window_transparency(&mut self) { + let transparent = self.nebula_window_opacity < 1.0; + self.window.set_transparent(transparent); + #[cfg(target_os = "macos")] + self.window.set_has_shadow(!transparent); } #[inline] @@ -4271,22 +4676,13 @@ impl Display { // chrome backdrop), then the terminal is painted as a rounded // `term_bg` card floating on it. Default-background cells draw no // background of their own (bg_alpha == 0), so they show the card. - let shell_bg = self.nebula_theme.palette().shell_bg; nebula_debug_log(format!( "render_clear path=pane window={}x{} alpha={:.3}", self.size_info.width(), self.size_info.height(), self.nebula_window_opacity, )); - self.renderer.clear(shell_bg, self.nebula_window_opacity); - self.draw_background_image(); - - let (cx, cy, cw, ch) = self.terminal_card_rect(); - let scale = self.window.scale_factor as f32; - let card_r = (UI_SHELL_RADIUS_LOGICAL * scale).round(); - let card = Rgba::new(background_color.r, background_color.g, background_color.b, 255); - let quad = UiQuad::solid(cx, cy, cw, ch, card_r, card); - self.renderer.draw_ui(&self.size_info, &[quad]); + self.draw_window_backdrop(background_color); } // 分屏渲染时每个 pane 都有独立的 viewport/projection;否则右侧内容会沿用上一帧 @@ -4625,22 +5021,16 @@ impl Display { ) { self.renderer.set_window_height(self.size_info.height()); - let shell_bg = self.nebula_theme.palette().shell_bg; nebula_debug_log(format!( "render_clear path=document window={}x{} alpha={:.3}", self.size_info.width(), self.size_info.height(), self.nebula_window_opacity, )); - self.renderer.clear(shell_bg, self.nebula_window_opacity); - self.draw_background_image(); - let card_bg = self.nebula_background.unwrap_or(self.colors[NamedColor::Background]); + self.draw_window_backdrop(card_bg); let (cx, cy, cw, ch) = self.terminal_card_rect(); let scale = self.window.scale_factor as f32; - let card_r = (UI_SHELL_RADIUS_LOGICAL * scale).round(); - let card = Rgba::new(card_bg.r, card_bg.g, card_bg.b, 255); - self.renderer.draw_ui(&self.size_info, &[UiQuad::solid(cx, cy, cw, ch, card_r, card)]); // The document reads inside the card, inset off its rounded corners. let area = ( @@ -4670,22 +5060,14 @@ impl Display { pub fn draw_settings_frame(&mut self, scheduler: &mut Scheduler) { self.renderer.set_window_height(self.size_info.height()); - let shell_bg = self.nebula_theme.palette().shell_bg; nebula_debug_log(format!( "render_clear path=settings window={}x{} alpha={:.3}", self.size_info.width(), self.size_info.height(), self.nebula_window_opacity, )); - self.renderer.clear(shell_bg, self.nebula_window_opacity); - self.draw_background_image(); - let card_bg = self.nebula_background.unwrap_or(self.colors[NamedColor::Background]); - let (cx, cy, cw, ch) = self.terminal_card_rect(); - let scale = self.window.scale_factor as f32; - let card_r = (UI_SHELL_RADIUS_LOGICAL * scale).round(); - let card = Rgba::new(card_bg.r, card_bg.g, card_bg.b, 255); - self.renderer.draw_ui(&self.size_info, &[UiQuad::solid(cx, cy, cw, ch, card_r, card)]); + self.draw_window_backdrop(card_bg); self.present_frame(scheduler); } @@ -4897,7 +5279,7 @@ impl Display { self.nebula_confirm_buttons = None; return; }; - let size = self.size_info; + let size = self.ui_size_info(); let scale = self.window.scale_factor as f32; let s = |v: f32| v * scale; let cell_w = size.cell_width(); @@ -4912,40 +5294,35 @@ impl Display { let txt = sk.ink; let dim = sk.ink_dim; - let (title, body, primary_label, cancel_label, danger) = match &confirm { + let (title, body, danger) = match &confirm { + NebulaConfirm::EnableBackgroundImageCoverChrome => ( + "让背景图覆盖窗口控件区域?".to_owned(), + "背景图会延伸到标题栏、窗口按钮、Tab 与 SSH 侧栏下方,低对比度图片可能影响操作可见性;界面仍会保留最低不透明度保护。".to_owned(), + false, + ), NebulaConfirm::InstallRequiredFont { .. } => ( "建议安装终端字体".to_owned(), "未检测到 Maple Mono Nerd Font;缺少图标时可安装后重启 Nebula。".to_owned(), - "打开字体文件夹 Enter", - "暂时跳过 Esc", false, ), NebulaConfirm::ClosePane { process, .. } => ( "关闭此分栏?".to_owned(), format!("{process} 仍在运行,关闭会中止它。"), - "关闭 Enter", - "取消 Esc", true, ), NebulaConfirm::CloseTab { process, .. } => ( "关闭此标签页?".to_owned(), format!("{process} 仍在运行,关闭会中止它。"), - "关闭 Enter", - "取消 Esc", true, ), NebulaConfirm::CloseWindow { process } => ( "关闭整个窗口?".to_owned(), format!("{process} 仍在运行,关闭会中止它。"), - "关闭 Enter", - "取消 Esc", true, ), NebulaConfirm::Paste { lines, .. } => ( format!("粘贴 {lines} 行文本?"), "多行粘贴会被 shell 逐行执行,请确认来源可信。".to_owned(), - "粘贴 Enter", - "取消 Esc", false, ), NebulaConfirm::DeleteSsh { host, from_config } => { @@ -4955,16 +5332,12 @@ impl Display { format!("隐藏 SSH 主机 {host}?"), "只从 Nebula 隐藏;~/.ssh/config 不会修改,保存的密码将在撤销期后清除。" .to_owned(), - "隐藏 Enter", - "取消 Esc", true, ) } else { ( format!("删除 SSH 主机 {host}?"), "会从主机列表移除,保存的 Windows 密码将在撤销期后清除。".to_owned(), - "删除 Enter", - "取消 Esc", true, ) } @@ -4976,8 +5349,6 @@ impl Display { } else { "远端文件会被永久删除,此操作无法撤销。".to_owned() }, - "删除 Enter", - "取消 Esc", true, ), }; @@ -4987,17 +5358,29 @@ impl Display { cols as f32 * cell_w }; - // Buttons: right-aligned row, primary rightmost (Windows order). + // Buttons: right-aligned row, primary rightmost (Windows order). 文案 + // 统一"是 / 否"(2026-07-23 用户裁定);Enter / Esc 快捷键继续生 + // 效。短文案下用最小宽度保住可点面积。 + let language = self.ui_language(); + let primary_label = language.pick("是", "Yes"); + let cancel_label = language.pick("否", "No"); let btn_h = s(34.0); let btn_pad = s(18.0); - let primary_w = text_w(primary_label) + 2.0 * btn_pad; - let cancel_w = text_w(cancel_label) + 2.0 * btn_pad; + let btn_min_w = s(88.0); + let primary_w = (text_w(primary_label) + 2.0 * btn_pad).max(btn_min_w); + let cancel_w = (text_w(cancel_label) + 2.0 * btn_pad).max(btn_min_w); - // Card sized to content, clamped into the window. + // Card sized to title/buttons, clamped into the window, and capped at + // 520 logical px: a long body WRAPS instead of stretching the card + // into a full-width banner (2026-07-23 用户反馈"警告框太宽"). let pad = s(26.0); - let content_w = text_w(&title).max(text_w(&body)).max(primary_w + s(12.0) + cancel_w); - let box_w = (content_w + 2.0 * pad).max(s(380.0)).min(size.width() - s(32.0)); - let box_h = pad + cell_h + s(10.0) + cell_h + s(24.0) + btn_h + pad * 0.75; + let head_w = text_w(&title).max(primary_w + s(12.0) + cancel_w); + let box_w = (head_w + 2.0 * pad).max(s(380.0)).min(s(520.0)).min(size.width() - s(32.0)); + let body_cols = (((box_w - 2.0 * pad) / cell_w).floor() as usize).max(8); + let body_lines = wrap_display_cols(&body, body_cols); + let line_h = cell_h + s(6.0); + let body_h = body_lines.len() as f32 * line_h - s(6.0); + let box_h = pad + cell_h + s(10.0) + body_h + s(24.0) + btn_h + pad * 0.75; let bx = ((size.width() - box_w) * 0.5).max(s(16.0)); let by = ((size.height() - box_h) * 0.5).max(s(16.0)); @@ -5045,15 +5428,13 @@ impl Display { let glyph_cache = &mut self.glyph_cache; let tx = bx + pad; self.renderer.draw_chrome_text(&size, tx, by + pad, txt, &title, glyph_cache); - self.renderer.draw_chrome_text( - &size, - tx, - by + pad + cell_h + s(10.0), - dim, - &body, - glyph_cache, - ); let btn_text_y = btn_y + (btn_h - cell_h) / 2.0; + // Body wraps to the card's inner width; lines carry a small leading. + let mut line_y = by + pad + cell_h + s(10.0); + for line in &body_lines { + self.renderer.draw_chrome_text(&size, tx, line_y, dim, line, glyph_cache); + line_y += line_h; + } self.renderer.draw_chrome_text( &size, cancel_x + btn_pad, @@ -5089,7 +5470,7 @@ impl Display { return; } - let size = self.size_info; + let size = self.ui_size_info(); let scale = self.window.scale_factor as f32; let s = |value: f32| value * scale; let cell_w = size.cell_width(); @@ -5250,6 +5631,10 @@ impl Display { } fn present_frame(&mut self, scheduler: &mut Scheduler) { + // 本帧的 UI 锚定比率:chrome/设置/浮层文本按它反向补偿终端缩放。 + // 终端网格与文档正文不经过 chrome-text 路径,不受影响。 + let ui_scale = self.ui_text_scale(); + self.renderer.set_ui_text_scale(ui_scale); nebula_debug_log(format!( "render_present window={}x{} pane_view={} frame_images={} chrome_logos={}", self.size_info.width(), @@ -6056,12 +6441,13 @@ impl Display { /// Draw preview for the currently highlighted `Hyperlink`. #[inline(never)] - /// Draw a compact "open this link" tooltip next to the mouse pointer. + /// Draw a compact "open this link" tooltip near the hovered hint. /// - /// Replaces the old full-width `file:///…` bar pinned to the bottom row. - /// Shows the destination (with the noisy `file://` scheme stripped) and - /// the gesture that opens it — `Ctrl+点击 打开` — anchored one row below - /// the hovered cell so it reads where the user is actually looking. + /// 2026-07-23 用户反馈重构:上一版是整行 opaque `draw_string`,锚在鼠 + /// 标 cell 上——指针沿链接滑动时提示逐格跳动(被感知为"闪烁"),路 + /// 径还能占满整行("显示太长")。现在锚定到 hint 自己的起始 cell(指 + /// 针滑动时纹丝不动)、路径限 48 列(尾部优先)、提示词压缩为 + /// `Ctrl+点击`,并以 0.85× UI 锚定字号画进圆角小气泡。 fn draw_hyperlink_preview( &mut self, config: &UiConfig, @@ -6070,63 +6456,88 @@ impl Display { ) { let num_cols = self.size_info.columns(); - // The destination under the mouse (first highlighted hint with a URI). - let Some(uri) = self + // The destination under the mouse (first highlighted hint with a URI) + // plus that hint's start cell as the anchor. + let Some((uri, hint_start)) = self .highlighted_hint .iter() .chain(&self.vi_highlighted_hint) - .find_map(|hint| hint.hyperlink().map(|h| h.uri().to_owned())) + .find_map(|hint| { + hint.hyperlink().map(|h| (h.uri().to_owned(), *hint.bounds().start())) + }) else { return; }; - // Anchor at the hovered cell; without one there is nothing to point at. - let Some(anchor) = + // Hint start scrolled out of the viewport → fall back to the mouse cell. + let anchor = term::point_to_viewport(display_offset, hint_start).or_else(|| { self.hint_mouse_point.and_then(|p| term::point_to_viewport(display_offset, p)) - else { + }); + let Some(anchor) = anchor else { return; }; // Strip the `file://` scheme (and its leading slash before a Windows - // drive) so a local path reads as a path, not a URL — the user asked - // specifically not to surface the raw `file:` form. + // drive) so a local path reads as a path, not a URL. let target = strip_file_scheme(&uri); - const HINT: &str = " · Ctrl+点击打开"; - - // Fit the target (keep the tail — the filename matters most) into the - // room left after the hint, computed in DISPLAY columns (CJK = 2) so - // the tooltip can never overrun the row and clip on the right. + const HINT: &str = " · Ctrl+点击"; let width = |s: &str| -> usize { s.chars().map(|c| c.width().unwrap_or(0)).sum() }; let hint_w = width(HINT); - let target_budget = num_cols.saturating_sub(hint_w + 1); + let target_budget = num_cols.saturating_sub(hint_w + 1).min(48); let target = fit_tail(&target, target_budget); let label = format!("{target}{HINT}"); - let label_w = width(&label); - // Position: one row below the pointer, or above when on the last row. + // Position: one row below the hint's first cell, or above on the last row. let line = if anchor.line + 1 < self.size_info.screen_lines() { anchor.line + 1 } else { anchor.line.saturating_sub(1) }; - // Start near the pointer column, shifted left so the whole label stays - // on screen (label_w is guaranteed <= num_cols by fit_tail above). - let column = anchor.column.0.min(num_cols.saturating_sub(label_w)); - let point = Point::new(line, Column(column)); - // Damage the tooltip row this frame and next (it moves with the mouse). - let damage = LineDamageBounds::new(point.line, point.column.0, num_cols); + // Damage the tooltip row this frame and next (it can appear/vanish). + let damage = LineDamageBounds::new(line, 0, num_cols); self.damage_tracker.frame().damage_line(damage); self.damage_tracker.next_frame().damage_line(damage); + let scale_px = self.window.scale_factor as f32; + let s = |v: f32| v * scale_px; + let text_scale = 0.85 * self.ui_text_scale(); + let cell_w = self.size_info.cell_width(); + let cell_h = self.size_info.cell_height(); + let label_px = width(&label) as f32 * cell_w * text_scale; + let pad_x = s(8.0); + let bubble_w = label_px + 2.0 * pad_x; + let bubble_h = cell_h * text_scale + s(8.0); + let max_x = (self.size_info.width() - self.size_info.padding_right() - bubble_w).max(0.0); + let x = (self.size_info.padding_x() + anchor.column.0 as f32 * cell_w).min(max_x); + let y = self.size_info.padding_y() + line as f32 * cell_h + (cell_h - bubble_h) * 0.5; + let fg = config.colors.footer_bar_foreground(); let bg = config.colors.footer_bar_background(); - self.renderer.draw_string( - point, + let quads = [ + UiQuad::solid( + x - s(1.0), + y - s(1.0), + bubble_w + s(2.0), + bubble_h + s(2.0), + s(7.0), + Rgba::new(fg.r, fg.g, fg.b, 46), + ), + UiQuad::solid(x, y, bubble_w, bubble_h, s(6.0), Rgba::new(bg.r, bg.g, bg.b, 240)), + ]; + self.renderer.draw_ui(&self.size_info, &quads); + + let glyph_cache = &mut self.glyph_cache; + let size = self.size_info; + self.renderer.draw_doc_text_tracked( + &size, + x + pad_x, + y + (bubble_h - cell_h * text_scale) * 0.5, + text_scale, + 0.0, fg, - bg, - label.chars(), - &self.size_info, - &mut self.glyph_cache, + Flags::empty(), + &label, + glyph_cache, ); } diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index 2447a43..dd6f0c0 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -15,12 +15,16 @@ use unicode_width::UnicodeWidthChar; +use nebula_terminal::vte::ansi::CursorShape; + use crate::config::UiConfig; use crate::display::color::Rgb; +use crate::renderer::image::{BackgroundImageAlignment, BackgroundImageFit}; use crate::renderer::ui::{Rgba, UiQuad}; use crate::renderer::{GlyphCache, Renderer}; use super::theme::Skin; +use super::{icons, widgets}; use super::{ AcceptKey, LanguagePreference, NebulaShell, NebulaTheme, SizeInfo, UiLanguage, chrome_settings_button_rect, contains_rect, nebula_data_dir, truncate_tab_label, @@ -35,11 +39,13 @@ use super::{ /// with real functionality behind them are listed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum NebulaSettingsSection { - /// Themes, custom colors, wallpaper and window opacity. + /// Themes, custom colors, wallpaper, cursor and window opacity. #[default] Appearance, /// Completion behaviour plus the raw `nebula_settings.txt` config file. Profiles, + /// Selection/clipboard behaviour (Windows Terminal's "交互" page). + Interaction, /// Read-only shortcut sheet + pointer to `[[keyboard.bindings]]` remapping. Keymap, /// Power-user switches (session residency on close, …). @@ -51,6 +57,7 @@ impl NebulaSettingsSection { match self { Self::Appearance => language.pick("外观", "Appearance"), Self::Profiles => language.pick("配置文件", "Profiles"), + Self::Interaction => language.pick("交互", "Interaction"), Self::Keymap => language.pick("按键映射", "Key bindings"), Self::Advanced => language.pick("高级", "Advanced"), } @@ -78,6 +85,102 @@ pub(super) const KEYMAP_ROWS: &[(&str, &str, &str)] = &[ ("全屏", "Fullscreen", "Alt+Enter"), ]; +/// Which independently draggable opacity control is being adjusted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SettingsOpacityTarget { + Terminal, + BackgroundImage, +} + +/// Which inline dropdown (combobox) is currently expanded. At most one at a +/// time; the option list floats over later rows instead of pushing them down. +/// 用户范式(2026-07-23):凡是多选项的设置一律做成 WT 风格内嵌下拉框, +/// 不再用"点击循环切换"——所有选项必须先可见再选择。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsDropdown { + Shell, + Font, + BackgroundFit, + BackgroundAlignment, + Language, + Accept, + CursorShape, + /// 背景色:色板网格 + 16 进制输入的专用浮层(不是通用行列表)。 + BackgroundColor, +} + +/// 背景色色盘的预设色板(2 行 × 6 列)。前排偏星云/终端惯用暗底,尾部 +/// 提供近黑与亮底;任意颜色可用下方的 16 进制输入框手动指定。 +pub(crate) const BACKGROUND_SWATCHES: [Rgb; 12] = [ + Rgb::new(8, 10, 24), + Rgb::new(12, 16, 28), + Rgb::new(18, 14, 32), + Rgb::new(24, 24, 37), + Rgb::new(0, 43, 54), + Rgb::new(6, 26, 28), + Rgb::new(40, 42, 54), + Rgb::new(30, 30, 30), + Rgb::new(12, 12, 12), + Rgb::new(0, 0, 0), + Rgb::new(253, 246, 227), + Rgb::new(255, 255, 255), +]; + +pub(super) const BACKGROUND_FIT_OPTIONS: [BackgroundImageFit; 4] = [ + BackgroundImageFit::Fill, + BackgroundImageFit::Uniform, + BackgroundImageFit::UniformToFill, + BackgroundImageFit::None, +]; + +pub(super) const BACKGROUND_ALIGNMENT_OPTIONS: [BackgroundImageAlignment; 9] = [ + BackgroundImageAlignment::TopLeft, + BackgroundImageAlignment::Top, + BackgroundImageAlignment::TopRight, + BackgroundImageAlignment::Left, + BackgroundImageAlignment::Center, + BackgroundImageAlignment::Right, + BackgroundImageAlignment::BottomLeft, + BackgroundImageAlignment::Bottom, + BackgroundImageAlignment::BottomRight, +]; + +pub(super) const LANGUAGE_OPTIONS: [LanguagePreference; 3] = + [LanguagePreference::System, LanguagePreference::ZhCn, LanguagePreference::EnUs]; + +pub(super) const ACCEPT_OPTIONS: [AcceptKey; 3] = + [AcceptKey::Both, AcceptKey::Tab, AcceptKey::Right]; + +/// Order mirrors the Windows Terminal appearance page the user referenced. +pub(super) const CURSOR_SHAPE_OPTIONS: [CursorShape; 4] = + [CursorShape::Beam, CursorShape::Underline, CursorShape::Block, CursorShape::HollowBlock]; + +pub(super) fn cursor_shape_label(shape: CursorShape, language: UiLanguage) -> &'static str { + match shape { + CursorShape::Beam => language.pick("条形(│)", "Bar (│)"), + CursorShape::Underline => language.pick("下划线(_)", "Underscore (_)"), + CursorShape::Block => language.pick("实心框(█)", "Filled box (█)"), + CursorShape::HollowBlock => language.pick("空心框(□)", "Empty box (□)"), + CursorShape::Hidden => language.pick("隐藏", "Hidden"), + } +} + +fn accept_label(accept: AcceptKey, language: UiLanguage) -> &'static str { + match accept { + AcceptKey::Right => language.pick("右方向键", "Right arrow"), + AcceptKey::Tab => "Tab", + AcceptKey::Both => language.pick("Tab 或右方向键", "Tab or Right arrow"), + } +} + +fn language_label(preference: LanguagePreference, language: UiLanguage) -> &'static str { + match preference { + LanguagePreference::System => language.pick("跟随系统", "Follow system"), + LanguagePreference::ZhCn => "简体中文", + LanguagePreference::EnUs => "English", + } +} + /// Hit result for the top-left Nebula settings affordance. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SettingsHit { @@ -98,14 +201,39 @@ pub enum SettingsHit { FontCycle, /// Imported-font picker rows; the final row is always "导入字体…". FontPickerRow(usize), + /// Font-size spinner steppers on the "字号" row. + FontSizeUp, + FontSizeDown, + /// Cursor group: shape dropdown + its option rows, and the blink toggle. + CursorShapeDropdown, + CursorShapeOption(usize), + CursorBlinkToggle, + /// 交互: copy-on-select toggle row. + CopyOnSelectToggle, + /// Language combobox trigger (options resolve to [`SettingsHit::Language`]). + LanguageDropdown, + /// Expanded dropdown option rows for the cycle-style settings. + AcceptOption(usize), + FitOption(usize), + AlignOption(usize), /// Restore one address from the persistent hidden-host list. RestoreHiddenSsh(usize), FetchToggle, PowerlineToggle, - OpacityDown, - OpacityUp, + OpacitySlider, BackgroundColor, + /// 背景色浮层:色板网格里的一格。 + BackgroundSwatch(usize), + /// 背景色浮层:16 进制输入框。 + BackgroundHexInput, + /// 背景色浮层内部的空白(吞掉点击且不关闭浮层)。 + BackgroundPopupPanel, BackgroundImage, + BackgroundImageClear, + BackgroundImageFit, + BackgroundImageAlignment, + BackgroundImageCoverChrome, + BackgroundImageOpacitySlider, OpenConfigFile, Reset, /// 高级: keep the resident server (detach) on window close. @@ -128,6 +256,16 @@ pub(super) struct NebulaRuntimeSettings { /// focused pane (or the process cwd for the first window). pub(super) startup_directory: Option, pub(super) font_family: String, + /// Terminal font size in LOGICAL px (`None` = follow the config file). + /// Persisted so the settings spinner and Ctrl+wheel zoom survive restarts. + pub(super) font_size: Option, + /// Default cursor shape; escape sequences (vim, claude) still override. + pub(super) cursor_shape: CursorShape, + /// Default-on: a static cursor reads as a hang ("没有活动感"). + pub(super) cursor_blink: bool, + /// 交互: selecting text copies it to the clipboard immediately (WT's + /// copyOnSelect). Off = right-click copies instead. + pub(super) copy_on_select: bool, pub(super) fetch: bool, pub(super) powerline: bool, /// Window close keeps the PTYs alive in the resident process (detach / @@ -137,6 +275,9 @@ pub(super) struct NebulaRuntimeSettings { pub(super) background: Option, pub(super) background_image: Option, pub(super) background_image_opacity: f32, + pub(super) background_image_fit: BackgroundImageFit, + pub(super) background_image_alignment: BackgroundImageAlignment, + pub(super) background_image_cover_chrome: bool, /// Chrome theme. Persisted so a restart keeps the chosen look AND the /// powerline bridge file gets rewritten with the right name on boot /// (it used to be reset to the default theme every launch). @@ -169,6 +310,10 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { shell_id: None, startup_directory: None, font_family: config.font.normal().family.clone(), + font_size: None, + cursor_shape: CursorShape::Beam, + cursor_blink: true, + copy_on_select: true, // Off by default: the welcome screen pipes a whole script through the // fresh shell and repaints on resize — real startup-latency cost on // the critical path (user ruling: startup speed outranks the art). @@ -182,6 +327,9 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { background: None, background_image: None, background_image_opacity: 0.38, + background_image_fit: BackgroundImageFit::default(), + background_image_alignment: BackgroundImageAlignment::default(), + background_image_cover_chrome: false, theme: NebulaTheme::default(), // Preserve existing installations: automatic switching is opt-in so // an update never replaces an explicitly selected theme unexpectedly. @@ -226,6 +374,18 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { settings.font_family = family.to_owned(); } }, + Some(("font_size", v)) => { + if let Ok(size) = v.trim().parse::() { + settings.font_size = Some(size.clamp(6.0, 72.0)); + } + }, + Some(("cursor_shape", v)) => { + if let Some(shape) = parse_cursor_shape(v) { + settings.cursor_shape = shape; + } + }, + Some(("cursor_blink", v)) => settings.cursor_blink = parse_bool(v, true), + Some(("copy_on_select", v)) => settings.copy_on_select = parse_bool(v, true), Some(("startup_directory", v)) => { let path = std::path::PathBuf::from(v.trim()); if path.is_dir() { @@ -237,7 +397,7 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { Some(("keep_session", v)) => settings.keep_session = parse_bool(v, false), Some(("opacity", v)) => { if let Ok(opacity) = v.trim().parse::() { - settings.opacity = opacity.clamp(0.35, 1.0); + settings.opacity = opacity.clamp(0.0, 1.0); } }, Some(("background", v)) => settings.background = parse_hex_rgb(v.trim()), @@ -250,6 +410,19 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { settings.background_image_opacity = opacity.clamp(0.0, 1.0); } }, + Some(("background_image_fit", v)) => { + if let Some(fit) = BackgroundImageFit::parse(v) { + settings.background_image_fit = fit; + } + }, + Some(("background_image_alignment", v)) => { + if let Some(alignment) = BackgroundImageAlignment::parse(v) { + settings.background_image_alignment = alignment; + } + }, + Some(("background_image_cover_chrome", v)) => { + settings.background_image_cover_chrome = parse_bool(v, false); + }, Some(("pinned_hosts", v)) => { settings.pinned_hosts = v .split(',') @@ -292,7 +465,26 @@ fn parse_bool(value: &str, default: bool) -> bool { } } -fn parse_hex_rgb(value: &str) -> Option { +fn parse_cursor_shape(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "block" => Some(CursorShape::Block), + "beam" | "bar" => Some(CursorShape::Beam), + "underline" => Some(CursorShape::Underline), + "hollow" => Some(CursorShape::HollowBlock), + _ => None, + } +} + +pub(super) fn cursor_shape_settings_value(shape: CursorShape) -> &'static str { + match shape { + CursorShape::Beam => "beam", + CursorShape::Underline => "underline", + CursorShape::HollowBlock => "hollow", + CursorShape::Block | CursorShape::Hidden => "block", + } +} + +pub(crate) fn parse_hex_rgb(value: &str) -> Option { let hex = value.strip_prefix('#').unwrap_or(value); if hex.len() != 6 { return None; @@ -307,6 +499,32 @@ fn format_hex_rgb(rgb: Rgb) -> String { format!("#{:02x}{:02x}{:02x}", rgb.r, rgb.g, rgb.b) } +fn background_image_fit_label(fit: BackgroundImageFit, language: UiLanguage) -> &'static str { + match fit { + BackgroundImageFit::Fill => language.pick("拉伸", "Fill"), + BackgroundImageFit::Uniform => language.pick("适应", "Uniform"), + BackgroundImageFit::UniformToFill => language.pick("填充", "Uniform to fill"), + BackgroundImageFit::None => language.pick("原始尺寸", "None"), + } +} + +fn background_image_alignment_label( + alignment: BackgroundImageAlignment, + language: UiLanguage, +) -> &'static str { + match alignment { + BackgroundImageAlignment::TopLeft => language.pick("左上", "Top left"), + BackgroundImageAlignment::Top => language.pick("顶部", "Top"), + BackgroundImageAlignment::TopRight => language.pick("右上", "Top right"), + BackgroundImageAlignment::Left => language.pick("左侧", "Left"), + BackgroundImageAlignment::Center => language.pick("居中", "Center"), + BackgroundImageAlignment::Right => language.pick("右侧", "Right"), + BackgroundImageAlignment::BottomLeft => language.pick("左下", "Bottom left"), + BackgroundImageAlignment::Bottom => language.pick("底部", "Bottom"), + BackgroundImageAlignment::BottomRight => language.pick("右下", "Bottom right"), + } +} + pub(super) fn nebula_settings_mtime() -> Option { std::fs::metadata(nebula_data_dir().join("nebula_settings.txt")) .and_then(|meta| meta.modified()) @@ -336,19 +554,27 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { let pinned_hosts = settings.pinned_hosts.join(","); let saved_hosts = settings.saved_hosts.join(","); let hidden_hosts = settings.hidden_hosts.join(","); + let font_size = + settings.font_size.map(|size| format!("{size:.1}")).unwrap_or_default(); let _ = std::fs::write( path, format!( - "language={}\ntheme={theme}\nfollow_system_theme={}\nghost={}\naccept={accept}\nshell={shell}\nstartup_directory={startup_directory}\nfont_family={}\nfetch={}\npowerline={}\nkeep_session={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\n", + "language={}\ntheme={theme}\nfollow_system_theme={}\nghost={}\naccept={accept}\nshell={shell}\nstartup_directory={startup_directory}\nfont_family={}\nfont_size={font_size}\ncursor_shape={}\ncursor_blink={}\ncopy_on_select={}\nfetch={}\npowerline={}\nkeep_session={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\nbackground_image_fit={}\nbackground_image_alignment={}\nbackground_image_cover_chrome={}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\n", settings.language.as_str(), settings.follow_system_theme as u8, settings.ghost as u8, settings.font_family, + cursor_shape_settings_value(settings.cursor_shape), + settings.cursor_blink as u8, + settings.copy_on_select as u8, settings.fetch as u8, settings.powerline as u8, settings.keep_session as u8, settings.opacity, - settings.background_image_opacity + settings.background_image_opacity, + settings.background_image_fit.settings_value(), + settings.background_image_alignment.settings_value(), + settings.background_image_cover_chrome as u8, ), ); } @@ -361,17 +587,18 @@ struct SettingsGeometry { popup: (f32, f32, f32, f32), sidebar: (f32, f32, f32, f32), content: (f32, f32, f32, f32), - nav: [(NebulaSettingsSection, f32, f32, f32, f32); 4], + nav: [(NebulaSettingsSection, f32, f32, f32, f32); 5], options: [(NebulaTheme, f32, f32, f32, f32); 7], + /// Live terminal preview card at the top of Appearance: configure → + /// immediately see (font, size, colors, wallpaper opacity, cursor). + preview: (f32, f32, f32, f32), system_theme: (f32, f32, f32, f32), shell: (f32, f32, f32, f32), startup_directory: (f32, f32, f32, f32), startup_directory_clear: (f32, f32, f32, f32), - /// Shell picker expanded rows (when open): first row Y, row height, max visible. - shell_picker: (f32, f32, usize), font: (f32, f32, f32, f32), - /// Font picker expanded rows, including the final import action. - font_picker: (f32, f32, usize), + /// "字号" spinner row; the value box + steppers derive via `widgets`. + font_size_row: (f32, f32, f32, f32), fetch: (f32, f32, f32, f32), powerline: (f32, f32, f32, f32), ghost: (f32, f32, f32, f32), @@ -379,15 +606,23 @@ struct SettingsGeometry { open_config_file: (f32, f32, f32, f32), hidden_host_row0: (f32, f32, f32, f32), hidden_host_count: usize, - /// Full-width "窗口透明度" row (the frame); the stepper buttons below sit - /// inside it. + /// Full-width "窗口透明度" row and its draggable track. language_row: (f32, f32, f32, f32), opacity_row: (f32, f32, f32, f32), - opacity_down: (f32, f32, f32, f32), - opacity_up: (f32, f32, f32, f32), - language: [(LanguagePreference, f32, f32, f32, f32); 3], + opacity_slider: (f32, f32, f32, f32), + /// Cursor group: shape combobox row + blink toggle row. + cursor_shape_row: (f32, f32, f32, f32), + cursor_blink_row: (f32, f32, f32, f32), background: (f32, f32, f32, f32), background_image: (f32, f32, f32, f32), + background_image_clear: (f32, f32, f32, f32), + background_image_fit: (f32, f32, f32, f32), + background_image_alignment: (f32, f32, f32, f32), + background_image_cover_chrome: (f32, f32, f32, f32), + background_image_opacity_row: (f32, f32, f32, f32), + background_image_opacity_slider: (f32, f32, f32, f32), + /// 交互: copy-on-select toggle row. + copy_on_select: (f32, f32, f32, f32), reset: (f32, f32, f32, f32), /// Top edge of the scrollable content viewport (just below the fixed /// header band); everything above it never scrolls. @@ -396,6 +631,7 @@ struct SettingsGeometry { /// `content_top`). `max_scroll = (height - viewport).max(0)`. appearance_h: f32, profiles_h: f32, + interaction_h: f32, keymap_h: f32, /// First keymap row rect; row `i` sits `i * row_h` below it. keymap_row0: (f32, f32, f32, f32), @@ -410,33 +646,21 @@ fn settings_viewport_h(popup_h: f32, scale_factor: f32) -> f32 { } /// Max scroll offset for `section` at the current window size. The input -/// layer clamps its accumulated wheel delta with this. +/// layer clamps its accumulated wheel delta with this. Dropdown popups float +/// over rows, so they never change a section's content height. pub(super) fn settings_max_scroll( size_info: &SizeInfo, scale_factor: f32, area: (f32, f32, f32, f32), section: NebulaSettingsSection, - shell_picker_open: bool, - shell_picker_count: usize, - font_picker_open: bool, - font_picker_count: usize, hidden_host_count: usize, ) -> f32 { - let geometry = settings_geometry( - size_info, - scale_factor, - area, - 0.0, - shell_picker_open, - shell_picker_count, - font_picker_open, - font_picker_count, - hidden_host_count, - ); + let geometry = settings_geometry(size_info, scale_factor, area, 0.0, hidden_host_count); let (_, _, _, ph) = geometry.popup; let content_h = match section { NebulaSettingsSection::Appearance => geometry.appearance_h, NebulaSettingsSection::Profiles => geometry.profiles_h, + NebulaSettingsSection::Interaction => geometry.interaction_h, NebulaSettingsSection::Keymap => geometry.keymap_h, NebulaSettingsSection::Advanced => geometry.advanced_h, }; @@ -448,10 +672,6 @@ fn settings_geometry( scale_factor: f32, area: (f32, f32, f32, f32), scroll: f32, - shell_picker_open: bool, - shell_picker_count: usize, - font_picker_open: bool, - font_picker_count: usize, hidden_host_count: usize, ) -> SettingsGeometry { let s = |v: f32| v * scale_factor; @@ -480,6 +700,12 @@ fn settings_geometry( // so `74 = 32 (section gap) + 42 (hanging title)`. const ROW_H: f32 = 44.0; const GROUP_ADVANCE: f32 = 74.0; + // 预览卡设计高度:两行示例文本 + 光标演示的呼吸空间。 + const PREVIEW_H: f32 = 150.0; + + // Live preview leads the page: configure → see it immediately. + let preview_y0 = 146.0; + let preview = (content_x + s(24.0), at(preview_y0), content_w - s(48.0), s(PREVIEW_H)); // Theme cards flow as a 4 + 3 grid with the design sheet's 20px gaps: // slot i sits at column i%4 on row i/4. The row pitch reserves a strip @@ -487,7 +713,7 @@ fn settings_geometry( let card_gap = s(20.0); let card_w = ((content_w - s(48.0) - 3.0 * card_gap) / 4.0).clamp(s(88.0), s(170.0)); let card_h = s(64.0); - let card_y0 = 146.0; + let card_y0 = preview_y0 + PREVIEW_H + GROUP_ADVANCE; let card_x = content_x + s(24.0); let card_row_pitch = card_h + s(48.0); let card = |i: f32| card_x + (i % 4.0) * (card_w + card_gap); @@ -497,47 +723,54 @@ fn settings_geometry( let row_w = content_w - s(48.0); let row_h = s(ROW_H); - // Appearance: cards (146..322 design px + label strip), colors group, - // interface group. The card block spans 2 grid rows of `64 + 48` design - // px each (card + label strip, matching `card_row_pitch` above). + // Appearance: preview, cards, colors, cursor and interface groups. let system_theme_y0 = card_y0 + 2.0 * (64.0 + 48.0) + GROUP_ADVANCE; let color_y0 = system_theme_y0 + ROW_H + GROUP_ADVANCE; - let iface_y0 = color_y0 + 2.0 * ROW_H + GROUP_ADVANCE; + // Match Windows Terminal's background-image controls: path, stretch, + // alignment and an independent image-opacity slider. + let background_image_y0 = color_y0 + ROW_H; + let background_image_fit_y0 = background_image_y0 + ROW_H; + let background_image_alignment_y0 = background_image_fit_y0 + ROW_H; + let background_image_opacity_y0 = background_image_alignment_y0 + ROW_H; + let background_image_cover_chrome_y0 = background_image_opacity_y0 + ROW_H; + // 光标组:形状下拉 + 闪烁开关。 + let cursor_y0 = color_y0 + 6.0 * ROW_H + GROUP_ADVANCE; + let iface_y0 = cursor_y0 + 2.0 * ROW_H + GROUP_ADVANCE; let opacity_y0 = iface_y0 + ROW_H; let appearance_h = s(opacity_y0 + ROW_H + 32.0 - 72.0); - // Opacity row controls, right-aligned with NO overlap: value · − · slider · +. + // 宽命中区包住细轨道,拖拽时无需精确点中 4px 线条。 let opacity_row = (row_x, at(opacity_y0), row_w, row_h); - let opacity_down = (row_x + row_w - s(224.0), at(opacity_y0) + s(4.0), s(40.0), s(36.0)); - let opacity_up = (row_x + row_w - s(46.0), at(opacity_y0) + s(4.0), s(40.0), s(36.0)); - let language_track_w = s(360.0).min(row_w * 0.58); - let language_w = language_track_w / 3.0; - let language_x = row_x + row_w - language_track_w - s(8.0); - let language_y = at(iface_y0) + s(5.0); + let slider_x = row_x + row_w - s(212.0); + let slider_w = s(188.0).min(row_w * 0.42).max(s(96.0)); + let opacity_slider = (slider_x, at(opacity_y0) + s(4.0), slider_w, s(36.0)); + let background_image_opacity_row = (row_x, at(background_image_opacity_y0), row_w, row_h); + let background_image_opacity_slider = + (slider_x, at(background_image_opacity_y0) + s(4.0), slider_w, s(36.0)); - // Sidebar navigation rows. Only the two wired-up sections are listed; the - // rects line up with the active-row highlight drawn while rendering. - // 4px between items — same breathing as the design sheet's nav menu. + // Sidebar navigation rows. The rects line up with the active-row + // highlight drawn while rendering. 4px between items — same breathing as + // the design sheet's nav menu. let nav_x = popup_x + s(24.0); let nav_w = sidebar_w - s(48.0); let nav_h = s(44.0); let nav_gap = s(4.0); let nav_y0 = popup_y + s(88.0); + let nav_slot = |i: f32| nav_y0 + i * (nav_h + nav_gap); let nav = [ - (NebulaSettingsSection::Appearance, nav_x, nav_y0, nav_w, nav_h), - (NebulaSettingsSection::Profiles, nav_x, nav_y0 + nav_h + nav_gap, nav_w, nav_h), - (NebulaSettingsSection::Keymap, nav_x, nav_y0 + 2.0 * (nav_h + nav_gap), nav_w, nav_h), - (NebulaSettingsSection::Advanced, nav_x, nav_y0 + 3.0 * (nav_h + nav_gap), nav_w, nav_h), + (NebulaSettingsSection::Appearance, nav_x, nav_slot(0.0), nav_w, nav_h), + (NebulaSettingsSection::Profiles, nav_x, nav_slot(1.0), nav_w, nav_h), + (NebulaSettingsSection::Interaction, nav_x, nav_slot(2.0), nav_w, nav_h), + (NebulaSettingsSection::Keymap, nav_x, nav_slot(3.0), nav_w, nav_h), + (NebulaSettingsSection::Advanced, nav_x, nav_slot(4.0), nav_w, nav_h), ]; - // Profiles: terminal, completion and config groups. Shell/font pickers - // expand inline, so every row after them shares the same accumulated offset. + // Profiles: dropdown popups FLOAT over later rows (Windows 11 combobox), + // so every row keeps a fixed offset — no picker shove, no scroll jumps. let shell_y0 = 146.0; - let shell_picker_extra = - if shell_picker_open { shell_picker_count as f32 * ROW_H } else { 0.0 }; - let startup_directory_y0 = shell_y0 + ROW_H + shell_picker_extra; + let startup_directory_y0 = shell_y0 + ROW_H; let font_y0 = startup_directory_y0 + ROW_H; - let font_picker_extra = if font_picker_open { font_picker_count as f32 * ROW_H } else { 0.0 }; - let fetch_y0 = font_y0 + ROW_H + font_picker_extra; + let font_size_y0 = font_y0 + ROW_H; + let fetch_y0 = font_size_y0 + ROW_H; let ghost_y0 = fetch_y0 + 2.0 * ROW_H + GROUP_ADVANCE; let open_y0 = ghost_y0 + 2.0 * ROW_H + GROUP_ADVANCE; let hidden_y0 = open_y0 + ROW_H + GROUP_ADVANCE; @@ -548,6 +781,11 @@ fn settings_geometry( }; let profiles_h = s(profiles_end + 32.0 - 72.0); + // 交互: a single copy-on-select toggle row for now. + let interaction_y0 = 146.0; + let interaction_h = s(interaction_y0 + ROW_H + 32.0 - 72.0); + let copy_on_select = (row_x, at(interaction_y0), row_w, row_h); + // Keymap: one contiguous group of read-only shortcut rows. let keymap_y0 = 146.0; let keymap_h = s(keymap_y0 + KEYMAP_ROWS.len() as f32 * ROW_H + 32.0 - 72.0); @@ -573,30 +811,27 @@ fn settings_geometry( (NebulaTheme::LinenLight, card(5.0), card_slot_y(5.0), card_w, card_h), (NebulaTheme::MossDark, card(6.0), card_slot_y(6.0), card_w, card_h), ], + preview, system_theme: (row_x, at(system_theme_y0), row_w, row_h), background: (row_x, at(color_y0), row_w, row_h), - background_image: (row_x, at(color_y0 + ROW_H), row_w, row_h), + background_image: (row_x, at(background_image_y0), row_w, row_h), + background_image_clear: ( + row_x + row_w - s(48.0), + at(background_image_y0) + s(5.0), + s(36.0), + s(34.0), + ), + background_image_fit: (row_x, at(background_image_fit_y0), row_w, row_h), + background_image_alignment: (row_x, at(background_image_alignment_y0), row_w, row_h), + background_image_cover_chrome: (row_x, at(background_image_cover_chrome_y0), row_w, row_h), + background_image_opacity_row, + background_image_opacity_slider, + cursor_shape_row: (row_x, at(cursor_y0), row_w, row_h), + cursor_blink_row: (row_x, at(cursor_y0 + ROW_H), row_w, row_h), language_row: (row_x, at(iface_y0), row_w, row_h), opacity_row, - opacity_down, - opacity_up, - language: [ - (LanguagePreference::System, language_x, language_y, language_w, s(34.0)), - (LanguagePreference::ZhCn, language_x + language_w, language_y, language_w, s(34.0)), - ( - LanguagePreference::EnUs, - language_x + language_w * 2.0, - language_y, - language_w, - s(34.0), - ), - ], + opacity_slider, shell: (row_x, at(shell_y0), row_w, row_h), - shell_picker: ( - at(shell_y0 + ROW_H), - row_h, - if shell_picker_open { shell_picker_count } else { 0 }, - ), startup_directory: (row_x, at(startup_directory_y0), row_w, row_h), startup_directory_clear: ( row_x + row_w - s(82.0), @@ -605,11 +840,7 @@ fn settings_geometry( s(34.0), ), font: (row_x, at(font_y0), row_w, row_h), - font_picker: ( - at(font_y0 + ROW_H), - row_h, - if font_picker_open { font_picker_count } else { 0 }, - ), + font_size_row: (row_x, at(font_size_y0), row_w, row_h), fetch: (row_x, at(fetch_y0), row_w, row_h), powerline: (row_x, at(fetch_y0 + ROW_H), row_w, row_h), ghost: (row_x, at(ghost_y0), row_w, row_h), @@ -617,10 +848,12 @@ fn settings_geometry( open_config_file: (row_x, at(open_y0), row_w, row_h), hidden_host_row0: (row_x, at(hidden_y0), row_w, row_h), hidden_host_count, + copy_on_select, reset: (popup_x + popup_w - s(170.0), popup_y + s(24.0), s(150.0), s(42.0)), content_top, appearance_h, profiles_h, + interaction_h, keymap_h, keymap_row0, keymap_row_h: row_h, @@ -629,9 +862,126 @@ fn settings_geometry( } } +pub(crate) fn opacity_slider_rect( + size_info: &SizeInfo, + scale_factor: f32, + area: (f32, f32, f32, f32), + scroll: f32, + target: SettingsOpacityTarget, +) -> (f32, f32, f32, f32) { + let geometry = settings_geometry(size_info, scale_factor, area, scroll, 0); + match target { + SettingsOpacityTarget::Terminal => geometry.opacity_slider, + SettingsOpacityTarget::BackgroundImage => geometry.background_image_opacity_slider, + } +} + +pub(crate) fn opacity_from_pointer(pointer_x: f32, slider: (f32, f32, f32, f32)) -> f32 { + ((pointer_x - slider.0) / slider.2.max(1.0)).clamp(0.0, 1.0) +} + +/// Appearance 预览卡的壁纸绘制矩形:`(fit 目标, 实际允许触碰的裁剪带)`。 +/// 裁剪带是预览与设置卡内容区的竖向交集——预览滚到 header 之下时壁纸不 +/// 能跟着涂出去。完全滚出可视区时返回 `None`。 +pub(super) fn appearance_preview_wallpaper_rects( + size_info: &SizeInfo, + scale_factor: f32, + area: (f32, f32, f32, f32), + scroll: f32, + hidden_hosts: usize, +) -> Option<((f32, f32, f32, f32), (f32, f32, f32, f32))> { + let geometry = settings_geometry(size_info, scale_factor, area, scroll, hidden_hosts); + let (vx, vy, vw, vh) = geometry.preview; + let (_, content_y, _, _) = geometry.content; + let (_, py, _, ph) = geometry.popup; + let top = vy.max(content_y); + let bottom = (vy + vh).min(py + ph); + if bottom <= top || vw <= 0.0 { + return None; + } + Some(((vx, vy, vw, vh), (vx, top, vw, bottom - top))) +} + +/// The combobox anchor rect + option count for `dropdown`, IF it belongs to +/// the active section. Hit-testing, popup quads and popup text all resolve +/// the floating list through this one helper so the three can never disagree. +/// 背景色浮层的几何:面板矩形、12 个色板格、16 进制输入框。绘制与命中 +/// 测试共用这一个来源(组件化范式:几何同源,控件与点击区不漂移)。 +pub(super) struct BackgroundColorPopup { + pub(super) rect: (f32, f32, f32, f32), + pub(super) swatch: [(f32, f32, f32, f32); 12], + pub(super) hex: (f32, f32, f32, f32), +} + +pub(super) fn background_color_popup( + geometry: &SettingsGeometry, + scale: f32, +) -> BackgroundColorPopup { + let s = |v: f32| v * scale; + let (ax, ay, aw, ah) = widgets::combobox_rect(geometry.background, scale); + const COLS: usize = 6; + let cell = s(30.0); + let gap = s(8.0); + let pad = s(12.0); + let grid_w = COLS as f32 * cell + (COLS - 1) as f32 * gap; + let grid_h = 2.0 * cell + gap; + let hex_h = s(34.0); + let w = (grid_w + 2.0 * pad).max(aw); + let h = pad + grid_h + gap + hex_h + pad; + // 与 combobox 浮层同规则:锚行右缘对齐,紧贴行下方展开。 + let x = ax + aw - w; + let y = ay + ah + s(6.0); + let mut swatch = [(0.0, 0.0, 0.0, 0.0); 12]; + for (i, rect) in swatch.iter_mut().enumerate() { + let row = i / COLS; + let col = i % COLS; + *rect = ( + x + pad + col as f32 * (cell + gap), + y + pad + row as f32 * (cell + gap), + cell, + cell, + ); + } + let hex = (x + pad, y + pad + grid_h + gap, w - 2.0 * pad, hex_h); + BackgroundColorPopup { rect: (x, y, w, h), swatch, hex } +} + +fn dropdown_anchor( + geometry: &SettingsGeometry, + section: NebulaSettingsSection, + dropdown: SettingsDropdown, + shell_count: usize, + font_count: usize, + scale: f32, +) -> Option<((f32, f32, f32, f32), usize)> { + use NebulaSettingsSection as Section; + let anchor = |row| widgets::combobox_rect(row, scale); + match (section, dropdown) { + (Section::Profiles, SettingsDropdown::Shell) => Some((anchor(geometry.shell), shell_count)), + (Section::Profiles, SettingsDropdown::Font) => Some((anchor(geometry.font), font_count)), + (Section::Profiles, SettingsDropdown::Accept) => { + Some((anchor(geometry.accept), ACCEPT_OPTIONS.len())) + }, + (Section::Appearance, SettingsDropdown::BackgroundFit) => { + Some((anchor(geometry.background_image_fit), BACKGROUND_FIT_OPTIONS.len())) + }, + (Section::Appearance, SettingsDropdown::BackgroundAlignment) => { + Some((anchor(geometry.background_image_alignment), BACKGROUND_ALIGNMENT_OPTIONS.len())) + }, + (Section::Appearance, SettingsDropdown::Language) => { + Some((anchor(geometry.language_row), LANGUAGE_OPTIONS.len())) + }, + (Section::Appearance, SettingsDropdown::CursorShape) => { + Some((anchor(geometry.cursor_shape_row), CURSOR_SHAPE_OPTIONS.len())) + }, + _ => None, + } +} + /// Hit-test the top-left settings button and its popup. `scroll` must be the /// same offset the renderer used, so hits land on what the user actually sees; /// rows scrolled out of the content viewport don't respond. +#[allow(clippy::too_many_arguments)] pub fn settings_hit( size_info: &SizeInfo, scale_factor: f32, @@ -641,23 +991,13 @@ pub fn settings_hit( popup_open: bool, section: NebulaSettingsSection, scroll: f32, - shell_picker_open: bool, - shell_picker_count: usize, - font_picker_open: bool, - font_picker_count: usize, + dropdown: Option, + shell_count: usize, + font_count: usize, hidden_host_count: usize, ) -> SettingsHit { - let geometry = settings_geometry( - size_info, - scale_factor, - area, - scroll, - shell_picker_open, - shell_picker_count, - font_picker_open, - font_picker_count, - hidden_host_count, - ); + let geometry = settings_geometry(size_info, scale_factor, area, scroll, hidden_host_count); + let s = |v: f32| v * scale_factor; if contains_rect(geometry.gear, x, y) { return SettingsHit::Toggle; @@ -672,6 +1012,56 @@ pub fn settings_hit( let (_, py, _, ph) = geometry.popup; let in_viewport = y >= geometry.content_top && y <= py + ph; + // An expanded dropdown owns the pointer first: its floating option list + // covers later rows, and those must not react through it. + if let Some(dropdown) = dropdown { + // 背景色是专用浮层(色板网格 + hex 输入),不走通用行列表。 + if dropdown == SettingsDropdown::BackgroundColor { + if section == NebulaSettingsSection::Appearance { + let popup = background_color_popup(&geometry, scale_factor); + for (index, rect) in popup.swatch.iter().enumerate() { + if contains_rect(*rect, x, y) { + return SettingsHit::BackgroundSwatch(index); + } + } + if contains_rect(popup.hex, x, y) { + return SettingsHit::BackgroundHexInput; + } + if contains_rect(popup.rect, x, y) { + return SettingsHit::BackgroundPopupPanel; + } + } + } else if let Some((anchor, count)) = + dropdown_anchor(&geometry, section, dropdown, shell_count, font_count, scale_factor) + { + let popup = widgets::combobox_popup_rect( + anchor, + count, + scale_factor, + geometry.content_top, + py + ph - s(6.0), + ); + if let Some(index) = widgets::popup_row_at(popup, count, scale_factor, x, y) { + return match dropdown { + SettingsDropdown::Shell => SettingsHit::ShellPickerRow(index), + SettingsDropdown::Font => SettingsHit::FontPickerRow(index), + SettingsDropdown::BackgroundFit => SettingsHit::FitOption(index), + SettingsDropdown::BackgroundAlignment => SettingsHit::AlignOption(index), + SettingsDropdown::Language => SettingsHit::Language(LANGUAGE_OPTIONS[index]), + SettingsDropdown::Accept => SettingsHit::AcceptOption(index), + SettingsDropdown::CursorShape => SettingsHit::CursorShapeOption(index), + // 背景色浮层在上方特判处理,走不到通用行列表。 + SettingsDropdown::BackgroundColor => SettingsHit::Panel, + }; + } + if contains_rect(popup, x, y) { + // Padding strip inside the floating list: swallow the click + // so rows underneath cannot react through the popup. + return SettingsHit::Panel; + } + } + } + // Sidebar navigation and the header reset button are available from every // section. for (nav_section, nx, ny, nw, nh) in geometry.nav { @@ -697,38 +1087,41 @@ pub fn settings_hit( if contains_rect(geometry.background, x, y) { return SettingsHit::BackgroundColor; } + if contains_rect(geometry.background_image_clear, x, y) { + return SettingsHit::BackgroundImageClear; + } if contains_rect(geometry.background_image, x, y) { return SettingsHit::BackgroundImage; } - for (language, lx, ly, lw, lh) in geometry.language { - if contains_rect((lx, ly, lw, lh), x, y) { - return SettingsHit::Language(language); - } + if contains_rect(geometry.background_image_fit, x, y) { + return SettingsHit::BackgroundImageFit; } - if contains_rect(geometry.opacity_down, x, y) { - return SettingsHit::OpacityDown; + if contains_rect(geometry.background_image_alignment, x, y) { + return SettingsHit::BackgroundImageAlignment; } - if contains_rect(geometry.opacity_up, x, y) { - return SettingsHit::OpacityUp; + if contains_rect(geometry.background_image_opacity_slider, x, y) { + return SettingsHit::BackgroundImageOpacitySlider; + } + if contains_rect(geometry.background_image_cover_chrome, x, y) { + return SettingsHit::BackgroundImageCoverChrome; + } + if contains_rect(geometry.cursor_shape_row, x, y) { + return SettingsHit::CursorShapeDropdown; + } + if contains_rect(geometry.cursor_blink_row, x, y) { + return SettingsHit::CursorBlinkToggle; + } + if contains_rect(geometry.language_row, x, y) { + return SettingsHit::LanguageDropdown; + } + if contains_rect(geometry.opacity_slider, x, y) { + return SettingsHit::OpacitySlider; } }, NebulaSettingsSection::Profiles => { if contains_rect(geometry.shell, x, y) { return SettingsHit::ShellCycle; } - // Hit-test expanded shell picker rows (inline list below shell row). - if shell_picker_open && in_viewport { - let (picker_y0, row_h, max) = geometry.shell_picker; - let picker_x = geometry.shell.0; - let picker_w = geometry.shell.2; - // Calculate how many rows based on scroll position - for i in 0..max { - let ry = picker_y0 + i as f32 * row_h; - if contains_rect((picker_x, ry, picker_w, row_h), x, y) { - return SettingsHit::ShellPickerRow(i); - } - } - } if contains_rect(geometry.startup_directory_clear, x, y) { return SettingsHit::StartupDirectoryClear; } @@ -738,14 +1131,14 @@ pub fn settings_hit( if contains_rect(geometry.font, x, y) { return SettingsHit::FontCycle; } - if font_picker_open && in_viewport { - let (picker_y0, row_h, max) = geometry.font_picker; - for i in 0..max { - let rect = - (geometry.font.0, picker_y0 + i as f32 * row_h, geometry.font.2, row_h); - if contains_rect(rect, x, y) { - return SettingsHit::FontPickerRow(i); - } + { + let (_, up, down) = + widgets::spinner_rects(geometry.font_size_row, scale_factor); + if contains_rect(up, x, y) { + return SettingsHit::FontSizeUp; + } + if contains_rect(down, x, y) { + return SettingsHit::FontSizeDown; } } if contains_rect(geometry.fetch, x, y) { @@ -771,6 +1164,11 @@ pub fn settings_hit( } } }, + NebulaSettingsSection::Interaction => { + if contains_rect(geometry.copy_on_select, x, y) { + return SettingsHit::CopyOnSelectToggle; + } + }, NebulaSettingsSection::Keymap => {}, NebulaSettingsSection::Advanced => { if contains_rect(geometry.keep_session, x, y) { @@ -803,14 +1201,15 @@ pub(super) struct SettingsView { /// Pre-rendered "默认 Shell" value (icon + name) — resolved by `Display` /// from the rich `shell_id` when set, else the 2-value enum label. pub(super) shell_label: String, - /// Whether the shell picker is expanded (inline list below the shell row). - pub(super) shell_picker_open: bool, + /// Which combobox is expanded, if any (floating option list). + pub(super) dropdown: Option, /// Detected shells for the picker (cached once per process). pub(super) shells: Vec<(String, String, String)>, // (id, name, program) pub(super) shell_id: Option, pub(super) startup_directory: Option, pub(super) font_family: String, - pub(super) font_picker_open: bool, + /// Current terminal font size in LOGICAL px, for the spinner value box. + pub(super) font_size_px: f32, /// Private families plus Maple; the import action is rendered separately. pub(super) fonts: Vec, pub(super) font_notice: Option, @@ -821,14 +1220,78 @@ pub(super) struct SettingsView { pub(super) powerline: bool, pub(super) keep_session: bool, pub(super) opacity: f32, + /// Which opacity slider is mid-drag, for thumb-dot grow feedback. + pub(super) dragging_opacity: Option, + pub(super) cursor_shape: CursorShape, + pub(super) cursor_blink: bool, + pub(super) copy_on_select: bool, + /// Live-preview colors: the ACTUAL terminal background/foreground the + /// grid would use right now (custom background wins over the theme). + pub(super) preview_bg: Rgb, + pub(super) preview_fg: Rgb, pub(super) background: Option, + /// 背景色浮层的 16 进制草稿(形如 `#0A0C18`)与输入聚焦态。 + pub(super) bg_hex_input: String, + pub(super) bg_hex_active: bool, pub(super) background_image: Option, pub(super) background_image_opacity: f32, + pub(super) background_image_fit: BackgroundImageFit, + pub(super) background_image_alignment: BackgroundImageAlignment, + pub(super) background_image_cover_chrome: bool, /// Content scroll offset in scaled px (0 = top). Owned by `Display`, /// clamped there against [`settings_max_scroll`]. pub(super) scroll: f32, } +/// Preview sample layout shared by the quad pass (cursor demo) and the text +/// pass (sample lines): 16px inner pad, 1.4× line pitch. +fn preview_line_y(top: f32, cell_h: f32, line: f32, scale: f32) -> f32 { + top + 16.0 * scale + line * (cell_h * 1.4) +} +/// Columns of "❯ " before the demo cursor on the preview's prompt line. +const PREVIEW_PROMPT_COLS: usize = 2; + +fn dropdown_selected_index(view: &SettingsView, dropdown: SettingsDropdown) -> Option { + match dropdown { + SettingsDropdown::Shell => view + .shells + .iter() + .position(|(id, _, _)| view.shell_id.as_deref() == Some(id.as_str())), + SettingsDropdown::Font => view.fonts.iter().position(|family| family == &view.font_family), + SettingsDropdown::BackgroundFit => { + BACKGROUND_FIT_OPTIONS.iter().position(|fit| *fit == view.background_image_fit) + }, + SettingsDropdown::BackgroundAlignment => BACKGROUND_ALIGNMENT_OPTIONS + .iter() + .position(|alignment| *alignment == view.background_image_alignment), + SettingsDropdown::Language => { + LANGUAGE_OPTIONS.iter().position(|preference| *preference == view.language_preference) + }, + SettingsDropdown::Accept => ACCEPT_OPTIONS.iter().position(|key| *key == view.accept), + SettingsDropdown::CursorShape => { + CURSOR_SHAPE_OPTIONS.iter().position(|shape| *shape == view.cursor_shape) + }, + SettingsDropdown::BackgroundColor => view + .background + .and_then(|current| BACKGROUND_SWATCHES.iter().position(|color| *color == current)), + } +} + +fn dropdown_hover_index(hover: SettingsHit, dropdown: SettingsDropdown) -> Option { + match (dropdown, hover) { + (SettingsDropdown::Shell, SettingsHit::ShellPickerRow(index)) => Some(index), + (SettingsDropdown::Font, SettingsHit::FontPickerRow(index)) => Some(index), + (SettingsDropdown::BackgroundFit, SettingsHit::FitOption(index)) => Some(index), + (SettingsDropdown::BackgroundAlignment, SettingsHit::AlignOption(index)) => Some(index), + (SettingsDropdown::Language, SettingsHit::Language(preference)) => { + LANGUAGE_OPTIONS.iter().position(|option| *option == preference) + }, + (SettingsDropdown::Accept, SettingsHit::AcceptOption(index)) => Some(index), + (SettingsDropdown::CursorShape, SettingsHit::CursorShapeOption(index)) => Some(index), + _ => None, + } +} + /// Push the Settings tab's background quads, navigation, rows and controls. pub(super) fn push_quads( view: &SettingsView, @@ -839,17 +1302,7 @@ pub(super) fn push_quads( let s = |v: f32| v * scale; let sk = view.theme.skin(); - let geometry = settings_geometry( - size, - scale, - view.area, - view.scroll, - view.shell_picker_open, - view.shells.len(), - view.font_picker_open, - view.fonts.len() + 1, - view.hidden_hosts.len(), - ); + let geometry = settings_geometry(size, scale, view.area, view.scroll, view.hidden_hosts.len()); let (px, py, pw, ph) = geometry.popup; // Header band height: the title row sits above the content, and the header // separator + big title are all measured from here. @@ -865,6 +1318,8 @@ pub(super) fn push_quads( quads.push(quad); } }; + // 通用组件(widgets)不感知视口裁剪:输出先落到 staged,再统一过 clip。 + let mut staged: Vec = Vec::new(); // The page is flush with the active tab card. No veil, drop shadow or // second window outline: depth belongs to the app shell, not this page. @@ -968,38 +1423,110 @@ pub(super) fn push_quads( ); } }; - - // A toggle switch at the right edge of `rect`: pill track + round thumb. - // `on` fills the track with the accent; off stays a muted gray. - let toggle = |quads: &mut Vec, rect: (f32, f32, f32, f32), on: bool| { - let (rx, ry, rw, rh) = rect; - let tw = s(38.0); - let th = s(20.0); - let tx = rx + rw - s(16.0) - tw; - let ty = ry + (rh - th) / 2.0; - clip( - quads, - UiQuad::solid( - tx, - ty, - tw, - th, - th / 2.0, - if on { - Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255) - } else { - sk.track_off - }, - ), - ); - let knob = th - s(6.0); - let kx = if on { tx + tw - s(3.0) - knob } else { tx + s(3.0) }; - let kcol = if on { sk.knob_on } else { sk.knob_off }; - clip(quads, UiQuad::solid(kx, ty + s(3.0), knob, knob, knob / 2.0, kcol)); + // Widget wrappers: stage → viewport-clip → push. Every multi-option row + // shares ONE combobox component (user ruling 2026-07-23), hover/press + // feedback included, so no page ever hand-rolls its own control again. + let combobox = + |quads: &mut Vec, staged: &mut Vec, row, hot: bool, open: bool| { + widgets::push_combobox(staged, widgets::combobox_rect(row, scale), scale, &sk, hot, open); + for quad in staged.drain(..) { + clip(quads, quad); + } + }; + let slider = |quads: &mut Vec, staged: &mut Vec, hit, value: f32, hot: bool| { + widgets::push_slider(staged, hit, value, scale, &sk, hot); + for quad in staged.drain(..) { + clip(quads, quad); + } + }; + let toggle = |quads: &mut Vec, staged: &mut Vec, row, on: bool| { + widgets::push_toggle(staged, row, on, scale, &sk); + for quad in staged.drain(..) { + clip(quads, quad); + } }; match section { NebulaSettingsSection::Appearance => { + // ---- Live preview card: configure → immediately see ---- + // Terminal colors, font family/size (text pass), and the demo + // cursor all read the same state the real grid uses. + { + let (vx, vy, vw, vh) = geometry.preview; + clip( + quads, + UiQuad::solid( + vx - s(1.0), + vy - s(1.0), + vw + s(2.0), + vh + s(2.0), + s(11.0), + sk.hairline, + ), + ); + clip( + quads, + UiQuad::solid( + vx, + vy, + vw, + vh, + s(10.0), + Rgba::new(view.preview_bg.r, view.preview_bg.g, view.preview_bg.b, 255), + ), + ); + // Demo cursor on the prompt line, driven by the REAL shape + + // blink settings (shares the UI caret's 500ms phase). + if !view.cursor_blink || super::caret_blink_on() { + let cell_w = size.cell_width(); + let cell_h = size.cell_height(); + let cursor_x = vx + s(16.0) + PREVIEW_PROMPT_COLS as f32 * cell_w; + let cursor_y = preview_line_y(vy, cell_h, 2.0, scale); + let ink = + Rgba::new(view.preview_fg.r, view.preview_fg.g, view.preview_fg.b, 235); + let bg = + Rgba::new(view.preview_bg.r, view.preview_bg.g, view.preview_bg.b, 255); + let stroke = (1.5 * scale).max(1.0); + let beam_w = (2.0 * scale).max(1.0); + match view.cursor_shape { + CursorShape::Beam => { + clip(quads, UiQuad::solid(cursor_x, cursor_y, beam_w, cell_h, 0.0, ink)); + }, + CursorShape::Underline => { + clip( + quads, + UiQuad::solid( + cursor_x, + cursor_y + cell_h - beam_w, + cell_w, + beam_w, + 0.0, + ink, + ), + ); + }, + CursorShape::HollowBlock => { + clip(quads, UiQuad::solid(cursor_x, cursor_y, cell_w, cell_h, 0.0, ink)); + clip( + quads, + UiQuad::solid( + cursor_x + stroke, + cursor_y + stroke, + cell_w - 2.0 * stroke, + cell_h - 2.0 * stroke, + 0.0, + bg, + ), + ); + }, + CursorShape::Hidden => {}, + CursorShape::Block => { + clip(quads, UiQuad::solid(cursor_x, cursor_y, cell_w, cell_h, 0.0, ink)); + }, + } + } + } + // Theme cards ARE the swatches: each card is filled with its own // theme's real panel color. Selection = accent ring + halo; hover // = the card lifts 2px and glows softly (the design sheet's @@ -1060,158 +1587,104 @@ pub(super) fn push_quads( group_frame(quads, geometry.system_theme, 1); row_hover(quads, geometry.system_theme, view.hover == SettingsHit::SystemThemeToggle); - toggle(quads, geometry.system_theme, view.follow_system_theme); + toggle(quads, &mut staged, geometry.system_theme, view.follow_system_theme); // 自定义背景和界面都使用连续分组,避免设置 Tab 内再次出现 // 漂浮卡片语言。 - group_frame(quads, geometry.background, 2); + group_frame(quads, geometry.background, 6); row_hover(quads, geometry.background, view.hover == SettingsHit::BackgroundColor); + // 背景色也是多选项设置:同一 combobox 组件,浮层换成色板+hex。 + combobox( + quads, + &mut staged, + geometry.background, + view.hover == SettingsHit::BackgroundColor, + view.dropdown == Some(SettingsDropdown::BackgroundColor), + ); row_hover(quads, geometry.background_image, view.hover == SettingsHit::BackgroundImage); - group_frame(quads, geometry.language_row, 2); - for (language, lx, ly, lw, lh) in geometry.language { - let selected = language == view.language_preference; - let hovered = view.hover == SettingsHit::Language(language); - clip( + if view.background_image.is_some() { + row_hover( quads, - UiQuad::solid( - lx, - ly, - lw, - lh, - s(6.0), - if selected { - sk.accent_soft - } else if hovered { - sk.hover - } else { - sk.surface - }, - ), + geometry.background_image_clear, + view.hover == SettingsHit::BackgroundImageClear, ); } + combobox( + quads, + &mut staged, + geometry.background_image_fit, + view.hover == SettingsHit::BackgroundImageFit, + view.dropdown == Some(SettingsDropdown::BackgroundFit), + ); + combobox( + quads, + &mut staged, + geometry.background_image_alignment, + view.hover == SettingsHit::BackgroundImageAlignment, + view.dropdown == Some(SettingsDropdown::BackgroundAlignment), + ); + slider( + quads, + &mut staged, + geometry.background_image_opacity_slider, + view.background_image_opacity, + view.hover == SettingsHit::BackgroundImageOpacitySlider + || view.dragging_opacity == Some(SettingsOpacityTarget::BackgroundImage), + ); + row_hover( + quads, + geometry.background_image_cover_chrome, + view.hover == SettingsHit::BackgroundImageCoverChrome, + ); + toggle( + quads, + &mut staged, + geometry.background_image_cover_chrome, + view.background_image_cover_chrome, + ); - // Opacity controls inside the row: ghost stepper buttons flanking - // a muted rail with a flat accent fill and a round thumb. - for (hit, rect) in [ - (SettingsHit::OpacityDown, geometry.opacity_down), - (SettingsHit::OpacityUp, geometry.opacity_up), - ] { - let (bx, by, bw, bh) = rect; - clip( - quads, - UiQuad::solid( - bx - s(1.0), - by - s(1.0), - bw + s(2.0), - bh + s(2.0), - s(7.0), - sk.hairline, - ), - ); - clip(quads, UiQuad::solid(bx, by, bw, bh, s(6.0), sk.panel)); - if view.hover == hit { - clip(quads, UiQuad::solid(bx, by, bw, bh, s(6.0), sk.hover)); - } - } - { - let (down_x, down_y, _, down_h) = geometry.opacity_down; - let (up_x, _, _, _) = geometry.opacity_up; - let track_x = down_x + s(56.0); - let track_w = (up_x - track_x - s(12.0)).max(s(90.0)); - let track_y = down_y + down_h / 2.0 - s(2.0); - let frac = ((view.opacity - 0.35) / 0.65).clamp(0.0, 1.0); - clip(quads, UiQuad::solid(track_x, track_y, track_w, s(4.0), s(2.0), sk.track_off)); - clip( - quads, - UiQuad::solid( - track_x, - track_y, - track_w * frac, - s(4.0), - s(2.0), - Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255), - ), - ); - clip( - quads, - UiQuad::solid( - track_x + track_w * frac - s(6.0), - track_y - s(4.0), - s(12.0), - s(12.0), - s(6.0), - sk.knob_off, - ), - ); - } + // 光标组:形状下拉 + 闪烁开关。 + group_frame(quads, geometry.cursor_shape_row, 2); + row_hover( + quads, + geometry.cursor_shape_row, + view.hover == SettingsHit::CursorShapeDropdown, + ); + combobox( + quads, + &mut staged, + geometry.cursor_shape_row, + view.hover == SettingsHit::CursorShapeDropdown, + view.dropdown == Some(SettingsDropdown::CursorShape), + ); + row_hover(quads, geometry.cursor_blink_row, view.hover == SettingsHit::CursorBlinkToggle); + toggle(quads, &mut staged, geometry.cursor_blink_row, view.cursor_blink); + + // 界面组:语言(同一通用下拉组件)+ 终端不透明度。 + group_frame(quads, geometry.language_row, 2); + row_hover(quads, geometry.language_row, view.hover == SettingsHit::LanguageDropdown); + combobox( + quads, + &mut staged, + geometry.language_row, + view.hover == SettingsHit::LanguageDropdown, + view.dropdown == Some(SettingsDropdown::Language), + ); + slider( + quads, + &mut staged, + geometry.opacity_slider, + view.opacity, + view.hover == SettingsHit::OpacitySlider + || view.dragging_opacity == Some(SettingsOpacityTarget::Terminal), + ); }, NebulaSettingsSection::Profiles => { - // Pickers expand inside the terminal group, so each contiguous - // block owns its own hairline frame instead of drawing through it. + // 终端组:Shell / 启动目录 / 字体+字号。下拉列表是浮层,行的 + // hairline 分组保持固定,不再被展开的列表推开。 group_frame(quads, geometry.shell, 1); - let (picker_y, picker_h, picker_count) = geometry.shell_picker; - if picker_count > 0 { - let picker_rect = - (geometry.shell.0, picker_y, geometry.shell.2, picker_count as f32 * picker_h); - group_frame(quads, picker_rect, picker_count); - for i in 0..picker_count { - let rect = ( - geometry.shell.0, - picker_y + i as f32 * picker_h, - geometry.shell.2, - picker_h, - ); - row_hover(quads, rect, view.hover == SettingsHit::ShellPickerRow(i)); - if view.shell_id.as_deref() == view.shells.get(i).map(|shell| shell.0.as_str()) - { - clip( - quads, - UiQuad::solid( - rect.0 + s(2.0), - rect.1 + s(2.0), - rect.2 - s(4.0), - rect.3 - s(4.0), - s(6.0), - sk.accent_soft, - ), - ); - } - } - } group_frame(quads, geometry.startup_directory, 1); - group_frame(quads, geometry.font, 1); - let (font_picker_y, font_picker_h, font_picker_count) = geometry.font_picker; - if font_picker_count > 0 { - let picker_rect = ( - geometry.font.0, - font_picker_y, - geometry.font.2, - font_picker_count as f32 * font_picker_h, - ); - group_frame(quads, picker_rect, font_picker_count); - for i in 0..font_picker_count { - let rect = ( - geometry.font.0, - font_picker_y + i as f32 * font_picker_h, - geometry.font.2, - font_picker_h, - ); - row_hover(quads, rect, view.hover == SettingsHit::FontPickerRow(i)); - if view.fonts.get(i).is_some_and(|family| family == &view.font_family) { - clip( - quads, - UiQuad::solid( - rect.0 + s(2.0), - rect.1 + s(2.0), - rect.2 - s(4.0), - rect.3 - s(4.0), - s(6.0), - sk.accent_soft, - ), - ); - } - } - } + group_frame(quads, geometry.font, 2); group_frame(quads, geometry.fetch, 2); group_frame(quads, geometry.ghost, 2); group_frame(quads, geometry.open_config_file, 1); @@ -1242,15 +1715,56 @@ pub(super) fn push_quads( view.hover == SettingsHit::StartupDirectoryClear, ); } + combobox( + quads, + &mut staged, + geometry.shell, + view.hover == SettingsHit::ShellCycle, + view.dropdown == Some(SettingsDropdown::Shell), + ); + combobox( + quads, + &mut staged, + geometry.font, + view.hover == SettingsHit::FontCycle, + view.dropdown == Some(SettingsDropdown::Font), + ); + widgets::push_spinner( + &mut staged, + geometry.font_size_row, + scale, + &sk, + view.hover == SettingsHit::FontSizeUp, + view.hover == SettingsHit::FontSizeDown, + ); + for quad in staged.drain(..) { + clip(quads, quad); + } + combobox( + quads, + &mut staged, + geometry.accept, + view.hover == SettingsHit::AcceptCycle, + view.dropdown == Some(SettingsDropdown::Accept), + ); // Boolean rows render a real switch instead of an "On/Off" string. for (rect, on) in [ (geometry.fetch, view.fetch), (geometry.powerline, view.powerline), (geometry.ghost, view.ghost), ] { - toggle(quads, rect, on); + toggle(quads, &mut staged, rect, on); } }, + NebulaSettingsSection::Interaction => { + group_frame(quads, geometry.copy_on_select, 1); + row_hover( + quads, + geometry.copy_on_select, + view.hover == SettingsHit::CopyOnSelectToggle, + ); + toggle(quads, &mut staged, geometry.copy_on_select, view.copy_on_select); + }, NebulaSettingsSection::Keymap => { group_frame(quads, geometry.keymap_row0, KEYMAP_ROWS.len()); // Keymap rows are read-only — no interaction, no hover. @@ -1258,7 +1772,7 @@ pub(super) fn push_quads( NebulaSettingsSection::Advanced => { group_frame(quads, geometry.keep_session, 1); row_hover(quads, geometry.keep_session, view.hover == SettingsHit::KeepSessionToggle); - toggle(quads, geometry.keep_session, view.keep_session); + toggle(quads, &mut staged, geometry.keep_session, view.keep_session); }, } @@ -1268,6 +1782,7 @@ pub(super) fn push_quads( let content_h = match section { NebulaSettingsSection::Appearance => geometry.appearance_h, NebulaSettingsSection::Profiles => geometry.profiles_h, + NebulaSettingsSection::Interaction => geometry.interaction_h, NebulaSettingsSection::Keymap => geometry.keymap_h, NebulaSettingsSection::Advanced => geometry.advanced_h, }; @@ -1290,6 +1805,232 @@ pub(super) fn push_quads( } } +/// The floating dropdown option list. `draw_chrome` paints these AFTER the +/// base text pass (a separate `draw_ui` call), so page labels can never bleed +/// through the popup plate — the same modal layering rule the command palette +/// needed. +pub(super) fn push_popup_quads( + view: &SettingsView, + quads: &mut Vec, + size: &SizeInfo, + scale: f32, +) { + let Some(dropdown) = view.dropdown else { return }; + let s = |v: f32| v * scale; + let sk = view.theme.skin(); + let geometry = settings_geometry(size, scale, view.area, view.scroll, view.hidden_hosts.len()); + // 背景色专用浮层:色板网格 + hex 输入框(几何与 hit 同源)。 + if dropdown == SettingsDropdown::BackgroundColor { + if view.section != NebulaSettingsSection::Appearance { + return; + } + let popup = background_color_popup(&geometry, scale); + let (px2, py2, pw2, ph2) = popup.rect; + // 与通用 combobox 浮层同一套皮肤:柔和投影 + hairline + 不透明面板。 + quads.push(UiQuad::glow( + px2 - s(14.0), + py2 - s(10.0), + pw2 + s(28.0), + ph2 + s(26.0), + Rgba::new(0, 0, 0, 70), + )); + quads.push(UiQuad::solid( + px2 - s(1.0), + py2 - s(1.0), + pw2 + s(2.0), + ph2 + s(2.0), + s(11.0), + sk.hairline, + )); + let mut plate = sk.panel; + plate.a = 255; + quads.push(UiQuad::solid(px2, py2, pw2, ph2, s(10.0), plate)); + quads.push(UiQuad::solid(px2, py2, pw2, ph2, s(10.0), sk.surface)); + + let selected = dropdown_selected_index(view, dropdown); + for (index, rect) in popup.swatch.iter().enumerate() { + let (sx, sy, sw2, sh2) = *rect; + let hovered = view.hover == SettingsHit::BackgroundSwatch(index); + if selected == Some(index) || hovered { + let ring = if selected == Some(index) { sk.accent } else { sk.ink_dim }; + quads.push(UiQuad::solid( + sx - s(2.0), + sy - s(2.0), + sw2 + s(4.0), + sh2 + s(4.0), + s(8.0), + Rgba::new(ring.r, ring.g, ring.b, 255), + )); + } + // 每格带 1px hairline 描边:亮色格在浅色面板上也有边界。 + quads.push(UiQuad::solid( + sx - s(1.0), + sy - s(1.0), + sw2 + s(2.0), + sh2 + s(2.0), + s(7.0), + sk.hairline, + )); + let color = BACKGROUND_SWATCHES[index]; + quads.push(UiQuad::solid(sx, sy, sw2, sh2, s(6.0), Rgba::new(color.r, color.g, color.b, 255))); + } + + // hex 输入框:聚焦态用主题色描边;caret 与 UI 光标共用 500ms 相位。 + let (hx, hy, hw, hh) = popup.hex; + let focused = view.bg_hex_active; + let border = if focused { sk.accent } else { sk.ink_dim }; + let border_alpha = if focused { 255 } else { 120 }; + quads.push(UiQuad::solid( + hx - s(1.0), + hy - s(1.0), + hw + s(2.0), + hh + s(2.0), + s(8.0), + Rgba::new(border.r, border.g, border.b, border_alpha), + )); + quads.push(UiQuad::solid(hx, hy, hw, hh, s(7.0), sk.surface)); + if focused && super::caret_blink_on() { + let cell_w = size.cell_width(); + let caret_x = hx + s(12.0) + view.bg_hex_input.chars().count() as f32 * cell_w; + let caret_h = hh - s(12.0); + quads.push(UiQuad::solid( + caret_x.min(hx + hw - s(6.0)), + hy + (hh - caret_h) / 2.0, + (1.5 * scale).max(1.0), + caret_h, + 0.0, + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255), + )); + } + return; + } + let (_, py, _, ph) = geometry.popup; + let Some((anchor, count)) = dropdown_anchor( + &geometry, + view.section, + dropdown, + view.shells.len(), + view.fonts.len() + 1, + scale, + ) else { + return; + }; + let popup = + widgets::combobox_popup_rect(anchor, count, scale, geometry.content_top, py + ph - s(6.0)); + let selected = dropdown_selected_index(view, dropdown); + let hover = dropdown_hover_index(view.hover, dropdown); + widgets::push_combobox_popup(quads, popup, count, selected, hover, scale, &sk); + if let Some(index) = selected { + let (rx, ry, rw, rh) = widgets::popup_row_rect(popup, index, scale); + icons::push_check( + quads, + rx + rw - s(16.0), + ry + rh * 0.5, + scale, + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255), + ); + } +} + +/// Option labels for the floating dropdown; returns shell brand-icon draw +/// requests like [`draw_text`]. Must run AFTER `push_popup_quads`'s quads are +/// painted so the labels sit on top of the popup plate. +pub(super) fn draw_popup_text( + view: &SettingsView, + r: &mut Renderer, + gc: &mut GlyphCache, + size: &SizeInfo, + scale: f32, +) -> Vec<(String, (f32, f32, f32, f32))> { + let mut icon_draws = Vec::new(); + let Some(dropdown) = view.dropdown else { return icon_draws }; + let s = |v: f32| v * scale; + let sk = view.theme.skin(); + let language = view.language; + let cell_w = size.cell_width(); + let cell_h = size.cell_height(); + let geometry = settings_geometry(size, scale, view.area, view.scroll, view.hidden_hosts.len()); + // 背景色浮层:hex 草稿(或占位提示)画进输入框,色板格无文字。 + if dropdown == SettingsDropdown::BackgroundColor { + if view.section != NebulaSettingsSection::Appearance { + return icon_draws; + } + let popup = background_color_popup(&geometry, scale); + let (hx, hy, hw, hh) = popup.hex; + let ty = hy + (hh - cell_h) / 2.0; + if view.bg_hex_input.is_empty() { + r.draw_chrome_text(size, hx + s(12.0), ty, sk.ink_dim, "#RRGGBB", gc); + } else { + r.draw_chrome_text(size, hx + s(12.0), ty, sk.ink, &view.bg_hex_input, gc); + } + // 输入框右侧给一个动作提示(回车应用)。 + let hint = language.pick("回车应用", "Enter applies"); + let hint_cols: usize = hint.chars().map(|c| c.width().unwrap_or(0)).sum(); + let hint_x = hx + hw - s(12.0) - hint_cols as f32 * cell_w; + if hint_x > hx + s(12.0) + 9.0 * cell_w { + r.draw_chrome_text(size, hint_x, ty, sk.ink_dim, hint, gc); + } + return icon_draws; + } + let (_, py, _, ph) = geometry.popup; + let Some((anchor, count)) = dropdown_anchor( + &geometry, + view.section, + dropdown, + view.shells.len(), + view.fonts.len() + 1, + scale, + ) else { + return icon_draws; + }; + let popup = + widgets::combobox_popup_rect(anchor, count, scale, geometry.content_top, py + ph - s(6.0)); + let selected = dropdown_selected_index(view, dropdown); + for index in 0..count { + let (rx, ry, rw, rh) = widgets::popup_row_rect(popup, index, scale); + let ty = ry + (rh - cell_h) / 2.0; + // Shell rows lead with the brand icon; every other list is text-only. + let mut text_x = rx + s(12.0); + let label: String = match dropdown { + SettingsDropdown::Shell => { + let Some((id, name, program)) = view.shells.get(index) else { continue }; + icon_draws + .push((id.clone(), (rx + s(8.0), ry + (rh - s(24.0)) / 2.0, s(24.0), s(24.0)))); + text_x = rx + s(40.0); + if program.is_empty() { name.clone() } else { format!("{name} · {program}") } + }, + SettingsDropdown::Font => match view.fonts.get(index) { + Some(family) => family.clone(), + None => language.pick("+ 导入字体…", "+ Import font...").to_owned(), + }, + SettingsDropdown::BackgroundFit => { + background_image_fit_label(BACKGROUND_FIT_OPTIONS[index], language).to_owned() + }, + SettingsDropdown::BackgroundAlignment => { + background_image_alignment_label(BACKGROUND_ALIGNMENT_OPTIONS[index], language) + .to_owned() + }, + SettingsDropdown::Language => { + language_label(LANGUAGE_OPTIONS[index], language).to_owned() + }, + SettingsDropdown::Accept => accept_label(ACCEPT_OPTIONS[index], language).to_owned(), + SettingsDropdown::CursorShape => { + cursor_shape_label(CURSOR_SHAPE_OPTIONS[index], language).to_owned() + }, + // 上方特判提前返回;此臂只为 match 完备。 + SettingsDropdown::BackgroundColor => continue, + }; + let import_row = + matches!(dropdown, SettingsDropdown::Font) && view.fonts.get(index).is_none(); + let color = if selected == Some(index) || import_row { sk.accent } else { sk.ink }; + let max_chars = + (((rx + rw - s(28.0)) - text_x).max(cell_w) / cell_w).floor().max(1.0) as usize; + let label = truncate_tab_label(&label, max_chars); + r.draw_chrome_text(size, text_x, ty, color, &label, gc); + } + icon_draws +} + /// Draw a chrome title at `mult`× the terminal font size. Rasterized at the /// REAL target size (`draw_doc_text`), not GPU-stretched from the base atlas — /// stretching is what made every modal title fuzzy with ragged edges. The @@ -1339,6 +2080,22 @@ fn row_label( k: &str, v: &str, value_ink: Rgb, +) { + row_label_with_right_inset(r, gc, size, scale, sk, (rx, ry, rw, rh), k, v, value_ink, 0.0); +} + +#[allow(clippy::too_many_arguments)] +fn row_label_with_right_inset( + r: &mut Renderer, + gc: &mut GlyphCache, + size: &SizeInfo, + scale: f32, + sk: &Skin, + (rx, ry, rw, rh): (f32, f32, f32, f32), + k: &str, + v: &str, + value_ink: Rgb, + right_inset: f32, ) { let s = |val: f32| val * scale; let cell_w = size.cell_width(); @@ -1346,11 +2103,11 @@ fn row_label( let ty = ry + (rh - cell_h) / 2.0; r.draw_chrome_text(size, rx + s(16.0), ty, sk.ink, k, gc); let value_left = rx + rw * 0.42; - let max_chars = - ((rx + rw - s(16.0) - value_left).max(cell_w) / cell_w).floor().max(1.0) as usize; + let value_right = rx + rw - s(16.0) - right_inset; + let max_chars = ((value_right - value_left).max(cell_w) / cell_w).floor().max(1.0) as usize; let value = truncate_tab_label(v, max_chars); let value_cols: usize = value.chars().map(|c| c.width().unwrap_or(0)).sum(); - let vx = rx + rw - s(16.0) - value_cols as f32 * cell_w; + let vx = value_right - value_cols as f32 * cell_w; r.draw_chrome_text(size, vx.max(value_left), ty, value_ink, &value, gc); } @@ -1368,18 +2125,10 @@ pub(super) fn draw_text( let sk = view.theme.skin(); let language = view.language; - let geometry = settings_geometry( - size, - scale, - view.area, - view.scroll, - view.shell_picker_open, - view.shells.len(), - view.font_picker_open, - view.fonts.len() + 1, - view.hidden_hosts.len(), - ); - let mut icon_draws = Vec::new(); + let geometry = settings_geometry(size, scale, view.area, view.scroll, view.hidden_hosts.len()); + // Kept for parity with [`draw_popup_text`]'s shell icons; the base page + // currently stages no icon draws of its own. + let icon_draws = Vec::new(); let (px, py, _pw, ph) = geometry.popup; let (content_x, content_y, _content_w, _) = geometry.content; // Text has no scissor, so unlike the quad pass (which cuts quads at the @@ -1388,6 +2137,19 @@ pub(super) fn draw_text( let clip_top = geometry.content_top; let clip_bot = py + ph - s(6.0); let visible = |ry: f32, rh: f32| ry >= clip_top && ry + rh <= clip_bot; + // 通用 combobox 的当前值:控件框内左对齐,截断在 chevron 井之前。 + let combobox_value = |r: &mut Renderer, + gc: &mut GlyphCache, + row: (f32, f32, f32, f32), + value: &str, + ink: Rgb| { + let rect = widgets::combobox_rect(row, scale); + let tx = widgets::combobox_text_x(rect, scale); + let right = widgets::combobox_text_right(rect, scale); + let max_chars = ((right - tx).max(cell_w) / cell_w).floor().max(1.0) as usize; + let value = truncate_tab_label(value, max_chars); + r.draw_chrome_text(size, tx, rect.1 + (rect.3 - cell_h) / 2.0, ink, &value, gc); + }; // Group titles hang 42px above their first row (title + 16px gap) and // scroll with it. let group_y = |row_y: f32| row_y - s(42.0); @@ -1451,6 +2213,55 @@ pub(super) fn draw_text( match section { NebulaSettingsSection::Appearance => { + // Live preview: sample lines in the CURRENT font/size on the + // CURRENT terminal colors; the demo cursor quad shares this + // layout via `preview_line_y`. + { + let (vx, vy, _, vh) = geometry.preview; + if visible(group_y(vy), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + content_x + s(24.0), + group_y(vy), + language.pick("预览", "Preview"), + ); + } + if visible(vy, vh) { + let fg = view.preview_fg; + r.draw_chrome_text( + size, + vx + s(16.0), + preview_line_y(vy, cell_h, 0.0, scale), + fg, + "user@nebula ~ $ nebula --version", + gc, + ); + let sample = format!( + "Nebula Terminal · {} · {:.0}px", + view.font_family, view.font_size_px + ); + r.draw_chrome_text( + size, + vx + s(16.0), + preview_line_y(vy, cell_h, 1.0, scale), + fg, + &sample, + gc, + ); + r.draw_chrome_text( + size, + vx + s(16.0), + preview_line_y(vy, cell_h, 2.0, scale), + fg, + "❯", + gc, + ); + } + } let cards_y = geometry.options[0].2; if visible(group_y(cards_y), title_h) { section_title( @@ -1550,9 +2361,9 @@ pub(super) fn draw_text( let image_v = view .background_image .as_deref() - .map(|path| format!("{path} · {:.0}%", view.background_image_opacity * 100.0)) + .map(str::to_owned) .unwrap_or_else(|| language.pick("未设置", "Not set").to_owned()); - row_label( + row_label_with_right_inset( r, gc, size, @@ -1562,6 +2373,153 @@ pub(super) fn draw_text( language.pick("背景图片", "Background image"), &image_v, sk.accent, + if view.background_image.is_some() { s(48.0) } else { 0.0 }, + ); + if view.background_image.is_some() { + let (cx, cy, cw, ch) = geometry.background_image_clear; + r.draw_chrome_text( + size, + cx + (cw - cell_w) / 2.0, + cy + (ch - cell_h) / 2.0, + if view.hover == SettingsHit::BackgroundImageClear { + sk.ink + } else { + sk.ink_dim + }, + "↶", + gc, + ); + } + } + let (_, fit_y, _, fit_h) = geometry.background_image_fit; + if visible(fit_y, fit_h) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.background_image_fit, + language.pick("背景图像拉伸模式", "Background image stretch mode"), + "", + sk.ink, + ); + combobox_value( + r, + gc, + geometry.background_image_fit, + background_image_fit_label(view.background_image_fit, language), + sk.accent, + ); + } + let (_, align_y, _, align_h) = geometry.background_image_alignment; + if visible(align_y, align_h) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.background_image_alignment, + language.pick("背景图像对齐", "Background image alignment"), + "", + sk.ink, + ); + combobox_value( + r, + gc, + geometry.background_image_alignment, + background_image_alignment_label(view.background_image_alignment, language), + sk.accent, + ); + } + let (_, image_opacity_y, _, image_opacity_h) = geometry.background_image_opacity_row; + if visible(image_opacity_y, image_opacity_h) { + r.draw_chrome_text( + size, + geometry.background_image_opacity_row.0 + s(16.0), + image_opacity_y + (image_opacity_h - cell_h) / 2.0, + sk.ink, + language.pick("背景图像不透明度", "Background image opacity"), + gc, + ); + let image_opacity_v = format!("{:.0}%", view.background_image_opacity * 100.0); + let image_opacity_cols: usize = + image_opacity_v.chars().map(|c| c.width().unwrap_or(0)).sum(); + r.draw_chrome_text( + size, + geometry.background_image_opacity_slider.0 + - s(10.0) + - image_opacity_cols as f32 * cell_w, + image_opacity_y + (image_opacity_h - cell_h) / 2.0, + sk.accent, + &image_opacity_v, + gc, + ); + } + let (_, cover_y, _, cover_h) = geometry.background_image_cover_chrome; + if visible(cover_y, cover_h) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.background_image_cover_chrome, + language.pick( + "将背景图扩展到标题栏和侧边栏", + "Extend background image into title bar and sidebar", + ), + "", + sk.ink, + ); + } + // ---- 光标组 ---- + let (cs_x, cs_y, _, cs_h) = geometry.cursor_shape_row; + if visible(group_y(cs_y), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + cs_x, + group_y(cs_y), + language.pick("光标", "Cursor"), + ); + } + if visible(cs_y, cs_h) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.cursor_shape_row, + language.pick("光标形状", "Cursor shape"), + "", + sk.ink, + ); + combobox_value( + r, + gc, + geometry.cursor_shape_row, + cursor_shape_label(view.cursor_shape, language), + sk.accent, + ); + } + let (_, blink_y, _, blink_h) = geometry.cursor_blink_row; + if visible(blink_y, blink_h) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.cursor_blink_row, + language.pick("光标闪烁", "Cursor blinking"), + "", + sk.ink, ); } let (or_x, or_y, _, or_h) = geometry.opacity_row; @@ -1587,22 +2545,13 @@ pub(super) fn draw_text( language.pick("语言", "Language"), gc, ); - for (preference, lx, ly, lw, lh) in geometry.language { - let label = match preference { - LanguagePreference::System => language.pick("跟随系统", "Follow system"), - LanguagePreference::ZhCn => "简体中文", - LanguagePreference::EnUs => "English", - }; - let cols: usize = label.chars().map(|c| c.width().unwrap_or(0)).sum(); - r.draw_chrome_text( - size, - lx + (lw - cols as f32 * cell_w) / 2.0, - ly + (lh - cell_h) / 2.0, - if preference == view.language_preference { sk.accent } else { sk.ink_dim }, - label, - gc, - ); - } + combobox_value( + r, + gc, + geometry.language_row, + language_label(view.language_preference, language), + sk.accent, + ); } if visible(or_y, or_h) { r.draw_chrome_text( @@ -1610,27 +2559,14 @@ pub(super) fn draw_text( or_x + s(16.0), or_y + (or_h - cell_h) / 2.0, sk.ink, - language.pick("窗口透明度", "Window opacity"), + language.pick("终端正文不透明度", "Terminal content opacity"), gc, ); - // Center the fullwidth −/+ glyphs inside their stepper buttons. - for (rect, glyph) in [(geometry.opacity_down, "-"), (geometry.opacity_up, "+")] - { - let (bx, by, bw, bh) = rect; - r.draw_chrome_text( - size, - bx + (bw - 2.0 * cell_w) / 2.0, - by + (bh - cell_h) / 2.0, - sk.ink, - glyph, - gc, - ); - } let opacity_v = format!("{:.0}%", view.opacity * 100.0); let opacity_cols: usize = opacity_v.chars().map(|c| c.width().unwrap_or(0)).sum(); r.draw_chrome_text( size, - geometry.opacity_down.0 - s(14.0) - opacity_cols as f32 * cell_w, + geometry.opacity_slider.0 - s(10.0) - opacity_cols as f32 * cell_w, or_y + (or_h - cell_h) / 2.0, sk.accent, &opacity_v, @@ -1664,44 +2600,10 @@ pub(super) fn draw_text( &sk, geometry.shell, language.pick("默认 Shell", "Default shell"), - &view.shell_label, - sk.accent, + "", + sk.ink, ); - } - let (picker_y, picker_h, picker_count) = geometry.shell_picker; - for (i, (id, name, program)) in view.shells.iter().take(picker_count).enumerate() { - let rect = - (geometry.shell.0, picker_y + i as f32 * picker_h, geometry.shell.2, picker_h); - if !visible(rect.1, rect.3) { - continue; - } - let selected = view.shell_id.as_deref() == Some(id.as_str()); - let color = if selected { sk.accent } else { sk.ink }; - let label = if program.is_empty() { - name.clone() - } else { - format!("{name} · {program}") - }; - r.draw_chrome_text( - size, - rect.0 + s(52.0), - rect.1 + (rect.3 - cell_h) / 2.0, - color, - &label, - gc, - ); - icon_draws - .push((id.clone(), (rect.0 + s(14.0), rect.1 + s(6.0), s(32.0), s(32.0)))); - if selected { - r.draw_chrome_text( - size, - rect.0 + rect.2 - s(38.0), - rect.1 + (rect.3 - cell_h) / 2.0, - sk.accent, - "✓", - gc, - ); - } + combobox_value(r, gc, geometry.shell, &view.shell_label, sk.accent); } if visible(geometry.startup_directory.1, geometry.startup_directory.3) { row_label( @@ -1717,18 +2619,21 @@ pub(super) fn draw_text( ); let (dx, dy, dw, dh) = geometry.startup_directory; - let value_x = dx + dw * 0.38; + // 与"默认 Shell / 终端字体"同一右对齐基线;有清除按钮时向左避让。 + let value_left = dx + dw * 0.42; let value_right = if view.startup_directory.is_some() { geometry.startup_directory_clear.0 - s(12.0) } else { dx + dw - s(16.0) }; - let max_chars = ((value_right - value_x).max(cell_w) / cell_w).floor() as usize; + let max_chars = ((value_right - value_left).max(cell_w) / cell_w).floor() as usize; let value = view .startup_directory .as_deref() .unwrap_or_else(|| language.pick("继承当前目录", "Inherit current directory")); let value = truncate_tab_label(value, max_chars.max(1)); + let value_cols: usize = value.chars().map(|c| c.width().unwrap_or(0)).sum(); + let value_x = (value_right - value_cols as f32 * cell_w).max(value_left); r.draw_chrome_text( size, @@ -1764,39 +2669,40 @@ pub(super) fn draw_text( &sk, geometry.font, language.pick("终端字体", "Terminal font"), + "", + sk.ink, + ); + combobox_value( + r, + gc, + geometry.font, font_value, if view.font_notice.is_some() { sk.ink_dim } else { sk.accent }, ); } - let (font_y, font_h, font_count) = geometry.font_picker; - for i in 0..font_count { - let rect = (geometry.font.0, font_y + i as f32 * font_h, geometry.font.2, font_h); - if !visible(rect.1, rect.3) { - continue; - } - let (label, color) = match view.fonts.get(i) { - Some(family) if family == &view.font_family => (family.as_str(), sk.accent), - Some(family) => (family.as_str(), sk.ink), - None => (language.pick("+ 导入字体…", "+ Import font..."), sk.accent), - }; + if visible(geometry.font_size_row.1, geometry.font_size_row.3) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.font_size_row, + language.pick("终端字号(Ctrl+滚轮缩放)", "Font size (Ctrl+wheel zooms)"), + "", + sk.ink, + ); + let (value_box, _, _) = widgets::spinner_rects(geometry.font_size_row, scale); + let value = format!("{:.0}", view.font_size_px); + let cols: usize = value.chars().map(|c| c.width().unwrap_or(0)).sum(); r.draw_chrome_text( size, - rect.0 + s(16.0), - rect.1 + (rect.3 - cell_h) / 2.0, - color, - label, + value_box.0 + (value_box.2 - cols as f32 * cell_w) / 2.0, + value_box.1 + (value_box.3 - cell_h) / 2.0, + sk.ink, + &value, gc, ); - if view.fonts.get(i).is_some_and(|family| family == &view.font_family) { - r.draw_chrome_text( - size, - rect.0 + rect.2 - s(38.0), - rect.1 + (rect.3 - cell_h) / 2.0, - sk.accent, - "✓", - gc, - ); - } } // Boolean rows: the switch (drawn in `push_quads`) carries the // state; no "On/Off" string next to it. @@ -1862,11 +2768,14 @@ pub(super) fn draw_text( &sk, geometry.accept, language.pick("补全接受键", "Completion accept key"), - match view.accept { - AcceptKey::Right => language.pick("右方向键", "Right arrow"), - AcceptKey::Tab => "Tab", - AcceptKey::Both => language.pick("Tab 或右方向键", "Tab or Right arrow"), - }, + "", + sk.ink, + ); + combobox_value( + r, + gc, + geometry.accept, + accept_label(view.accept, language), sk.accent, ); } @@ -1930,6 +2839,39 @@ pub(super) fn draw_text( } } }, + NebulaSettingsSection::Interaction => { + let (ix, iy, _, ih) = geometry.copy_on_select; + if visible(group_y(iy), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + ix, + group_y(iy), + language.pick("剪贴板", "Clipboard"), + ); + } + if visible(iy, ih) { + // The switch (drawn in `push_quads`) carries the state; the + // label spells out the OFF fallback so both modes are clear. + row_label( + r, + gc, + size, + scale, + &sk, + geometry.copy_on_select, + language.pick( + "自动将所选内容复制到剪贴板(关闭时右键复制 / 粘贴)", + "Copy selection to clipboard (off: right-click copies / pastes)", + ), + "", + sk.ink, + ); + } + }, NebulaSettingsSection::Keymap => { let (kx, ky, kw, kh) = geometry.keymap_row0; if visible(group_y(ky), title_h) { @@ -2000,3 +2942,18 @@ pub(super) fn draw_text( } icon_draws } + +#[cfg(test)] +mod opacity_tests { + use super::opacity_from_pointer; + + #[test] + fn slider_pointer_maps_to_clamped_fraction() { + let slider = (100.0, 20.0, 200.0, 36.0); + assert_eq!(opacity_from_pointer(50.0, slider), 0.0); + assert_eq!(opacity_from_pointer(100.0, slider), 0.0); + assert_eq!(opacity_from_pointer(200.0, slider), 0.5); + assert_eq!(opacity_from_pointer(300.0, slider), 1.0); + assert_eq!(opacity_from_pointer(350.0, slider), 1.0); + } +} diff --git a/nebula_app/src/display/state.rs b/nebula_app/src/display/state.rs index 8389f17..b380bb5 100644 --- a/nebula_app/src/display/state.rs +++ b/nebula_app/src/display/state.rs @@ -67,6 +67,10 @@ impl NebulaShell { /// A blocking window action awaiting user input. #[derive(Clone, Debug, PartialEq, Eq)] pub enum NebulaConfirm { + /// Wallpapers are normally confined to terminal content. Extending them + /// under persistent controls is opt-in because low-contrast images can + /// make caption buttons, tabs and SSH navigation harder to read. + EnableBackgroundImageCoverChrome, InstallRequiredFont { directory: PathBuf, }, diff --git a/nebula_app/src/display/surface_opacity.rs b/nebula_app/src/display/surface_opacity.rs new file mode 100644 index 0000000..426c885 --- /dev/null +++ b/nebula_app/src/display/surface_opacity.rs @@ -0,0 +1,56 @@ +//! Central opacity policy for the terminal surface and persistent chrome. +//! +//! Terminal opacity is an exact user preference and — by explicit user ruling +//! (2026-07-23) — the WHOLE window follows it as one body: shell backdrop, +//! title bar, sidebar and the borders around the terminal card all share the +//! same alpha, so adjusting the slider never splits the frame from its +//! contents. Text/icons stay opaque, which is what keeps the controls +//! recoverable even at 0%. Modals keep a readability floor: a confirm dialog +//! must stay legible over any wallpaper. + +use crate::renderer::ui::Rgba; + +pub(crate) const MODAL_OPACITY_FLOOR: f32 = 0.92; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct SurfaceOpacityPolicy { + pub terminal: f32, + pub chrome: f32, + pub modal: f32, +} + +impl SurfaceOpacityPolicy { + pub(crate) fn new(user_opacity: f32) -> Self { + let terminal = user_opacity.clamp(0.0, 1.0); + Self { terminal, chrome: terminal, modal: terminal.max(MODAL_OPACITY_FLOOR) } + } + + /// Preserve the theme token's authored alpha while applying a surface + /// multiplier. This avoids turning intentionally soft panels fully opaque + /// when the user's terminal opacity is 100%. + pub(crate) fn chrome_color(self, color: Rgba) -> Rgba { + let alpha = (color.a as f32 * self.chrome).round().clamp(0.0, 255.0) as u8; + Rgba::new(color.r, color.g, color.b, alpha) + } +} + +#[cfg(test)] +mod tests { + use super::{MODAL_OPACITY_FLOOR, SurfaceOpacityPolicy}; + + #[test] + fn whole_window_follows_user_while_modals_keep_readability_floor() { + let policy = SurfaceOpacityPolicy::new(0.0); + assert_eq!(policy.terminal, 0.0); + assert_eq!(policy.chrome, 0.0); + assert_eq!(policy.modal, MODAL_OPACITY_FLOOR); + } + + #[test] + fn fully_opaque_preference_keeps_every_surface_opaque() { + let policy = SurfaceOpacityPolicy::new(1.0); + assert_eq!(policy.terminal, 1.0); + assert_eq!(policy.chrome, 1.0); + assert_eq!(policy.modal, 1.0); + } +} diff --git a/nebula_app/src/display/terminal_math.rs b/nebula_app/src/display/terminal_math.rs index d42abe7..0072f2d 100644 --- a/nebula_app/src/display/terminal_math.rs +++ b/nebula_app/src/display/terminal_math.rs @@ -113,10 +113,16 @@ impl TerminalMathState { &mut self, grid: &TextGrid, allow_inline_dollar: bool, + active_edit_rows: Option<&std::ops::RangeInclusive>, ) -> Option { let mut completed_pending = false; let scan = scan_grid_result(grid, allow_inline_dollar); for overlay in scan.overlays { + // 当前光标所在的逻辑行仍由 CLI 编辑器拥有。这里必须在持久化 + // 之前排除整段换行链,否则公式会先进入缓存,下一帧又覆盖输入。 + if active_edit_rows.is_some_and(|rows| overlay.intersects_rows(rows)) { + continue; + } let anchor = overlay_anchor(grid, &overlay); completed_pending |= self .pending_display @@ -130,6 +136,9 @@ impl TerminalMathState { let Some((position, kind)) = scan.unmatched_display else { return None; }; + if active_edit_rows.is_some_and(|rows| rows.contains(&position.row)) { + return None; + } let current = PendingDisplayFormula { anchor: FormulaAnchor { row: grid.absolute_top.saturating_add(position.row), @@ -477,6 +486,13 @@ impl FormulaOverlay { }) } + fn intersects_rows(&self, rows: &std::ops::RangeInclusive) -> bool { + self.spans + .iter() + .filter_map(|span| usize::try_from(span.row).ok()) + .any(|row| rows.contains(&row)) + } + fn bounds(&self, size: &SizeInfo) -> Option { let first = self.spans.first()?; let last = self.spans.last()?; @@ -583,6 +599,24 @@ impl TextGrid { self.rows.get(position.row)?.get(position.column).copied().flatten() } + /// Physical rows joined by terminal wrap flags form one editable logical + /// line. Suppressing only the cursor cell lets an earlier formula on the + /// same prompt line render while the user is still typing after it. + fn logical_rows_containing(&self, row: usize) -> Option> { + if row >= self.rows.len() { + return None; + } + let mut start = row; + while start > 0 && self.wrapped.get(start - 1).copied().unwrap_or(false) { + start -= 1; + } + let mut end = row; + while end + 1 < self.rows.len() && self.wrapped.get(end).copied().unwrap_or(false) { + end += 1; + } + Some(start..=end) + } + fn starts_with(&self, position: GridPosition, delimiter: &[char]) -> bool { delimiter.iter().enumerate().all(|(offset, expected)| { self.character(GridPosition { row: position.row, column: position.column + offset }) @@ -713,8 +747,11 @@ pub(super) fn scan_visible( default_background: Rgb, ) -> Vec { let grid = TextGrid::from_term(terminal, size); + let active_edit_rows = cursor.and_then(|cursor| grid.logical_rows_containing(cursor.line)); state.synchronize_grid(&grid); - if let Some(anchor) = state.scan_visible_grid(&grid, allow_inline_dollar) { + if let Some(anchor) = + state.scan_visible_grid(&grid, allow_inline_dollar, active_edit_rows.as_ref()) + { let lookback = grid.absolute_top.saturating_sub(anchor.row); // `extract` counts every terminal cell toward the 16 KiB parser limit. // Deriving the row cap from the current width keeps this rare temporary @@ -735,7 +772,9 @@ pub(super) fn scan_visible( state .visible_overlays(&grid) .into_iter() - .filter(|overlay| cursor.is_none_or(|cursor| !overlay.contains(cursor))) + .filter(|overlay| { + active_edit_rows.as_ref().is_none_or(|rows| !overlay.intersects_rows(rows)) + }) .filter_map(|mut overlay| { overlay.fallback = rendered_cells .iter() @@ -1215,6 +1254,36 @@ mod tests { assert!(multiline[0].1); } + #[test] + fn active_cli_input_logical_line_is_never_persisted_as_math_output() { + let mut grid = + TextGrid::from_rows(&["prompt $$lim_x{x}$$", "continued $x^1$", "assistant $$x^2$$"]); + // The first physical row wraps into the cursor row, so both belong to + // one live editor buffer and must be excluded together. + grid.wrapped[0] = true; + let active_rows = grid.logical_rows_containing(1).expect("cursor logical line"); + assert_eq!(active_rows, 0..=1); + + let mut state = TerminalMathState::default(); + state.synchronize_grid(&grid); + assert!(state.scan_visible_grid(&grid, true, Some(&active_rows)).is_none()); + + let overlays = state.visible_overlays(&grid); + assert_eq!(overlays.len(), 1); + assert_eq!(overlays[0].source.as_ref(), "x^2"); + } + + #[test] + fn unmatched_display_delimiter_on_live_input_does_not_start_history_reconstruction() { + let grid = TextGrid::from_rows(&["prompt $$"]); + let active_rows = grid.logical_rows_containing(0).expect("cursor row"); + let mut state = TerminalMathState::default(); + state.synchronize_grid(&grid); + + assert!(state.scan_visible_grid(&grid, true, Some(&active_rows)).is_none()); + assert!(state.pending_display.is_none()); + } + #[test] fn screenshot_cli_formulas_reach_the_shared_compiler() { let samples = [ @@ -1357,7 +1426,7 @@ d & -b \\ 0, ); state.synchronize_grid(&opening); - assert!(state.scan_visible_grid(&opening, false).is_none()); + assert!(state.scan_visible_grid(&opening, false, None).is_none()); let visible_tail = grid_at( &[ @@ -1371,7 +1440,7 @@ d & -b \\ ); state.synchronize_grid(&visible_tail); let history_anchor = state - .scan_visible_grid(&visible_tail, false) + .scan_visible_grid(&visible_tail, false, None) .expect("closing delimiter requests the pending opening from history"); assert_eq!(history_anchor, FormulaAnchor { row: 40, column: 0 }); diff --git a/nebula_app/src/display/widgets.rs b/nebula_app/src/display/widgets.rs new file mode 100644 index 0000000..a11f486 --- /dev/null +++ b/nebula_app/src/display/widgets.rs @@ -0,0 +1,263 @@ +//! Reusable settings-control primitives: slider, toggle, combobox, spinner. +//! +//! Same layering contract as [`super::icons`]: pure geometry + quad output, +//! no `Display` state and no hover decisions. Layout, drawing and hit-testing +//! all call the SAME rect helpers here, so a control's clickable area can +//! never drift from its painted area — the drift class of bug that made the +//! hand-rolled per-page controls expensive to debug. +//! +//! Text (current values, option labels) is deliberately NOT drawn here: the +//! renderer draws chrome text in a separate pass, so widgets expose the +//! geometry (`*_rect`, [`combobox_text_x`]) and the text pass reuses it. + +use super::icons; +use super::theme::Skin; +use crate::renderer::ui::{Rgba, UiQuad}; + +pub(crate) type Rect = (f32, f32, f32, f32); + +/// Row-height of one dropdown option in logical px. +pub(crate) const POPUP_ROW_H: f32 = 36.0; +/// Closed combobox / spinner control height in logical px. +const CONTROL_H: f32 = 32.0; +/// Right inset shared by every row-trailing control. +const ROW_INSET: f32 = 16.0; + +#[inline] +fn accent(sk: &Skin) -> Rgba { + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255) +} + +#[inline] +fn opaque_panel(sk: &Skin) -> Rgba { + Rgba::new(sk.panel.r, sk.panel.g, sk.panel.b, 255) +} + +// ---- slider ---- + +/// Windows Terminal-style slider inside the row's wide hit rect: a thin +/// track + accent fill + a ringed round thumb whose inner dot grows while +/// the pointer is on the control (`hot`). No plate behind the track — the +/// hit rect stays invisible. +pub(crate) fn push_slider( + quads: &mut Vec, + hit: Rect, + value: f32, + scale: f32, + sk: &Skin, + hot: bool, +) { + let s = |v: f32| v * scale; + let (hit_x, hit_y, hit_w, hit_h) = hit; + let frac = value.clamp(0.0, 1.0); + let center_y = hit_y + hit_h * 0.5; + // Keep the full thumb inside the hit rect at 0% and 100%. + let thumb_r = s(9.0); + let track_x = hit_x + thumb_r; + let track_w = (hit_w - 2.0 * thumb_r).max(s(24.0)); + let track_h = s(4.0); + let track_y = center_y - track_h * 0.5; + let thumb_x = track_x + track_w * frac; + + quads.push(UiQuad::solid(track_x, track_y, track_w, track_h, track_h * 0.5, sk.track_off)); + quads.push(UiQuad::solid( + track_x, + track_y, + (track_w * frac).max(track_h), + track_h, + track_h * 0.5, + accent(sk), + )); + + // Thumb: hairline ring → light plate → accent dot (grows when hot). + quads.push(UiQuad::solid( + thumb_x - thumb_r - s(1.0), + center_y - thumb_r - s(1.0), + (thumb_r + s(1.0)) * 2.0, + (thumb_r + s(1.0)) * 2.0, + thumb_r + s(1.0), + sk.hairline, + )); + quads.push(UiQuad::solid( + thumb_x - thumb_r, + center_y - thumb_r, + thumb_r * 2.0, + thumb_r * 2.0, + thumb_r, + sk.knob_off, + )); + let dot_r = if hot { s(5.0) } else { s(3.5) }; + quads.push(UiQuad::solid( + thumb_x - dot_r, + center_y - dot_r, + dot_r * 2.0, + dot_r * 2.0, + dot_r, + accent(sk), + )); +} + +// ---- toggle ---- + +/// A toggle switch at the right edge of `row`: pill track + round thumb. +/// `on` fills the track with the accent; off stays a muted gray. +pub(crate) fn push_toggle(quads: &mut Vec, row: Rect, on: bool, scale: f32, sk: &Skin) { + let s = |v: f32| v * scale; + let (rx, ry, rw, rh) = row; + let tw = s(38.0); + let th = s(20.0); + let tx = rx + rw - s(ROW_INSET) - tw; + let ty = ry + (rh - th) / 2.0; + quads.push(UiQuad::solid(tx, ty, tw, th, th / 2.0, if on { accent(sk) } else { sk.track_off })); + let knob = th - s(6.0); + let kx = if on { tx + tw - s(3.0) - knob } else { tx + s(3.0) }; + let kcol = if on { sk.knob_on } else { sk.knob_off }; + quads.push(UiQuad::solid(kx, ty + s(3.0), knob, knob, knob / 2.0, kcol)); +} + +// ---- combobox ---- + +/// The closed dropdown control docked at the right edge of its settings row. +pub(crate) fn combobox_rect(row: Rect, scale: f32) -> Rect { + let s = |v: f32| v * scale; + let (rx, ry, rw, rh) = row; + let cw = s(220.0).min(rw * 0.46).max(s(132.0)); + let ch = s(CONTROL_H); + (rx + rw - s(ROW_INSET) - cw, ry + (rh - ch) / 2.0, cw, ch) +} + +/// Left edge for the closed control's value text. +pub(crate) fn combobox_text_x(rect: Rect, scale: f32) -> f32 { + rect.0 + 12.0 * scale +} + +/// Right edge available to the value text (the chevron well starts here). +pub(crate) fn combobox_text_right(rect: Rect, scale: f32) -> f32 { + rect.0 + rect.2 - 28.0 * scale +} + +/// Closed combobox: hairline frame, quiet surface, trailing chevron. +pub(crate) fn push_combobox( + quads: &mut Vec, + rect: Rect, + scale: f32, + sk: &Skin, + hot: bool, + open: bool, +) { + let s = |v: f32| v * scale; + let (cx, cy, cw, ch) = rect; + quads.push(UiQuad::solid(cx - s(1.0), cy - s(1.0), cw + s(2.0), ch + s(2.0), s(7.0), sk.hairline)); + quads.push(UiQuad::solid(cx, cy, cw, ch, s(6.0), opaque_panel(sk))); + quads.push(UiQuad::solid(cx, cy, cw, ch, s(6.0), sk.surface)); + if hot || open { + quads.push(UiQuad::solid(cx, cy, cw, ch, s(6.0), sk.hover)); + } + let ink = Rgba::new(sk.ink_dim.r, sk.ink_dim.g, sk.ink_dim.b, 235); + icons::push_chevron(quads, cx + cw - s(15.0), cy + ch * 0.5, scale, ink, open); +} + +/// Popup rect for `count` options anchored under (or, when the viewport +/// bottom is too close, above) the closed control. The popup floats over +/// later rows instead of pushing them down. +pub(crate) fn combobox_popup_rect( + anchor: Rect, + count: usize, + scale: f32, + clip_top: f32, + clip_bot: f32, +) -> Rect { + let s = |v: f32| v * scale; + let row_h = s(POPUP_ROW_H); + let pad = s(4.0); + let height = count as f32 * row_h + 2.0 * pad; + let below = anchor.1 + anchor.3 + s(4.0); + let y = if below + height <= clip_bot || anchor.1 - s(4.0) - height < clip_top { + below + } else { + anchor.1 - s(4.0) - height + }; + (anchor.0, y, anchor.2, height) +} + +pub(crate) fn popup_row_rect(popup: Rect, index: usize, scale: f32) -> Rect { + let s = |v: f32| v * scale; + (popup.0 + s(4.0), popup.1 + s(4.0) + index as f32 * s(POPUP_ROW_H), popup.2 - s(8.0), s(POPUP_ROW_H)) +} + +pub(crate) fn popup_row_at(popup: Rect, count: usize, scale: f32, x: f32, y: f32) -> Option { + (0..count).find(|&index| super::contains_rect(popup_row_rect(popup, index, scale), x, y)) +} + +/// The floating option list: opaque plate + soft shadow so it reads as a +/// layer ABOVE the page, selected row in the accent wash, hover in the quiet +/// wash. Option text and check marks belong to the text pass. +pub(crate) fn push_combobox_popup( + quads: &mut Vec, + popup: Rect, + count: usize, + selected: Option, + hover: Option, + scale: f32, + sk: &Skin, +) { + let s = |v: f32| v * scale; + let (px, py, pw, ph) = popup; + quads.push(UiQuad::glow(px - s(14.0), py - s(10.0), pw + s(28.0), ph + s(26.0), Rgba::new(0, 0, 0, 70))); + quads.push(UiQuad::solid(px - s(1.0), py - s(1.0), pw + s(2.0), ph + s(2.0), s(9.0), sk.hairline)); + quads.push(UiQuad::solid(px, py, pw, ph, s(8.0), opaque_panel(sk))); + quads.push(UiQuad::solid(px, py, pw, ph, s(8.0), sk.surface)); + for index in 0..count { + let (rx, ry, rw, rh) = popup_row_rect(popup, index, scale); + if selected == Some(index) { + quads.push(UiQuad::solid(rx, ry + s(2.0), rw, rh - s(4.0), s(6.0), sk.accent_soft)); + } else if hover == Some(index) { + quads.push(UiQuad::solid(rx, ry + s(2.0), rw, rh - s(4.0), s(6.0), sk.hover)); + } + } +} + +// ---- spinner ---- + +/// Numeric stepper docked at the right edge of its row: +/// `[ value ] [∧] [∨]`, Windows 11 style. Returns (value box, up, down). +pub(crate) fn spinner_rects(row: Rect, scale: f32) -> (Rect, Rect, Rect) { + let s = |v: f32| v * scale; + let (rx, ry, rw, rh) = row; + let ch = s(CONTROL_H); + let cy = ry + (rh - ch) / 2.0; + let button_w = s(32.0); + let value_w = s(56.0); + let gap = s(4.0); + let down = (rx + rw - s(ROW_INSET) - button_w, cy, button_w, ch); + let up = (down.0 - gap - button_w, cy, button_w, ch); + let value = (up.0 - gap - value_w, cy, value_w, ch); + (value, up, down) +} + +pub(crate) fn push_spinner( + quads: &mut Vec, + row: Rect, + scale: f32, + sk: &Skin, + hot_up: bool, + hot_down: bool, +) { + let s = |v: f32| v * scale; + let (value, up, down) = spinner_rects(row, scale); + for (rect, hot, chevron_up) in + [(value, false, None), (up, hot_up, Some(true)), (down, hot_down, Some(false))] + { + let (cx, cy, cw, ch) = rect; + quads.push(UiQuad::solid(cx - s(1.0), cy - s(1.0), cw + s(2.0), ch + s(2.0), s(7.0), sk.hairline)); + quads.push(UiQuad::solid(cx, cy, cw, ch, s(6.0), opaque_panel(sk))); + quads.push(UiQuad::solid(cx, cy, cw, ch, s(6.0), sk.surface)); + if hot { + quads.push(UiQuad::solid(cx, cy, cw, ch, s(6.0), sk.hover)); + } + if let Some(up_arrow) = chevron_up { + let ink = Rgba::new(sk.ink_dim.r, sk.ink_dim.g, sk.ink_dim.b, 235); + icons::push_chevron(quads, cx + cw * 0.5, cy + ch * 0.5, scale, ink, up_arrow); + } + } +} diff --git a/nebula_app/src/display/window.rs b/nebula_app/src/display/window.rs index 6f02caa..e225cf2 100644 --- a/nebula_app/src/display/window.rs +++ b/nebula_app/src/display/window.rs @@ -464,6 +464,11 @@ impl Window { self.window.is_maximized() } + #[inline] + pub fn is_fullscreen(&self) -> bool { + self.window.fullscreen().is_some() + } + pub fn set_minimized(&self, minimized: bool) { self.window.set_minimized(minimized); } @@ -482,6 +487,18 @@ impl Window { self.set_maximized(!self.window.is_maximized()); } + /// Caption-button semantics: normal windows maximize, while maximized or + /// fullscreen windows restore to an ordinary window. Exiting fullscreen + /// alone can restore a previously maximized state, so clear both states. + pub fn toggle_maximized_or_restore(&self) { + if self.is_fullscreen() { + self.set_fullscreen(false); + self.set_maximized(false); + } else { + self.toggle_maximized(); + } + } + /// Custom resize hit targets are invalid while Windows owns the maximized /// or fullscreen bounds; exposing them causes edge clicks to start a drag. pub fn allows_drag_resize(&self) -> bool { diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index e75222a..87fdd77 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -1018,7 +1018,9 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon None => return, }; - if ty == ClipboardType::Selection && self.config.selection.save_to_clipboard { + // 交互设置「选中即复制」:开启时任何选区立即进系统剪贴板; + // 关闭时选择只保留在终端内,复制交给右键(复制/粘贴)路径。 + if ty == ClipboardType::Selection && self.display.nebula_copy_on_select { self.clipboard.store(ClipboardType::Clipboard, text.clone()); } self.clipboard.store(ty, text); @@ -1303,6 +1305,14 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon self.display.pending_update.set_font(font); } + fn apply_default_cursor_style(&mut self) { + // 用户在设置页显式选了光标样式:先清掉 shell 早前用 DECSCUSR 钉住 + // 的覆盖(PSReadLine/starship 启动时常发),否则新默认被旧覆盖压 + // 住、"改了不生效";之后 vim 等程序再发 DECSCUSR 仍可正常覆盖。 + self.terminal.reset_cursor_style_override(); + self.update_cursor_blinking(); + } + #[inline] fn pop_message(&mut self) { if !self.message_buffer.is_empty() { @@ -2050,6 +2060,11 @@ impl<'a, N: Notify + 'a, T: EventListener> ActionContext<'a, N, T> { /// Update the cursor blinking state. fn update_cursor_blinking(&mut self) { + // Push the settings default (shape + blink) into the terminal first: + // `Term::cursor_style()` falls back to it whenever no DECSCUSR escape + // has overridden the style, so vim's mode cursor keeps working while + // plain shells follow the user's choice immediately. + self.terminal.set_default_cursor_style(self.display.nebula_default_cursor_style()); // Get config cursor style. let mut cursor_style = self.config.cursor.style; let vi_mode = self.terminal.mode().contains(TermMode::VI); @@ -2443,6 +2458,7 @@ impl input::Processor> { // Rescale font size for the new factor. let font_scale = scale_factor as f32 / old_scale_factor as f32; self.ctx.display.font_size = self.ctx.display.font_size.scale(font_scale); + self.ctx.display.rescale_ui_font(font_scale); let font = self.ctx.display.effective_font(&self.ctx.config.font); let font_size = self.ctx.display.font_size; diff --git a/nebula_app/src/input/chrome.rs b/nebula_app/src/input/chrome.rs index e6a54c9..4ce8e41 100644 --- a/nebula_app/src/input/chrome.rs +++ b/nebula_app/src/input/chrome.rs @@ -50,6 +50,9 @@ impl> Processor { pub fn nebula_confirm_accept(&mut self, confirm: crate::display::NebulaConfirm) { use crate::display::NebulaConfirm; match confirm { + NebulaConfirm::EnableBackgroundImageCoverChrome => { + self.ctx.display().confirm_background_image_cover_chrome(); + }, NebulaConfirm::ClosePane { .. } => { self.ctx.nebula_tab(crate::event::TabRequest::Close); }, @@ -212,7 +215,7 @@ impl> Processor { let debug_x = self.ctx.mouse().x as f32; let debug_y = self.ctx.mouse().y as f32; if self.ctx.nebula_chrome_active() { - let window_size = self.ctx.display().size_info; + let window_size = self.ctx.display().ui_size_info(); let pane_size = self.ctx.size_info(); let scale = self.ctx.window().scale_factor as f32; let chrome_hit = self.ctx.display().chrome_hit(debug_x, debug_y); @@ -250,13 +253,7 @@ impl> Processor { self.ctx.mark_dirty(); return; } - if self.ctx.display().nebula_shell_picker_open { - self.ctx.display().close_shell_picker(); - self.ctx.mark_dirty(); - return; - } - if self.ctx.display().nebula_font_picker_open { - self.ctx.display().close_font_picker(); + if self.ctx.display().close_settings_dropdown() { self.ctx.mark_dirty(); return; } @@ -302,7 +299,7 @@ impl> Processor { if button == MouseButton::Left && self.ctx.display().command_palette_open() { let x = self.ctx.mouse().x as f32; let y = self.ctx.mouse().y as f32; - let size = self.ctx.display().size_info; + let size = self.ctx.display().ui_size_info(); let scale = self.ctx.window().scale_factor as f32; let layout = crate::display::command_palette::palette_layout( size.width(), @@ -605,7 +602,7 @@ impl> Processor { let y = self.ctx.mouse().y as f32; // Chrome geometry is window-relative; the pane view would misplace // every hit rect in split mode (unclickable gear, wrong tabs). - let size = self.ctx.display().size_info; + let size = self.ctx.display().ui_size_info(); let scale = self.ctx.window().scale_factor as f32; // Window border resize takes priority over the chrome controls. let resize_enabled = self.ctx.window().allows_drag_resize(); @@ -633,9 +630,8 @@ impl> Processor { let settings_open = self.ctx.display().settings_open(); let settings_section = self.ctx.display().settings_section(); let settings_scroll = self.ctx.display().settings_scroll(); - let shell_picker_open = self.ctx.display().nebula_shell_picker_open; + let settings_dropdown = self.ctx.display().nebula_settings_dropdown; let shell_picker_count = self.ctx.display().shell_picker_count(); - let font_picker_open = self.ctx.display().nebula_font_picker_open; let font_picker_count = self.ctx.display().font_picker_count(); let hidden_host_count = self.ctx.display().hidden_ssh_host_count(); let settings_area = self.ctx.display().terminal_card_rect(); @@ -648,33 +644,15 @@ impl> Processor { settings_open, settings_section, settings_scroll, - shell_picker_open, + settings_dropdown, shell_picker_count, - font_picker_open, font_picker_count, hidden_host_count, ); - if shell_picker_open - && !matches!( - settings_hit, - crate::display::SettingsHit::ShellCycle - | crate::display::SettingsHit::ShellPickerRow(_) - ) - { - // First outside click dismisses the picker without activating - // an unrelated control hidden behind its temporary focus scope. - self.ctx.display().close_shell_picker(); - self.ctx.mark_dirty(); - return; - } - if font_picker_open - && !matches!( - settings_hit, - crate::display::SettingsHit::FontCycle - | crate::display::SettingsHit::FontPickerRow(_) - ) - { - self.ctx.display().close_font_picker(); + // 打开的下拉框独占第一击:命中不属于它(选项行或锚行)时, + // 这一击只负责关闭浮层,绝不让下层控件借机误触发。 + if settings_dropdown.is_some() && !settings_dropdown_keeps_open(settings_hit) { + self.ctx.display().close_settings_dropdown(); self.ctx.mark_dirty(); return; } @@ -695,6 +673,14 @@ impl> Processor { }, crate::display::SettingsHit::Language(language) => { self.ctx.display().set_ui_language(language); + self.ctx.display().close_settings_dropdown(); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::LanguageDropdown => { + self.ctx + .display() + .toggle_settings_dropdown(crate::display::SettingsDropdown::Language); self.ctx.mark_dirty(); return; }, @@ -704,7 +690,53 @@ impl> Processor { return; }, crate::display::SettingsHit::AcceptCycle => { - self.ctx.display().cycle_accept(); + self.ctx + .display() + .toggle_settings_dropdown(crate::display::SettingsDropdown::Accept); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::AcceptOption(index) => { + self.ctx.display().set_accept_option(index); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::CursorShapeDropdown => { + self.ctx + .display() + .toggle_settings_dropdown(crate::display::SettingsDropdown::CursorShape); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::CursorShapeOption(index) => { + if self.ctx.display().set_cursor_shape_option(index) { + self.ctx.apply_default_cursor_style(); + } + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::CursorBlinkToggle => { + self.ctx.display().toggle_cursor_blink(); + self.ctx.apply_default_cursor_style(); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::CopyOnSelectToggle => { + self.ctx.display().toggle_copy_on_select(); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::FontSizeUp => { + let ui_scale = self.ctx.display().window.scale_factor as f32; + self.ctx.change_font_size(ui_scale); + self.ctx.display().persist_nebula_settings(); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::FontSizeDown => { + let ui_scale = self.ctx.display().window.scale_factor as f32; + self.ctx.change_font_size(-ui_scale); + self.ctx.display().persist_nebula_settings(); self.ctx.mark_dirty(); return; }, @@ -765,26 +797,83 @@ impl> Processor { self.ctx.mark_dirty(); return; }, - crate::display::SettingsHit::OpacityDown => { - self.ctx.display().adjust_window_opacity(-0.05); - self.ctx.mark_dirty(); - return; - }, - crate::display::SettingsHit::OpacityUp => { - self.ctx.display().adjust_window_opacity(0.05); + crate::display::SettingsHit::OpacitySlider => { + self.ctx.display().begin_settings_opacity_drag( + crate::display::SettingsOpacityTarget::Terminal, + x, + ); self.ctx.mark_dirty(); return; }, crate::display::SettingsHit::BackgroundColor => { - self.ctx.display().cycle_background_color(); + // 打开色板 + 16 进制输入浮层(不再点击循环切换)。 + self.ctx.display().open_background_color_picker(); self.ctx.mark_dirty(); return; }, + crate::display::SettingsHit::BackgroundSwatch(index) => { + self.ctx.display().set_background_color_option(index); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::BackgroundHexInput => { + self.ctx.display().focus_bg_hex_input(); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::BackgroundPopupPanel => { + // 浮层内部空白:吞掉点击,浮层保持展开。 + return; + }, crate::display::SettingsHit::BackgroundImage => { self.ctx.display().pick_background_image(); self.ctx.mark_dirty(); return; }, + crate::display::SettingsHit::BackgroundImageClear => { + self.ctx.display().clear_background_image(); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::BackgroundImageFit => { + self.ctx + .display() + .toggle_settings_dropdown( + crate::display::SettingsDropdown::BackgroundFit, + ); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::FitOption(index) => { + self.ctx.display().set_background_image_fit_option(index); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::BackgroundImageAlignment => { + self.ctx.display().toggle_settings_dropdown( + crate::display::SettingsDropdown::BackgroundAlignment, + ); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::AlignOption(index) => { + self.ctx.display().set_background_image_alignment_option(index); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::BackgroundImageOpacitySlider => { + self.ctx.display().begin_settings_opacity_drag( + crate::display::SettingsOpacityTarget::BackgroundImage, + x, + ); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::BackgroundImageCoverChrome => { + self.ctx.display().request_toggle_background_image_cover_chrome(); + self.ctx.mark_dirty(); + return; + }, crate::display::SettingsHit::OpenConfigFile => { self.ctx.display().open_user_config_file(); self.ctx.mark_dirty(); @@ -916,7 +1005,7 @@ impl> Processor { return; }, crate::display::ChromeHit::Maximize => { - self.ctx.window().toggle_maximized(); + self.ctx.window().toggle_maximized_or_restore(); return; }, crate::display::ChromeHit::TitleBar => { @@ -931,7 +1020,7 @@ impl> Processor { // terminal's selection arming, which clamps the point into the // nearest grid cell — dragging from the sidebar then painted a // stray selection across the pane (the "drag ghost"). - let window_size = self.ctx.display().size_info; + let window_size = self.ctx.display().ui_size_info(); let scale = self.ctx.window().scale_factor as f32; if crate::display::in_chrome_bar(&window_size, scale, x, y) { crate::display::nebula_debug_log(format!( @@ -1012,3 +1101,32 @@ impl> Processor { } } } + +/// Hits that must NOT dismiss an expanded settings dropdown: its own option +/// rows and the anchor rows (the anchor click toggles the dropdown itself). +/// Everything else closes the floating list first — a press outside a popup +/// never activates the control underneath it. +fn settings_dropdown_keeps_open(hit: crate::display::SettingsHit) -> bool { + use crate::display::SettingsHit as Hit; + matches!( + hit, + Hit::ShellCycle + | Hit::ShellPickerRow(_) + | Hit::FontCycle + | Hit::FontPickerRow(_) + | Hit::BackgroundImageFit + | Hit::FitOption(_) + | Hit::BackgroundImageAlignment + | Hit::AlignOption(_) + | Hit::LanguageDropdown + | Hit::Language(_) + | Hit::AcceptCycle + | Hit::AcceptOption(_) + | Hit::CursorShapeDropdown + | Hit::CursorShapeOption(_) + | Hit::BackgroundColor + | Hit::BackgroundSwatch(_) + | Hit::BackgroundHexInput + | Hit::BackgroundPopupPanel + ) +} diff --git a/nebula_app/src/input/keyboard.rs b/nebula_app/src/input/keyboard.rs index fa09347..174fe5e 100644 --- a/nebula_app/src/input/keyboard.rs +++ b/nebula_app/src/input/keyboard.rs @@ -48,11 +48,38 @@ impl> Processor { } } - // The inline default-shell list is a transient picker. Esc only + // 背景色浮层的 16 进制输入独占键盘:Enter 应用、Esc 关闭、退格删 + // 除、hex 字符追加;dropdown 通用的"任意键关闭"对它不适用。 + if self.ctx.display().nebula_settings_dropdown + == Some(crate::display::SettingsDropdown::BackgroundColor) + && self.ctx.display().nebula_bg_hex_active + { + match &key.logical_key { + Key::Named(NamedKey::Enter) => { + self.ctx.display().bg_hex_commit(); + }, + Key::Named(NamedKey::Escape) => { + self.ctx.display().close_settings_dropdown(); + }, + Key::Named(NamedKey::Backspace) => { + self.ctx.display().bg_hex_backspace(); + }, + Key::Character(text) => { + for ch in text.chars() { + self.ctx.display().bg_hex_push(ch); + } + }, + _ => {}, + } + self.ctx.mark_dirty(); + return; + } + + // An expanded settings dropdown is a transient picker. Esc only // dismisses; any other key dismisses and then follows its normal path. - if self.ctx.display().nebula_shell_picker_open { + if self.ctx.display().nebula_settings_dropdown.is_some() { let escape = matches!(key.logical_key, Key::Named(NamedKey::Escape)); - self.ctx.display().close_shell_picker(); + self.ctx.display().close_settings_dropdown(); self.ctx.mark_dirty(); if escape { return; diff --git a/nebula_app/src/input/mod.rs b/nebula_app/src/input/mod.rs index 8caf9db..4b98ae9 100644 --- a/nebula_app/src/input/mod.rs +++ b/nebula_app/src/input/mod.rs @@ -112,6 +112,9 @@ pub trait ActionContext { fn create_new_window(&mut self) {} fn change_font_size(&mut self, _delta: f32) {} fn reset_font_size(&mut self) {} + /// Re-sync the focused terminal's default cursor style (shape + blink) + /// from the Nebula settings and reschedule blinking. + fn apply_default_cursor_style(&mut self) {} fn pop_message(&mut self) {} fn message(&self) -> Option<&Message>; fn config(&self) -> &UiConfig; diff --git a/nebula_app/src/input/mouse.rs b/nebula_app/src/input/mouse.rs index 09532d0..0229d1d 100644 --- a/nebula_app/src/input/mouse.rs +++ b/nebula_app/src/input/mouse.rs @@ -37,7 +37,7 @@ impl> Processor { let size_info = self.ctx.size_info(); // Chrome (tabs / title bar / settings) is laid out on the *window*, // not the focused pane's viewport; in split mode the two differ. - let window_size = self.ctx.display().size_info; + let window_size = self.ctx.display().ui_size_info(); let (x, y) = position.into(); @@ -57,6 +57,17 @@ impl> Processor { self.ctx.mouse_mut().x = x; self.ctx.mouse_mut().y = y; + if self.ctx.display().nebula_settings_opacity_drag.is_some() { + if lmb_pressed { + self.ctx.display().update_settings_opacity_drag(x as f32); + } else { + self.ctx.display().finish_settings_opacity_drag(); + } + self.ctx.window().set_mouse_cursor(CursorIcon::EwResize); + self.ctx.mark_dirty(); + return; + } + // The SSH editor is modal. Its controls own hover/cursor feedback while // open, and the closing animation swallows pointer motion until the // retained editor snapshot is released. @@ -134,9 +145,8 @@ impl> Processor { let settings_open = self.ctx.display().settings_open(); let settings_section = self.ctx.display().settings_section(); let settings_scroll = self.ctx.display().settings_scroll(); - let shell_picker_open = self.ctx.display().nebula_shell_picker_open; + let settings_dropdown = self.ctx.display().nebula_settings_dropdown; let shell_picker_count = self.ctx.display().shell_picker_count(); - let font_picker_open = self.ctx.display().nebula_font_picker_open; let font_picker_count = self.ctx.display().font_picker_count(); let hidden_host_count = self.ctx.display().hidden_ssh_host_count(); let settings_area = self.ctx.display().terminal_card_rect(); @@ -162,6 +172,42 @@ impl> Processor { self.ctx.window().set_mouse_cursor(CursorIcon::Pointer); return; } + // Command palette is a pointer-modal overlay: rows light up under the + // cursor and NOTHING below (settings rows, chrome, links) may react + // while it is open. This must run BEFORE the settings/chrome hover + // dispatch — those branches `return`, which is exactly how palette + // row hover silently died before. + if self.ctx.display().command_palette_open() { + let px = x as f32; + let py = y as f32; + let layout = crate::display::command_palette::palette_layout( + window_size.width(), + window_size.height(), + scale, + ); + let (ix, iy, iw, ih) = layout.panel; + // Hover detection: the pointer must be inside the panel rectangle, + // AND below the input box (list_y), AND the computed row must be a + // real item (< visible count). Without the visible-count check, rows + // beyond the filtered list — empty space inside the panel — also hover. + let hover_row = if px >= ix && px < ix + iw && py >= layout.list_y && py < iy + ih { + let row = ((py - layout.list_y) / layout.row_h) as usize; + let visible_count = + self.ctx.display().nebula_palette_visible_count().min(layout.max_rows); + if row < visible_count { Some(row) } else { None } + } else { + None + }; + if self.ctx.display().palette_hover(hover_row) { + self.ctx.mark_dirty(); + } + self.ctx.window().set_mouse_cursor(if hover_row.is_some() { + CursorIcon::Pointer + } else { + CursorIcon::Default + }); + return; + } let settings_hover = crate::display::settings_hit( &window_size, scale, @@ -171,9 +217,8 @@ impl> Processor { settings_open, settings_section, settings_scroll, - shell_picker_open, + settings_dropdown, shell_picker_count, - font_picker_open, font_picker_count, hidden_host_count, ); @@ -231,16 +276,38 @@ impl> Processor { | crate::display::SettingsHit::FetchToggle | crate::display::SettingsHit::PowerlineToggle | crate::display::SettingsHit::KeepSessionToggle - | crate::display::SettingsHit::OpacityDown - | crate::display::SettingsHit::OpacityUp | crate::display::SettingsHit::BackgroundColor | crate::display::SettingsHit::BackgroundImage + | crate::display::SettingsHit::BackgroundImageClear + | crate::display::SettingsHit::BackgroundImageFit + | crate::display::SettingsHit::BackgroundImageAlignment + | crate::display::SettingsHit::FitOption(_) + | crate::display::SettingsHit::AlignOption(_) + | crate::display::SettingsHit::BackgroundSwatch(_) + | crate::display::SettingsHit::BackgroundHexInput + | crate::display::SettingsHit::AcceptOption(_) + | crate::display::SettingsHit::LanguageDropdown + | crate::display::SettingsHit::CursorShapeDropdown + | crate::display::SettingsHit::CursorShapeOption(_) + | crate::display::SettingsHit::CursorBlinkToggle + | crate::display::SettingsHit::CopyOnSelectToggle + | crate::display::SettingsHit::FontSizeUp + | crate::display::SettingsHit::FontSizeDown + | crate::display::SettingsHit::BackgroundImageCoverChrome | crate::display::SettingsHit::OpenConfigFile | crate::display::SettingsHit::Reset => { self.ctx.window().set_mouse_cursor(CursorIcon::Pointer); return; }, - crate::display::SettingsHit::Panel => { + crate::display::SettingsHit::OpacitySlider + | crate::display::SettingsHit::BackgroundImageOpacitySlider => { + // WT 风格滑块用普通指针:双向箭头(EwResize)让拖动读起来像 + // 在改窗口大小,是用户报告的"手势不对"。 + self.ctx.window().set_mouse_cursor(CursorIcon::Pointer); + return; + }, + crate::display::SettingsHit::Panel + | crate::display::SettingsHit::BackgroundPopupPanel => { self.ctx.window().set_mouse_cursor(CursorIcon::Default); return; }, @@ -341,31 +408,9 @@ impl> Processor { self.ctx.mark_dirty(); } - // Command palette hover: light up the row under the cursor so the user - // knows where they'd click. Only track while the palette is open. - if self.ctx.display().command_palette_open() { - let px = x as f32; - let py = y as f32; - let layout = crate::display::command_palette::palette_layout( - window_size.width(), - window_size.height(), - scale, - ); - let (ix, iy, iw, ih) = layout.panel; - // Hover detection: the pointer must be inside the panel rectangle, - // AND below the input box (list_y), AND the computed row must be a - // real item (< visible count). Without the visible-count check, rows - // beyond the filtered list — empty space inside the panel — also hover. - let hover_row = if px >= ix && px < ix + iw && py >= layout.list_y && py < iy + ih { - let row = ((py - layout.list_y) / layout.row_h) as usize; - let visible_count = - self.ctx.display().nebula_palette_visible_count().min(layout.max_rows); - if row < visible_count { Some(row) } else { None } - } else { - None - }; - self.ctx.display().palette_hover(hover_row); - } + // Command palette hover moved to the TOP of this handler (pointer + // modal): earlier hover branches `return` and were silently starving + // the palette of hover updates. let inside_text_area = size_info.contains_point(x, y); let cell_side = self.cell_side(x); @@ -671,6 +716,10 @@ impl> Processor { // even while a TUI has grabbed the mouse. A plain click (never dragged) // returns `None` here and falls through to normal release handling. if button == MouseButton::Left { + if self.ctx.display().finish_settings_opacity_drag() { + self.ctx.mark_dirty(); + return; + } // Drop a dragged tree entry: released over the terminal (anywhere // off the drawer) pastes its full path but never presses Enter. // A directory that never crossed the threshold retains its normal @@ -770,6 +819,21 @@ impl> Processor { } pub fn mouse_wheel_input(&mut self, delta: MouseScrollDelta, phase: TouchPhase) { + // Ctrl+wheel zooms the terminal font (Windows Terminal / browser + // convention). Checked before every scroll consumer so zoom wins over + // page/drawer/grid scrolling while the modifier is held. + if self.ctx.modifiers().state().control_key() { + let steps = match delta { + MouseScrollDelta::LineDelta(_, lines) => lines, + MouseScrollDelta::PixelDelta(pos) => pos.y.signum() as f32, + }; + if steps != 0.0 { + let scale = self.ctx.window().scale_factor as f32; + self.ctx.change_font_size(steps.signum() * scale); + } + return; + } + // The Settings tab captures the wheel: scroll its page instead of the // sink terminal used for special-tab input routing. if self.ctx.display().settings_open() { diff --git a/nebula_app/src/renderer/image.rs b/nebula_app/src/renderer/image.rs index 3e4eaf0..62d65df 100644 --- a/nebula_app/src/renderer/image.rs +++ b/nebula_app/src/renderer/image.rs @@ -24,6 +24,124 @@ use crate::renderer::shader::{ShaderProgram, ShaderVersion}; const IMAGE_SHADER_F: &str = include_str!("../../res/image.f.glsl"); const IMAGE_SHADER_V: &str = include_str!("../../res/image.v.glsl"); +/// Windows Terminal-compatible wallpaper sizing modes. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum BackgroundImageFit { + /// Distort the image to exactly match the window. + Fill, + /// Preserve aspect ratio and keep the entire image visible. + Uniform, + /// Preserve aspect ratio and crop the overflow (CSS `cover`). + #[default] + UniformToFill, + /// Draw at the image's native pixel size. + None, +} + +impl BackgroundImageFit { + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "fill" | "stretch" => Some(Self::Fill), + "uniform" | "contain" => Some(Self::Uniform), + "uniform_to_fill" | "uniformtofill" | "cover" => Some(Self::UniformToFill), + "none" | "native" => Some(Self::None), + _ => None, + } + } + + pub const fn settings_value(self) -> &'static str { + match self { + Self::Fill => "fill", + Self::Uniform => "uniform", + Self::UniformToFill => "uniform_to_fill", + Self::None => "none", + } + } + + pub const fn next(self) -> Self { + match self { + Self::Fill => Self::Uniform, + Self::Uniform => Self::UniformToFill, + Self::UniformToFill => Self::None, + Self::None => Self::Fill, + } + } +} + +/// Anchor used when the fitted wallpaper is larger or smaller than the window. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum BackgroundImageAlignment { + TopLeft, + Top, + TopRight, + Left, + #[default] + Center, + Right, + BottomLeft, + Bottom, + BottomRight, +} + +impl BackgroundImageAlignment { + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().replace('_', "-").as_str() { + "top-left" => Some(Self::TopLeft), + "top" => Some(Self::Top), + "top-right" => Some(Self::TopRight), + "left" => Some(Self::Left), + "center" | "centre" => Some(Self::Center), + "right" => Some(Self::Right), + "bottom-left" => Some(Self::BottomLeft), + "bottom" => Some(Self::Bottom), + "bottom-right" => Some(Self::BottomRight), + _ => None, + } + } + + pub const fn settings_value(self) -> &'static str { + match self { + Self::TopLeft => "top_left", + Self::Top => "top", + Self::TopRight => "top_right", + Self::Left => "left", + Self::Center => "center", + Self::Right => "right", + Self::BottomLeft => "bottom_left", + Self::Bottom => "bottom", + Self::BottomRight => "bottom_right", + } + } + + pub const fn next(self) -> Self { + match self { + Self::TopLeft => Self::Top, + Self::Top => Self::TopRight, + Self::TopRight => Self::Left, + Self::Left => Self::Center, + Self::Center => Self::Right, + Self::Right => Self::BottomLeft, + Self::BottomLeft => Self::Bottom, + Self::Bottom => Self::BottomRight, + Self::BottomRight => Self::TopLeft, + } + } + + const fn factors(self) -> (f32, f32) { + match self { + Self::TopLeft => (0.0, 0.0), + Self::Top => (0.5, 0.0), + Self::TopRight => (1.0, 0.0), + Self::Left => (0.0, 0.5), + Self::Center => (0.5, 0.5), + Self::Right => (1.0, 0.5), + Self::BottomLeft => (0.0, 1.0), + Self::Bottom => (0.5, 1.0), + Self::BottomRight => (1.0, 1.0), + } + } +} + #[repr(C)] #[derive(Debug, Clone, Copy)] struct ImageVertex { @@ -43,7 +161,7 @@ struct CachedImage { impl CachedImage { fn load(path: &Path) -> Result { - let decoded = decode_png(path)?; + let decoded = decode_background_image(path)?; let texture = upload_rgba_texture(decoded.width, decoded.height, &decoded.rgba); Ok(Self { path: path.to_path_buf(), texture, width: decoded.width, height: decoded.height }) } @@ -91,13 +209,25 @@ struct DecodedImage { rgba: Vec, } -#[cfg(all(feature = "png", not(target_os = "macos")))] -fn decode_png(path: &Path) -> Result { +#[cfg(not(target_os = "macos"))] +fn decode_background_image(path: &Path) -> Result { use std::fs::File; use std::io::BufReader; let file = File::open(path).map_err(|err| format!("open {path:?}: {err}"))?; - decode_png_reader(BufReader::new(file)) + decode_background_reader(BufReader::new(file)) +} + +#[cfg(not(target_os = "macos"))] +fn decode_background_reader( + reader: R, +) -> Result { + let reader = ::image::ImageReader::new(reader) + .with_guessed_format() + .map_err(|err| format!("detect image format: {err}"))?; + let decoded = reader.decode().map_err(|err| format!("decode image: {err}"))?; + let rgba = decoded.to_rgba8(); + Ok(DecodedImage { width: rgba.width(), height: rgba.height(), rgba: rgba.into_raw() }) } /// Decode PNG bytes (an OSC 1337 payload) into RGBA8. Public so the event @@ -159,9 +289,9 @@ fn decode_png_reader(reader: R) -> Result Result { - Err("PNG background support is not enabled for this build".to_owned()) +#[cfg(target_os = "macos")] +fn decode_background_image(_path: &Path) -> Result { + Err("Background image support is not enabled for this build".to_owned()) } /// A GPU texture for one inline image, keyed by the image's id. @@ -185,6 +315,8 @@ pub struct ImageRenderer { program: ShaderProgram, u_texture: GLint, u_opacity: GLint, + u_clip_rect: GLint, + u_clip_radius: GLint, image: Option, failed_path: Option, /// Lazily-uploaded textures for OSC 1337 inline images. @@ -196,6 +328,8 @@ impl ImageRenderer { let program = ShaderProgram::new(shader_version, None, IMAGE_SHADER_V, IMAGE_SHADER_F)?; let u_texture = program.get_uniform_location(c"uTexture")?; let u_opacity = program.get_uniform_location(c"uOpacity")?; + let u_clip_rect = program.get_uniform_location(c"uClipRect")?; + let u_clip_radius = program.get_uniform_location(c"uClipRadius")?; let mut vao: GLuint = 0; let mut vbo: GLuint = 0; @@ -225,6 +359,8 @@ impl ImageRenderer { program, u_texture, u_opacity, + u_clip_rect, + u_clip_radius, image: None, failed_path: None, inline: HashMap::new(), @@ -250,9 +386,7 @@ impl ImageRenderer { let texture = self .inline .entry(id) - .or_insert_with(|| InlineTexture { - texture: upload_rgba_texture(px.0, px.1, rgba), - }) + .or_insert_with(|| InlineTexture { texture: upload_rgba_texture(px.0, px.1, rgba) }) .texture; let (x, y, w, h) = rect; @@ -267,6 +401,9 @@ impl ImageRenderer { gl::BindTexture(gl::TEXTURE_2D, texture); gl::Uniform1i(self.u_texture, 0); gl::Uniform1f(self.u_opacity, 1.0); + // Inline images are plain rects; stale wallpaper clip state must + // not eat their corners. + gl::Uniform1f(self.u_clip_radius, 0.0); gl::BufferData( gl::ARRAY_BUFFER, @@ -288,7 +425,18 @@ impl ImageRenderer { self.inline.retain(|id, _| alive(*id)); } - pub fn draw(&mut self, size_info: &SizeInfo, path: &Path, opacity: f32) { + #[allow(clippy::too_many_arguments)] + pub fn draw( + &mut self, + size_info: &SizeInfo, + path: &Path, + opacity: f32, + fit: BackgroundImageFit, + alignment: BackgroundImageAlignment, + target: (f32, f32, f32, f32), + clip: (f32, f32, f32, f32), + clip_radius: f32, + ) { if opacity <= 0.0 || !self.ensure_image(path) { return; } @@ -298,11 +446,14 @@ impl ImageRenderer { None => return, }; - let vertices = cover_vertices( + let vertices = wallpaper_vertices( size_info.width(), size_info.height(), + target, image.width as f32, image.height as f32, + fit, + alignment, ); unsafe { @@ -314,6 +465,15 @@ impl ImageRenderer { gl::BindTexture(gl::TEXTURE_2D, image.texture); gl::Uniform1i(self.u_texture, 0); gl::Uniform1f(self.u_opacity, opacity.clamp(0.0, 1.0)); + // The card is rounded; clip the wallpaper to a rounded rect + // (shader SDF) or its square corners paint over the card radius. + // `clip` usually equals `target`, but scrolled previews pass a + // sub-band so the image cannot bleed over the settings header. + // Rects are top-left-origin window px — gl_FragCoord is + // bottom-left, so flip Y here. + let (cx, cy, cw, ch) = clip; + gl::Uniform4f(self.u_clip_rect, cx, size_info.height() - cy - ch, cw, ch); + gl::Uniform1f(self.u_clip_radius, clip_radius.max(0.0)); gl::BufferData( gl::ARRAY_BUFFER, @@ -375,21 +535,54 @@ impl Drop for ImageRenderer { } } -fn cover_vertices(window_w: f32, window_h: f32, image_w: f32, image_h: f32) -> [ImageVertex; 6] { +fn wallpaper_vertices( + window_w: f32, + window_h: f32, + target: (f32, f32, f32, f32), + image_w: f32, + image_h: f32, + fit: BackgroundImageFit, + alignment: BackgroundImageAlignment, +) -> [ImageVertex; 6] { + let (target_x, target_y, target_w, target_h) = target; + let (x, y, width, height) = + wallpaper_rect(target_w, target_h, image_w, image_h, fit, alignment); + quad_vertices(window_w.max(1.0), window_h.max(1.0), x, y, width, height).map(|mut vertex| { + vertex.x += target_x / (window_w.max(1.0) * 0.5); + vertex.y -= target_y / (window_h.max(1.0) * 0.5); + vertex + }) +} + +fn wallpaper_rect( + window_w: f32, + window_h: f32, + image_w: f32, + image_h: f32, + fit: BackgroundImageFit, + alignment: BackgroundImageAlignment, +) -> (f32, f32, f32, f32) { let window_w = window_w.max(1.0); let window_h = window_h.max(1.0); let image_w = image_w.max(1.0); let image_h = image_h.max(1.0); - // CSS `background-size: cover`: preserve aspect ratio, crop only at the - // edges. This avoids the "stretched wallpaper" feel that makes terminals - // look cheap. - let scale = (window_w / image_w).max(window_h / image_h); - let draw_w = image_w * scale; - let draw_h = image_h * scale; - let x0 = (window_w - draw_w) * 0.5; - let y0 = (window_h - draw_h) * 0.5; - quad_vertices(window_w, window_h, x0, y0, draw_w, draw_h) + let (draw_w, draw_h) = match fit { + BackgroundImageFit::Fill => (window_w, window_h), + BackgroundImageFit::Uniform => { + let scale = (window_w / image_w).min(window_h / image_h); + (image_w * scale, image_h * scale) + }, + BackgroundImageFit::UniformToFill => { + let scale = (window_w / image_w).max(window_h / image_h); + (image_w * scale, image_h * scale) + }, + BackgroundImageFit::None => (image_w, image_h), + }; + let (align_x, align_y) = alignment.factors(); + let x0 = (window_w - draw_w) * align_x; + let y0 = (window_h - draw_h) * align_y; + (x0, y0, draw_w, draw_h) } /// Vertices for an image drawn 1:1 at an explicit pixel rect. @@ -430,3 +623,58 @@ fn quad_vertices( [tl, bl, tr, tr, br, bl] } + +#[cfg(test)] +mod tests { + use super::{BackgroundImageAlignment as Align, BackgroundImageFit as Fit, wallpaper_rect}; + + #[test] + fn uniform_to_fill_crops_and_honors_alignment() { + let centered = + wallpaper_rect(1000.0, 500.0, 400.0, 400.0, Fit::UniformToFill, Align::Center); + let right = wallpaper_rect(1000.0, 500.0, 400.0, 400.0, Fit::UniformToFill, Align::Right); + + assert_eq!(centered, (0.0, -250.0, 1000.0, 1000.0)); + assert_eq!(right, (0.0, -250.0, 1000.0, 1000.0)); + } + + #[test] + fn uniform_contains_and_bottom_right_anchors_the_spare_space() { + let rect = wallpaper_rect(1000.0, 500.0, 400.0, 400.0, Fit::Uniform, Align::BottomRight); + assert_eq!(rect, (500.0, 0.0, 500.0, 500.0)); + } + + #[test] + fn native_size_uses_all_nine_alignment_anchors() { + let top_left = wallpaper_rect(1000.0, 500.0, 200.0, 100.0, Fit::None, Align::TopLeft); + let center = wallpaper_rect(1000.0, 500.0, 200.0, 100.0, Fit::None, Align::Center); + let bottom_right = + wallpaper_rect(1000.0, 500.0, 200.0, 100.0, Fit::None, Align::BottomRight); + + assert_eq!(top_left, (0.0, 0.0, 200.0, 100.0)); + assert_eq!(center, (400.0, 200.0, 200.0, 100.0)); + assert_eq!(bottom_right, (800.0, 400.0, 200.0, 100.0)); + } + + #[test] + fn persisted_aliases_parse_without_case_sensitivity() { + assert_eq!(Fit::parse("UniformToFill"), Some(Fit::UniformToFill)); + assert_eq!(Fit::parse("contain"), Some(Fit::Uniform)); + assert_eq!(Align::parse("BOTTOM_RIGHT"), Some(Align::BottomRight)); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn jpeg_selected_by_the_file_picker_decodes_to_rgba() { + let pixels = [255, 0, 0, 0, 255, 0]; + let mut encoded = Vec::new(); + ::image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, 90) + .encode(&pixels, 2, 1, ::image::ExtendedColorType::Rgb8) + .expect("encode fixture"); + + let decoded = super::decode_background_reader(std::io::Cursor::new(encoded)) + .expect("decode JPEG background"); + assert_eq!((decoded.width, decoded.height), (2, 1)); + assert_eq!(decoded.rgba.len(), 2 * 4); + } +} diff --git a/nebula_app/src/renderer/mod.rs b/nebula_app/src/renderer/mod.rs index 46d1364..5f3cb19 100644 --- a/nebula_app/src/renderer/mod.rs +++ b/nebula_app/src/renderer/mod.rs @@ -22,7 +22,7 @@ use crate::display::SizeInfo; use crate::display::color::Rgb; use crate::display::content::RenderableCell; use crate::gl; -use crate::renderer::image::ImageRenderer; +use crate::renderer::image::{BackgroundImageAlignment, BackgroundImageFit, ImageRenderer}; use crate::renderer::math::{MathClip, MathRenderer}; use crate::renderer::rects::{RectRenderer, RenderRect}; use crate::renderer::shader::ShaderError; @@ -104,6 +104,11 @@ pub struct Renderer { image_renderer: ImageRenderer, math_renderer: MathRenderer, robustness: bool, + /// UI 文本补偿比率 = 配置字号 / 当前终端字号,见 + /// `Display::ui_text_scale`。chrome 系列文本按它真栅格化,配合调用方 + /// 传入的 ui 化 `SizeInfo`(cell × 同一比率),让 Ctrl+滚轮缩放只改 + /// 终端网格、UI 字号视觉恒定。1.0 = 无补偿(快路径)。 + ui_text_scale: f32, /// Full window height in physical pixels, used to flip pane viewports from /// top-down `SizeInfo` coordinates into OpenGL's bottom-left origin. Set /// each frame from the window's `SizeInfo`. @@ -203,10 +208,16 @@ impl Renderer { image_renderer, math_renderer, robustness, + ui_text_scale: 1.0, window_height: std::cell::Cell::new(0.0), }) } + /// Set the per-frame UI text compensation ratio (1.0 disables it). + pub fn set_ui_text_scale(&mut self, scale: f32) { + self.ui_text_scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + } + pub fn draw_cells>( &mut self, size_info: &SizeInfo, @@ -330,6 +341,34 @@ impl Renderer { /// Draw a chrome label at an arbitrary pixel position (top-left of the first /// cell), with a transparent background so the underlying pill shows. + /// Ink bounds of a single chrome glyph relative to the cell's top-left pen + /// origin, in physical px: `(left, top, width, height)`. Nerd Font marks + /// often ink outside their nominal 1-column advance, so grid centering + /// leaves them visibly off inside their hover pills; this measures the + /// REAL raster so callers can optically center. + pub fn chrome_glyph_ink( + &mut self, + glyph_cache: &mut GlyphCache, + size_info: &SizeInfo, + character: char, + ) -> Option<(f32, f32, f32, f32)> { + // 量取按 UI 补偿后的真实字号(与 draw_chrome_text 的栅格化一致), + // 否则终端缩放时 pill 内的光学居中会按错误的墨迹框计算。 + let glyph_key = crossfont::GlyphKey { + font_key: glyph_cache.font_key, + size: glyph_cache.font_size.scale(self.ui_text_scale), + character, + }; + let glyph = self.with_loader(|mut api| glyph_cache.get(glyph_key, &mut api, false)); + if glyph.width <= 0 || glyph.height <= 0 { + return None; + } + // draw_cells places ink at `cell_bottom - glyph.top` (see the gles2 / + // glsl3 vertex assembly), so measured from the cell TOP the ink sits: + let top = size_info.cell_height() - glyph.top as f32; + Some((glyph.left as f32, top, glyph.width as f32, glyph.height as f32)) + } + pub fn draw_chrome_text( &mut self, size_info: &SizeInfo, @@ -345,6 +384,12 @@ impl Renderer { /// [`Self::draw_chrome_text`] with extra cell style flags. `BOLD`/`ITALIC` /// select the real bold/italic faces in `draw_cells` — the document viewer /// uses this so emphasis is carried by the typeface, not just ink color. + /// + /// When [`Self::ui_text_scale`] is active the glyphs are re-rasterized at + /// the compensated size, so UI labels stay visually anchored to the config + /// font size while Ctrl+wheel zooms only the terminal grid. Callers pass a + /// ui-scaled `SizeInfo` (`Display::ui_size_info`) so the cell advance + /// matches the rasterized size. pub fn draw_chrome_text_styled( &mut self, size_info: &SizeInfo, @@ -354,6 +399,31 @@ impl Renderer { style: Flags, text: &str, glyph_cache: &mut GlyphCache, + ) { + let ratio = self.ui_text_scale; + if (ratio - 1.0).abs() < 0.01 { + self.draw_chrome_text_at(size_info, x, y, fg, style, text, glyph_cache); + } else { + let base_size = glyph_cache.font_size; + glyph_cache.font_size = base_size.scale(ratio); + self.draw_chrome_text_at(size_info, x, y, fg, style, text, glyph_cache); + glyph_cache.font_size = base_size; + } + } + + /// Chrome text at the glyph cache's CURRENT font size, stepped by the + /// passed `SizeInfo` cells. Internal: [`Self::draw_chrome_text_styled`] + /// wraps this with the UI compensation ratio; [`Self::draw_doc_text_tracked`] + /// calls it directly so document text keeps following the terminal zoom. + fn draw_chrome_text_at( + &mut self, + size_info: &SizeInfo, + x: f32, + y: f32, + fg: Rgb, + style: Flags, + text: &str, + glyph_cache: &mut GlyphCache, ) { self.begin_chrome_text(size_info, x, y); @@ -405,7 +475,10 @@ impl Renderer { text: &str, glyph_cache: &mut GlyphCache, ) -> f32 { - self.begin_chrome_text_scaled(size_info, x, y, mult); + // The atlas bitmaps are terminal-font-sized: stretch them by the UI + // compensation ratio too, so the visual size matches the ui-scaled + // cell advance the caller lays out with. + self.begin_chrome_text_scaled(size_info, x, y, mult * self.ui_text_scale); let mut col = 0usize; let cells = text.chars().filter_map(|character| { @@ -478,7 +551,9 @@ impl Renderer { ) -> f32 { let cell_w = size_info.cell_width(); if (scale - 1.0).abs() < 0.01 && tracking.abs() < 0.01 { - self.draw_chrome_text_styled(size_info, x, y, fg, style, text, glyph_cache); + // Document text tracks the terminal zoom by design (md/txt viewers + // may scale) — bypass the UI compensation wrapper. + self.draw_chrome_text_at(size_info, x, y, fg, style, text, glyph_cache); let cols: usize = text.chars().map(|c| c.width().unwrap_or(0)).sum(); return cols as f32 * cell_w; } @@ -609,14 +684,16 @@ impl Renderer { let top = (baseline_y + rule.y).max(clip.top); let right = (origin_x + rule.x + rule.width).min(clip.right); let bottom = (baseline_y + rule.y + rule.height.max(1.0)).min(clip.bottom); - (right > left && bottom > top).then(|| UiQuad::solid( - left, - top, - right - left, - bottom - top, - 0.0, - crate::renderer::ui::Rgba::opaque(color), - )) + (right > left && bottom > top).then(|| { + UiQuad::solid( + left, + top, + right - left, + bottom - top, + 0.0, + crate::renderer::ui::Rgba::opaque(color), + ) + }) }) .collect(); self.draw_ui(size_info, &rules); @@ -633,8 +710,23 @@ impl Renderer { result } - /// Draw a full-window background image using CSS-like `cover` scaling. - pub fn draw_background_image(&mut self, size_info: &SizeInfo, path: &Path, opacity: f32) { + /// Draw a background image with Windows Terminal-compatible sizing and + /// alignment semantics. `target` is the rect the image is fitted into; + /// `clip` is the rect it may actually touch (usually the same, a sub-band + /// for scrolled previews). `clip_radius > 0` additionally rounds the + /// clip rect's corners via the shader SDF. + #[allow(clippy::too_many_arguments)] + pub fn draw_background_image( + &mut self, + size_info: &SizeInfo, + path: &Path, + opacity: f32, + fit: BackgroundImageFit, + alignment: BackgroundImageAlignment, + target: (f32, f32, f32, f32), + clip: (f32, f32, f32, f32), + clip_radius: f32, + ) { if opacity <= 0.0 { return; } @@ -642,14 +734,25 @@ impl Renderer { unsafe { gl::Viewport(0, 0, size_info.width() as i32, size_info.height() as i32); gl::BlendFuncSeparate(gl::SRC_ALPHA, gl::ONE_MINUS_SRC_ALPHA, gl::SRC_ALPHA, gl::ONE); + // Cover/native sizing can extend past the selected terminal area. + // A scissor keeps the default wallpaper out of persistent chrome. + let (x, y, width, height) = clip; + gl::Enable(gl::SCISSOR_TEST); + gl::Scissor( + x.round() as i32, + (size_info.height() - y - height).round() as i32, + width.round().max(0.0) as i32, + height.round().max(0.0) as i32, + ); } - self.image_renderer.draw(size_info, path, opacity); + self.image_renderer.draw(size_info, path, opacity, fit, alignment, target, clip, clip_radius); // The image pass rebinds TEXTURE_2D behind the text renderer's back; // drop its cached atlas binding or every later glyph goes invisible. self.invalidate_text_texture_cache(); unsafe { + gl::Disable(gl::SCISSOR_TEST); gl::BlendFunc(gl::SRC1_COLOR, gl::ONE_MINUS_SRC1_COLOR); self.set_viewport(size_info); } diff --git a/nebula_app/src/renderer/ui.rs b/nebula_app/src/renderer/ui.rs index ba7ab8e..e7050a6 100644 --- a/nebula_app/src/renderer/ui.rs +++ b/nebula_app/src/renderer/ui.rs @@ -250,6 +250,44 @@ impl UiQuad { } } + /// Shell filler for one corner of a rounded card: an `r × r` block that + /// paints only the area OUTSIDE the card's quarter-circle corner. Placed + /// over the card's corner, it lets a square shell frame wrap a rounded + /// card with complementary antialiasing — same circle center and radius + /// as the card's convex corner, so coverage sums to exactly one across + /// the arc (no seam, no double cover). `corner` follows the + /// `corner_radii` order on the CARD: 0 top-left, 1 top-right, + /// 2 bottom-right, 3 bottom-left. Callers pass pre-rounded pixel + /// coordinates; `pixel_snapped` skips feathered quads. + #[inline] + pub fn concave_corner(x: f32, y: f32, r: f32, corner: usize, color: Rgba) -> Self { + // Circle center in quad-local px: the block corner pointing INTO the + // card (e.g. wrapping the card's top-left arc, the center sits at the + // block's bottom-right). + let center = match corner { + 0 => [r, r], + 1 => [0.0, r], + 2 => [0.0, 0.0], + _ => [r, 0.0], + }; + Self { + x, + y, + width: r, + height: r, + radius: 0.0, + // Repurposed as `[center_x, center_y, radius, 0]` by the concave + // fragment branch (`feather == 2.0`). + corner_radii: [center[0], center[1], r, 0.0], + feather: 2.0, + corners: None, + v_range: [0.0, 1.0], + color0: color, + color1: color, + gradient: Gradient::None, + } + } + /// Rounded rectangle with independent per-corner radii, ordered /// `[top-left, top-right, bottom-right, bottom-left]`. Used for the /// connected top-bar / sidebar L-frame, where the two join corners stay diff --git a/nebula_app/src/ssh.rs b/nebula_app/src/ssh.rs index 9498be1..f727502 100644 --- a/nebula_app/src/ssh.rs +++ b/nebula_app/src/ssh.rs @@ -594,6 +594,17 @@ pub fn run(args: Vec) -> i32 { SshPlan::Inject => { let b64 = base64::engine::general_purpose::STANDARD; let bootstrap = build_bootstrap(&b64.encode(REMOTE_BASH), &b64.encode(REMOTE_ZSH)); + // Keepalive: long-idle managed sessions (claude/codex runs) were + // dropped by NAT/firewall idle timeouts. Application-level pings + // every 30s survive those; 6 missed replies ≈ 3 min before the + // link is declared dead. Skipped when the user passes their own + // ServerAliveInterval on the command line; a value from + // ssh_config IS overridden (command line wins in OpenSSH) — the + // trade accepted here, since a dead-by-default link is the bug + // being fixed. + if !args.iter().any(|arg| arg.contains("ServerAliveInterval")) { + cmd.args(["-o", "ServerAliveInterval=30", "-o", "ServerAliveCountMax=6"]); + } // The bootstrap contains quotes and `$` — passing it straight // through Windows `Command` arg-escaping → ssh.exe → the remote // login shell is a three-layer quoting minefield. Sidestep it: diff --git a/nebula_config/Cargo.toml b/nebula_config/Cargo.toml index ec22f51..a11a273 100644 --- a/nebula_config/Cargo.toml +++ b/nebula_config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_config" -version = "0.6.0" +version = "0.7.0" license = "GPL-3.0-or-later" description = "Nebula configuration abstractions" homepage = "https://github.com/Kuddev/nebula" @@ -14,5 +14,5 @@ serde = "1.0.163" toml.workspace = true [dev-dependencies] -nebula_config_derive = { version = "0.6.0", path = "../nebula_config_derive" } +nebula_config_derive = { version = "0.7.0", path = "../nebula_config_derive" } serde = { version = "1.0.163", features = ["derive"] } diff --git a/nebula_config_derive/Cargo.toml b/nebula_config_derive/Cargo.toml index 49a1a00..ac61073 100644 --- a/nebula_config_derive/Cargo.toml +++ b/nebula_config_derive/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_config_derive" -version = "0.6.0" +version = "0.7.0" license = "GPL-3.0-or-later" description = "Failure resistant deserialization derive" homepage = "https://github.com/Kuddev/nebula" @@ -18,7 +18,7 @@ syn = { version = "2.0.16", features = ["derive", "parsing", "proc-macro", "prin [dev-dependencies.nebula_config] path = "../nebula_config" -version = "0.6.0" +version = "0.7.0" [dev-dependencies] log = "0.4.11" diff --git a/nebula_terminal/Cargo.toml b/nebula_terminal/Cargo.toml index bb000b1..8f9bf52 100644 --- a/nebula_terminal/Cargo.toml +++ b/nebula_terminal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_terminal" -version = "0.6.0" +version = "0.7.0" license = "GPL-3.0-or-later" description = "Nebula terminal emulation core" readme = "../README.md" diff --git a/nebula_terminal/src/term/mod.rs b/nebula_terminal/src/term/mod.rs index c69a584..29e3528 100644 --- a/nebula_terminal/src/term/mod.rs +++ b/nebula_terminal/src/term/mod.rs @@ -459,6 +459,29 @@ impl Term { self.damage.full = true; } + /// Update the DEFAULT cursor style (shape + blinking) without touching + /// the rest of the config. [`Self::cursor_style`] falls back to this when + /// no DECSCUSR escape has overridden the style, so the runtime settings + /// page can restyle the cursor while vim-style overrides keep working. + pub fn set_default_cursor_style(&mut self, style: CursorStyle) { + if self.config.default_cursor_style != style { + self.config.default_cursor_style = style; + self.mark_fully_damaged(); + } + } + + /// Drop any DECSCUSR override so [`Self::cursor_style`] falls back to the + /// default again. The runtime settings page calls this when the user picks + /// a cursor shape or toggles blinking: shells that pin their own style at + /// startup (PSReadLine, starship) would otherwise keep the old look alive + /// and the new choice would only show up after a terminal reset. + pub fn reset_cursor_style_override(&mut self) { + if self.cursor_style.is_some() { + self.cursor_style = None; + self.mark_fully_damaged(); + } + } + /// Set new options for the [`Term`]. pub fn set_options(&mut self, options: Config) where