diff --git a/CHANGELOG.md b/CHANGELOG.md index d2dd269..09df39a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,69 @@ Every release entry is provided in English and Simplified Chinese. 每个版本条目均同时提供英文和简体中文说明。 +## 0.4.0 - 2026-07-14 + +### Terminal Rendering And Interaction / 终端渲染与交互 + +- **No more missing rows at the bottom** — the terminal now makes proper room for both the top bar and the bottom edge. The last prompt, cursor, selection, and full-screen terminal content stay inside the visible card, including in split views. + **中文:** 终端底部不再凭空少一截啦。顶部栏和底部边距现在各占各的空间,即使使用分屏,最后一行命令、光标、选区和全屏程序内容也都能完整显示在卡片内。 +- **Selection stays clean in transparent windows** — selected text no longer shows ghosted content from apps behind Nebula or leaves visual residue behind. + **中文:** 透明窗口里的选区不再透出后方应用,也不会留下残影,选中文字时看起来更干净。 +- **Softer cursor and selection colors** — the cursor and selection now follow the current theme with lower-saturation colors, so they feel more balanced and are no longer harsh on the eyes. A color chosen by the user still takes priority. + **中文:** 光标和选区现在会跟随主题色啦,并使用低饱和度的颜色,看起来更协调、不再刺眼;如果用户自己设置了光标颜色,仍会优先使用用户的选择。 +- **Links are easier to recognize** — clickable file paths and terminal links now keep a dashed underline. The underline follows the original text color, so folders, executables, and multicolored filenames remain easy to tell apart. + **中文:** 可点击的文件路径和终端链接现在会一直带有虚线下划线,而且下划线会跟随文字原本的颜色,目录、可执行文件和彩色文件名依然一眼就能分清。 +- **Mouse selection feels like other desktop apps** — double- and triple-click selection now follows the system's timing and movement rules, while `Shift`+click extends the current selection. A normal click will no longer unexpectedly select a whole word or line. + **中文:** 鼠标选中文字现在更符合系统习惯:双击、三击会遵循系统的速度和移动范围,`Shift`+点击可以继续扩展选区,普通单击也不会再莫名选中整词或整行。 +- **Multiline shortcuts no longer look like pasted text** — key bindings that send an `Esc`-prefixed sequence, such as `Shift`+`Enter` for multiline input in Claude Code, now go straight to the terminal instead of opening the multiline paste confirmation. + **中文:** `Shift`+`Enter` 这类发送 `Esc` 组合序列的多行输入快捷键,现在会直接交给终端,不会再被误认为粘贴内容并弹出多行粘贴确认。 +- **Text sizing looks normal again** — headings and copy in the sidebar, SSH view, and document view no longer appear stretched, crowded, or stuck together when display scaling is enabled. + **中文:** 开启系统缩放后,侧栏、SSH 页面和文档里的标题与说明文字不再被异常放大,也不会显得拉长、拥挤或粘在一起。 + +### SSH Safety And Feedback / SSH 安全与反馈 + +- **Right-click menus for SSH hosts and tabs** — SSH hosts can be connected, copied, edited, or removed from a right-click menu. Tabs can be duplicated, split, renamed, closed, or given a custom color. The menu closes naturally when clicking elsewhere, pressing `Esc`, typing, or switching away from the window. + **中文:** SSH 主机和标签页都补上了顺手的右键菜单。SSH 主机可以连接、复制地址、编辑或删除;标签页可以复制、左右/上下分屏、重命名、关闭或设置颜色。点击其他地方、按 `Esc`、继续输入或切走窗口时,菜单都会自然收起。 +- **Deleted hosts can be recovered** — removing a host now asks for confirmation and provides an eight-second Undo button plus `Ctrl+Z`. Hosts read from `~/.ssh/config` are only hidden in Nebula, never deleted from that file, and hidden hosts can be brought back from Settings. Saved order is restored on Undo, and credentials are not erased until the Undo period ends. + **中文:** 删除 SSH 主机前现在会先确认,删除后还有 8 秒撤销时间,也可以直接按 `Ctrl+Z`。从 `~/.ssh/config` 读取的主机只会在 Nebula 里隐藏,不会改动原文件;之后也能从设置页的隐藏主机入口找回来。撤销时会恢复原来的顺序,保存的密码也会等撤销时间结束后再清理。 +- **SSH errors are shown where you can see them** — an invalid address keeps the text you entered, returns focus to the address box, and explains what needs fixing. If a terminal pane cannot be created, Nebula now shows the host, the reason, and what to try next instead of leaving the details only in the log. + **中文:** SSH 地址填错时不会再悄悄失败:已经输入的内容会保留,光标会回到地址框,并直接告诉你哪里需要修改。终端面板创建失败时,界面也会显示目标主机、失败原因和下一步建议,不用再去日志里猜。 +- **SSH fields now use familiar editing shortcuts** — address and password boxes support `Ctrl+A`, `Ctrl+C`, `Ctrl+V`, replacing selected text, Chinese IME input, and visible selection. Hidden passwords can be selected and pasted, but can only be copied after being revealed. + **中文:** SSH 地址和密码框现在可以正常使用 `Ctrl+A`、`Ctrl+C`、`Ctrl+V`,也支持中文输入法、全选后直接替换和清晰的选中效果。隐藏状态下的密码可以选择和粘贴,但只有点开显示后才能复制。 + +### UI Hierarchy And Control Consistency / UI 层级与控件一致性 + +- **A more consistent interface** — spacing now follows a 4px rhythm, while type sizes, row heights, icon buttons, corners, borders, shadows, animations, and control states share the same visual rules across the app. + **中文:** 界面的间距现在统一按 4px 节奏排布,字号、行高、图标按钮、圆角、描边、阴影、动画和各种操作状态也都使用同一套视觉规则,页面之间看起来更整齐、更一致。 +- **Themes can follow the system** — enable “Follow system light/dark mode” in Appearance and Nebula will switch between the matching light and dark themes when Windows or macOS changes. Choosing a theme card manually turns automatic switching off, so an explicit choice is never overwritten. + **中文:** 主题现在可以跟着系统明暗模式自动切换啦。在“外观”里打开“跟随系统明暗模式”后,Nebula 会随 Windows 或 macOS 切换到同系列的浅色或深色主题;手动点选主题卡会退出自动跟随,不会覆盖用户明确选择的主题。 +- **Text boxes behave the same everywhere** — renaming tabs, filtering files, entering Git commit messages, editing SSH hosts, and searching commands now all support the same copy, paste, select-all, replacement, IME, and selection behavior. + **中文:** 各处输入框终于用起来一致了:无论是重命名标签页、筛选文件、填写 Git 提交信息、编辑 SSH 主机还是搜索命令,都能用同样的复制、粘贴、全选、替换和中文输入法操作。 +- **A calmer sidebar** — `TABS` and `SSH HOSTS` now have clearer heading sizes, weights, and shades. The two `+` buttons only appear when the pointer is over their section title, the tab menu uses a vertical three-dot icon, and the empty SSH message is easier to read. + **中文:** 侧栏现在更清爽了:`TABS` 和 `SSH HOSTS` 的字号、字重与灰度层级更清楚;两个 `+` 只会在鼠标移到对应标题时出现,标签页菜单改成竖向三点,SSH 为空时的提示也更容易看清。 +- **Tab colors are now optional** — tabs no longer show a color strip by default. The strip appears only after you choose a color, and custom tab names and colors are restored with the session. + **中文:** 标签页默认不再显示色条,只有用户主动设置颜色后才会出现;自定义名称和颜色也会跟随会话保存,下次打开仍然保留。 +- **The `+` buttons are properly centered** — the icon, hover background, and clickable area now share the same center, so the button looks and feels aligned. Menu icons are also limited to shapes that the bundled Maple Mono Nerd Font can display reliably. + **中文:** `+` 图标、悬停背景和实际可点击区域现在共用同一个中心,看起来不会再歪,点起来也更准确;菜单图标也只使用内置 Maple Mono Nerd Font 能稳定显示的字形,避免出现方框或错位。 +- **Shell and profile search is back** — the picker can once again search and filter shells or profiles, with Chinese IME and familiar editing shortcuts. Search boxes and results use a compact 38px height, while SSH hints are brighter and easier to read. + **中文:** Shell 和 Profile 选择器的搜索回来了,支持中文输入法、常用编辑快捷键和模糊筛选。搜索框与结果行统一收紧到 38px,SSH 提示文字也调亮了一些,不再灰得看不清。 +- **Right-click menus feel lighter** — menus now use a soft theme-aware shadow, a subtle border, and a short open/close animation. Tab color labels and swatches also have more natural spacing. + **中文:** 右键菜单加上了跟随主题的柔和阴影、细边框和短促的开合动画,层次更自然;标签页颜色名称和色块之间也留出了更舒服的间距。 + +### Architecture, Research, And Verification / 架构、调研与验证 + +- **Cleaner internal structure** — context menus, text editing, SSH UI state, and shared visual values now live in separate modules, making later changes easier to understand and less likely to affect unrelated parts of the app. + **中文:** 右键菜单、文本输入、SSH 界面状态和通用视觉配置已经拆到各自的模块里,后续修改更容易看懂,也更不容易误伤其他功能。 +- **UX review and competitor research** — added a source-backed review of common user expectations, covering normal, empty, and error states, safe recovery after destructive actions, focus behavior, and font/icon reliability. A separate report compares Kaku, kitty, zap, and Tabby across their interface, workflows, SSH, file tools, protocols, and backup design. + **中文:** 新增了一份结合现有代码的体验审查,重点检查正常、空白和出错时是否好理解,误删后能不能找回,焦点是否自然,以及字体和图标能否稳定显示;另一份报告则对比了 Kaku、kitty、zap 和 Tabby 的界面、工作流、SSH、文件工具、协议与备份设计。 +- **More regression tests** — new tests cover the terminal bottom edge, split views, link underlines, transparent cursor and selection colors, overlapping links, menu placement, SSH deletion recovery, text editing, theme-family switching, and control-state priority. Current result: **188 passed; 0 failed**. + **中文:** 新增回归测试,覆盖终端底部显示、分屏、链接下划线、透明窗口中的光标与选区、重叠链接、菜单位置、SSH 删除恢复、文本输入、主题系列切换和操作状态优先级。当前结果:**188 项通过,0 项失败**。 + +### Still In Progress / 还在继续做 + +- **Not marked as complete yet** — the full SSH connecting/connected/failed experience, further cleanup of `display/mod.rs`, one shared animation timeline, tab close/reflow animations, and the OpenGL/wgpu direction are still being worked on or evaluated. + **中文:** 完整的 SSH 连接中/已连接/失败状态、`display/mod.rs` 的进一步拆分、统一动画时间线、标签页关闭与回流动画,以及 OpenGL/wgpu 方案选择都还在继续开发或评估,本次没有把它们算作已经交付。 + ## 0.3.0 - 2026-07-12 ### Highlights / 亮点 diff --git a/Cargo.lock b/Cargo.lock index 6672772..d148661 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1775,7 +1775,7 @@ dependencies = [ [[package]] name = "nebula" -version = "0.3.0" +version = "0.4.0" dependencies = [ "ahash", "base64", @@ -1824,7 +1824,7 @@ dependencies = [ [[package]] name = "nebula-completions" -version = "0.3.0" +version = "0.4.0" dependencies = [ "lscolors", "nu-ansi-term", @@ -1835,7 +1835,7 @@ dependencies = [ [[package]] name = "nebula_config" -version = "0.3.0" +version = "0.4.0" dependencies = [ "log", "nebula_config_derive", @@ -1845,7 +1845,7 @@ dependencies = [ [[package]] name = "nebula_config_derive" -version = "0.3.0" +version = "0.4.0" dependencies = [ "log", "nebula_config", @@ -1858,11 +1858,11 @@ dependencies = [ [[package]] name = "nebula_hook" -version = "0.3.0" +version = "0.4.0" [[package]] name = "nebula_terminal" -version = "0.3.0" +version = "0.4.0" dependencies = [ "base64", "bitflags 2.9.4", diff --git a/INSTALL.md b/INSTALL.md index cb94d79..71f489f 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -2,7 +2,7 @@ ## Release package (recommended) -1. Download `NebulaTerminal-v0.2.1-windows-x64.zip` from the +1. Download `NebulaTerminal-v0.4.0-windows-x64.zip` from the [Releases](https://github.com/Kuddev/nebula/releases) page. 2. Unzip anywhere (no installer, no admin rights). 3. **Install the font**: double-click `MapleMonoNormal-NF-CN-Regular.ttf` and press diff --git a/README.md b/README.md index b1c7c47..6d05452 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ experience that works without extra setup. > it and press **Install**, then launch Nebula. (Licensed under SIL OFL 1.1.) **Release build (recommended)** — download -`NebulaTerminal-v0.3.0-windows-x64.zip` from +`NebulaTerminal-v0.4.0-windows-x64.zip` from [Releases](https://github.com/Kuddev/nebula/releases), unzip anywhere, install `MapleMonoNormal-NF-CN-Regular.ttf`, then run `nebula.exe`. Keep the bundled files next to the exe (`nebula-hook.exe` powers AI notifications; @@ -302,7 +302,7 @@ Nebula 是一款 Windows 上的终端模拟器,以 Rust 编写,构建在 GPU **Release 包(推荐)** — 从 [Releases](https://github.com/Kuddev/nebula/releases) 下载 -`NebulaTerminal-v0.3.0-windows-x64.zip`,解压到任意目录,先安装 +`NebulaTerminal-v0.4.0-windows-x64.zip`,解压到任意目录,先安装 `MapleMonoNormal-NF-CN-Regular.ttf`,再运行 `nebula.exe`。请保持随包文件与 exe 同目录(`nebula-hook.exe` 驱动 AI 通知;`conpty.dll` + `OpenConsole.exe` 提供现代 ConPTY 宿主)。 diff --git a/nebula-completions/Cargo.toml b/nebula-completions/Cargo.toml index 2fb1a68..ebc5329 100644 --- a/nebula-completions/Cargo.toml +++ b/nebula-completions/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula-completions" -version = "0.3.0" +version = "0.4.0" edition.workspace = true [features] diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index f85c3bd..706df82 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula" -version = "0.3.0" +version = "0.4.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.3.0" +version = "0.4.0" [dependencies.nebula_config_derive] path = "../nebula_config_derive" -version = "0.3.0" +version = "0.4.0" [dependencies.nebula_config] path = "../nebula_config" -version = "0.3.0" +version = "0.4.0" [dependencies.nebula-completions] path = "../nebula-completions" -version = "0.3.0" +version = "0.4.0" [dependencies] ahash = { version = "0.8.6", features = ["no-rng"] } diff --git a/nebula_app/src/display/chrome.rs b/nebula_app/src/display/chrome.rs index e6713fd..aa85de5 100644 --- a/nebula_app/src/display/chrome.rs +++ b/nebula_app/src/display/chrome.rs @@ -772,7 +772,7 @@ pub(super) fn draw_chrome(d: &mut Display) { let gx = size.padding_x(); let gy = size.padding_y(); let gw = size.width() - gx - size.padding_right(); - let gh = size.height() - 2.0 * gy; + let gh = size.height() - gy - size.padding_bottom(); let (px2, py2, pw2, ph2) = match nav { SplitNav::Left => (gx, gy, gw / 2.0, gh), SplitNav::Right => (gx + gw / 2.0, gy, gw / 2.0, gh), diff --git a/nebula_app/src/display/hint.rs b/nebula_app/src/display/hint.rs index 9b2fda5..d7c0560 100644 --- a/nebula_app/src/display/hint.rs +++ b/nebula_app/src/display/hint.rs @@ -1,5 +1,5 @@ use std::borrow::Cow; -use std::cmp::Reverse; +use std::cmp::{self, Reverse}; use std::collections::HashSet; use std::iter; use std::rc::Rc; @@ -368,6 +368,84 @@ pub fn visible_unique_hyperlinks_iter(term: &Term) -> impl Iterator(term: &Term, config: &UiConfig) -> Vec { + let mut matches = Vec::new(); + + for hint in config + .hints + .enabled + .iter() + .filter(|hint| hint.mouse.as_ref().is_some_and(|mouse| mouse.enabled)) + { + if hint.content.hyperlinks { + matches.extend(visible_hyperlinks_iter(term)); + } + + if let Some(regex) = hint.content.regex.as_ref() { + regex.with_compiled(|regex| { + let regex_matches = visible_regex_match_iter(term, regex).collect::>(); + if hint.post_processing { + for bounds in regex_matches { + matches.extend(HintPostProcessor::new(term, regex, bounds)); + } + } else { + matches.extend(regex_matches); + } + }); + } + } + + merge_overlapping_matches(matches) +} + +/// Iterate over every contiguous visible OSC 8 range, including duplicate URIs. +fn visible_hyperlinks_iter(term: &Term) -> impl Iterator + '_ { + let mut display_iter = term.grid().display_iter().peekable(); + + iter::from_fn(move || { + let (cell, hyperlink) = display_iter.find_map(|cell| { + let hyperlink = cell.hyperlink()?; + Some((cell, hyperlink)) + })?; + let start = cell.point; + let mut end = start; + + while let Some(next_cell) = display_iter.peek() { + if next_cell.hyperlink().as_ref() != Some(&hyperlink) { + break; + } + end = next_cell.point; + let _ = display_iter.next(); + } + + Some(start..=end) + }) +} + +/// Sort and merge overlaps so render-time membership checks stay linear in cells + links. +fn merge_overlapping_matches(mut matches: Vec) -> Vec { + matches.sort_by_key(|bounds| (*bounds.start(), Reverse(*bounds.end()))); + let mut merged: Vec = Vec::with_capacity(matches.len()); + + for bounds in matches { + if let Some(last) = merged.last_mut() { + if bounds.start() <= last.end() { + let start = *last.start(); + let end = cmp::max(*last.end(), *bounds.end()); + *last = start..=end; + continue; + } + } + merged.push(bounds); + } + + merged +} + /// Retrieve the match, if the specified point is inside the content matching the regex. fn regex_match_at( term: &Term, @@ -699,6 +777,49 @@ mod tests { assert_eq!(None, unique_hyperlinks.next()); } + #[test] + fn visible_hyperlinks_keep_repeated_targets() { + let mut term = mock_term("000\r\n111"); + let hyperlink = Hyperlink::new(Some("same"), String::from("file:///same")); + + term.goto(0, 0); + term.set_hyperlink(Some(hyperlink.clone().into())); + term.input('a'); + term.input('b'); + term.set_hyperlink(None); + term.goto(1, 0); + term.set_hyperlink(Some(hyperlink.into())); + term.input('c'); + term.input('d'); + term.set_hyperlink(None); + + let matches = visible_hyperlinks_iter(&term).collect::>(); + assert_eq!( + matches, + vec![ + Point::new(Line(0), Column(0))..=Point::new(Line(0), Column(1)), + Point::new(Line(1), Column(0))..=Point::new(Line(1), Column(1)), + ] + ); + } + + #[test] + fn overlapping_clickable_ranges_are_merged() { + let matches = vec![ + Point::new(Line(0), Column(2))..=Point::new(Line(0), Column(6)), + Point::new(Line(0), Column(0))..=Point::new(Line(0), Column(3)), + Point::new(Line(1), Column(0))..=Point::new(Line(1), Column(2)), + ]; + + assert_eq!( + merge_overlapping_matches(matches), + vec![ + Point::new(Line(0), Column(0))..=Point::new(Line(0), Column(6)), + Point::new(Line(1), Column(0))..=Point::new(Line(1), Column(2)), + ] + ); + } + #[test] fn visible_regex_match_covers_entire_viewport() { let content = "I'm a match!\r\n".repeat(4096); diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index bbb4e6a..ba1aaac 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -23,7 +23,7 @@ use serde::{Deserialize, Serialize}; use winit::dpi::PhysicalSize; use winit::keyboard::ModifiersState; use winit::raw_window_handle::RawWindowHandle; -use winit::window::CursorIcon; +use winit::window::{CursorIcon, Theme as WinitTheme}; use crossfont::{Rasterize, Rasterizer, Size as FontSize}; use unicode_width::UnicodeWidthChar; @@ -1409,8 +1409,19 @@ pub struct Display { /// fragile, one batched pass is not. nebula_frame_images: Vec<(u64, std::sync::Arc>, (u32, u32), (f32, f32, f32, f32))>, - /// Runtime-only chrome theme selected from the top-left settings panel. + /// Theme currently painted. In automatic mode this is the light/dark + /// member resolved from `nebula_theme_preference` and the system state. pub nebula_theme: NebulaTheme, + /// Theme family explicitly selected by the user and written to settings. + /// Kept separate from the painted theme so an automatic light switch does + /// not forget which dark theme to restore later. + nebula_theme_preference: NebulaTheme, + pub nebula_follow_system_theme: bool, + nebula_system_theme: Option, + /// User-configured winit decoration override. Automatic Nebula theming + /// temporarily clears it because winit only emits `ThemeChanged` while a + /// window is following the operating system. + nebula_window_theme_override: Option, pub nebula_settings_open: bool, /// Settings content scroll offset in scaled px (0 = top of the section). nebula_settings_scroll: f32, @@ -1664,7 +1675,27 @@ impl Display { // Clear screen. let settings_init = settings::nebula_settings_load(config); - let background_color = settings_init.background.unwrap_or(config.colors.primary.background); + let nebula_window_theme_override = config.window.theme(); + if settings_init.follow_system_theme { + window.set_theme(None); + } + let nebula_system_theme = window.theme(); + let nebula_theme = if settings_init.follow_system_theme { + nebula_system_theme + .map(|theme| { + settings_init + .theme + .for_system_appearance(matches!(theme, WinitTheme::Light)) + }) + .unwrap_or(settings_init.theme) + } else { + settings_init.theme + }; + let background_color = if settings_init.follow_system_theme { + nebula_theme.palette().term_bg + } else { + settings_init.background.unwrap_or(config.colors.primary.background) + }; renderer.clear(background_color, settings_init.opacity); window.set_transparent(settings_init.opacity < 1.0); @@ -1708,7 +1739,7 @@ impl Display { let hint_state = HintState::new(config.hints.alphabet()); // Publish the RESTORED theme to the prompt bridge (writing the default // here used to reset the powerline colors on every launch). - write_nebula_prompt_theme(settings_init.theme); + write_nebula_prompt_theme(nebula_theme); let mut damage_tracker = DamageTracker::new(size_info.screen_lines(), size_info.columns()); damage_tracker.debug = config.debug.highlight_damage; @@ -1724,7 +1755,7 @@ impl Display { // the background OSC 11 reports must match the theme from frame one). let nebula_default_colors = List::from(&config.colors); let mut initial_colors = nebula_default_colors; - settings_init.theme.apply_term_colors(&mut initial_colors, &nebula_default_colors); + nebula_theme.apply_term_colors(&mut initial_colors, &nebula_default_colors); Ok(Self { context: ManuallyDrop::new(context), @@ -1772,7 +1803,11 @@ impl Display { nebula_ssh_editor_open: false, nebula_ssh_editor_hover: SshEditorHit::None, nebula_frame_images: Vec::new(), - nebula_theme: settings_init.theme, + nebula_theme, + nebula_theme_preference: settings_init.theme, + nebula_follow_system_theme: settings_init.follow_system_theme, + nebula_system_theme, + nebula_window_theme_override, nebula_settings_open: false, nebula_settings_scroll: 0.0, nebula_palette: command_palette::CommandPalette::new(), @@ -1821,7 +1856,11 @@ impl Display { nebula_powerline_enabled: settings_init.powerline, nebula_keep_session: settings_init.keep_session, nebula_window_opacity: settings_init.opacity, - nebula_background: settings_init.background, + nebula_background: if settings_init.follow_system_theme { + Some(nebula_theme.palette().term_bg) + } else { + settings_init.background + }, nebula_background_image: settings_init.background_image, nebula_background_image_opacity: settings_init.background_image_opacity, nebula_settings_mtime: settings::nebula_settings_mtime(), @@ -2428,7 +2467,7 @@ impl Display { let gx = self.size_info.padding_x(); let gy = self.size_info.padding_y(); let gw = self.size_info.width() - gx - self.size_info.padding_right(); - let gh = self.size_info.height() - 2.0 * gy; + let gh = self.size_info.height() - gy - self.size_info.padding_bottom(); if gw <= 0.0 || gh <= 0.0 || x < gx || y < gy || x > gx + gw || y > gy + gh { return None; } @@ -2637,6 +2676,7 @@ impl Display { section: self.nebula_settings_section, hover: self.nebula_settings_hover, theme: self.nebula_theme, + follow_system_theme: self.nebula_follow_system_theme, ghost: self.nebula_ghost_enabled, accept: self.nebula_accept, shell_label: { @@ -2694,7 +2734,7 @@ impl Display { } } - pub fn select_nebula_theme(&mut self, theme: NebulaTheme) { + fn apply_nebula_theme(&mut self, theme: NebulaTheme) { self.nebula_theme = theme; // A theme carries its terminal background (the light themes are // unusable without it). Switching theme IS choosing the look, so it @@ -2706,9 +2746,62 @@ impl Display { let defaults = self.nebula_default_colors; theme.apply_term_colors(&mut self.colors, &defaults); write_nebula_prompt_theme(theme); + self.pending_update.dirty = true; + } + + pub fn select_nebula_theme(&mut self, theme: NebulaTheme) { + self.nebula_theme_preference = theme; + // Clicking a concrete theme is an explicit manual choice. Automatic + // mode must step aside instead of changing it again on the next OS + // appearance event. + self.nebula_follow_system_theme = false; + self.window.set_theme(self.nebula_window_theme_override); + self.apply_nebula_theme(theme); self.persist_nebula_settings(); // Panel stays open so users can adjust several settings at once. - self.pending_update.dirty = true; + } + + pub fn toggle_system_theme_following(&mut self) { + self.nebula_follow_system_theme = !self.nebula_follow_system_theme; + if self.nebula_follow_system_theme { + // winit explicitly suppresses ThemeChanged for overridden + // windows, so automatic mode must let the OS own this value. + self.window.set_theme(None); + self.nebula_system_theme = self.window.theme(); + } else { + self.window.set_theme(self.nebula_window_theme_override); + } + let theme = if self.nebula_follow_system_theme { + self.nebula_system_theme + .map(|system| { + self.nebula_theme_preference + .for_system_appearance(matches!(system, WinitTheme::Light)) + }) + .unwrap_or(self.nebula_theme_preference) + } else { + self.nebula_theme_preference + }; + self.apply_nebula_theme(theme); + self.persist_nebula_settings(); + } + + /// Apply a live operating-system appearance change without rewriting the + /// stored theme family. This is intentionally a no-op in manual mode. + pub fn system_theme_changed(&mut self, system_theme: WinitTheme) { + self.nebula_system_theme = Some(system_theme); + if self.nebula_follow_system_theme { + let theme = self + .nebula_theme_preference + .for_system_appearance(matches!(system_theme, WinitTheme::Light)); + self.apply_nebula_theme(theme); + } + } + + /// Remember a reloaded window-decoration preference without allowing it + /// to suppress OS theme notifications while automatic mode is enabled. + pub fn update_window_theme_override(&mut self, theme: Option) { + self.nebula_window_theme_override = theme; + self.window.set_theme(if self.nebula_follow_system_theme { None } else { theme }); } pub fn toggle_ghost(&mut self) { @@ -2881,7 +2974,10 @@ impl Display { } pub fn reset_appearance_settings(&mut self) { - self.nebula_theme = NebulaTheme::default(); + self.nebula_theme_preference = NebulaTheme::default(); + self.nebula_follow_system_theme = false; + self.window.set_theme(self.nebula_window_theme_override); + self.nebula_theme = self.nebula_theme_preference; let defaults = self.nebula_default_colors; self.nebula_theme.apply_term_colors(&mut self.colors, &defaults); write_nebula_prompt_theme(self.nebula_theme); @@ -3284,7 +3380,8 @@ impl Display { background: self.nebula_background, background_image: self.nebula_background_image.clone(), background_image_opacity: self.nebula_background_image_opacity, - theme: self.nebula_theme, + theme: self.nebula_theme_preference, + follow_system_theme: self.nebula_follow_system_theme, pinned_hosts: self.nebula_pinned_hosts.clone(), saved_hosts: self.nebula_saved_hosts.clone(), hidden_hosts: self.nebula_hidden_hosts.clone(), @@ -3300,13 +3397,30 @@ impl Display { let settings = settings::nebula_settings_load(config); let image_changed = settings.background_image != self.nebula_background_image; - if settings.theme != self.nebula_theme { - // Hand-edited theme in the config file: apply and re-publish the - // prompt bridge just like an in-panel selection would. - self.nebula_theme = settings.theme; - let defaults = self.nebula_default_colors; - settings.theme.apply_term_colors(&mut self.colors, &defaults); - write_nebula_prompt_theme(settings.theme); + self.nebula_theme_preference = settings.theme; + let follow_system_changed = self.nebula_follow_system_theme != settings.follow_system_theme; + self.nebula_follow_system_theme = settings.follow_system_theme; + if follow_system_changed { + self.window.set_theme(if settings.follow_system_theme { + None + } else { + self.nebula_window_theme_override + }); + if settings.follow_system_theme { + self.nebula_system_theme = self.window.theme(); + } + } + let active_theme = if settings.follow_system_theme { + self.nebula_system_theme + .map(|system| settings.theme.for_system_appearance(matches!(system, WinitTheme::Light))) + .unwrap_or(settings.theme) + } else { + settings.theme + }; + if active_theme != self.nebula_theme { + // Hand-edited theme or automatic-mode setting: apply and publish + // it exactly like an in-panel selection would. + self.apply_nebula_theme(active_theme); } self.nebula_ghost_enabled = settings.ghost; self.nebula_accept = settings.accept; @@ -3316,7 +3430,11 @@ impl Display { self.nebula_powerline_enabled = settings.powerline; self.nebula_keep_session = settings.keep_session; self.nebula_window_opacity = settings.opacity; - self.nebula_background = settings.background; + self.nebula_background = if settings.follow_system_theme { + Some(active_theme.palette().term_bg) + } else { + settings.background + }; self.nebula_background_image = settings.background_image; self.nebula_background_image_opacity = settings.background_image_opacity; // Sync the host lists too: another window shares the settings file, @@ -3658,6 +3776,7 @@ impl Display { // Collect renderable content before the terminal is dropped. let custom_background = self.nebula_background; + let clickable_matches = hint::visible_clickable_matches(&terminal, config); let mut content = RenderableContent::new(config, self, &terminal, search_state, &view); let mut grid_cells = Vec::new(); let mut grid_pad_bg = None; @@ -3846,6 +3965,7 @@ impl Display { let highlighted_hint = &self.highlighted_hint; let vi_highlighted_hint = &self.vi_highlighted_hint; let damage_tracker = &mut self.damage_tracker; + let mut clickable_index = 0usize; let cells = grid_cells.into_iter().map(|mut cell| { match cell.character { @@ -3866,9 +3986,28 @@ impl Display { _ => (), } - // Underline hints hovered by mouse or vi mode cursor. + let point = term::viewport_to_point(display_offset, cell.point); + while clickable_matches + .get(clickable_index) + .is_some_and(|bounds| bounds.end() < &point) + { + clickable_index += 1; + } + let is_clickable = clickable_matches + .get(clickable_index) + .is_some_and(|bounds| bounds.contains(&point)); + if is_clickable { + // 点击目标的虚线直接继承每个 cell 的文字色;不能统一成主题色, + // 否则 ls 的目录/可执行文件颜色语义会被下划线悄悄抹平。 + cell.flags.remove(Flags::ALL_UNDERLINES); + cell.flags.insert(Flags::DASHED_UNDERLINE); + cell.underline = cell.fg; + } + + // Underline hints hovered by mouse or vi mode cursor. Persistent + // clickable ranges stay dashed; other hint states retain the + // stronger solid underline used by keyboard/vi highlighting. if has_highlighted_hint { - let point = term::viewport_to_point(display_offset, cell.point); let hyperlink = cell.extra.as_ref().and_then(|extra| extra.hyperlink.as_ref()); let should_highlight = |hint: &Option| { @@ -3876,7 +4015,9 @@ impl Display { }; if should_highlight(highlighted_hint) || should_highlight(vi_highlighted_hint) { damage_tracker.frame().damage_point(cell.point); - cell.flags.insert(Flags::UNDERLINE); + if !is_clickable { + cell.flags.insert(Flags::UNDERLINE); + } } } diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index a135663..05ce5c1 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -88,6 +88,7 @@ pub enum SettingsHit { Dismiss, Nav(NebulaSettingsSection), Theme(NebulaTheme), + SystemThemeToggle, GhostToggle, AcceptCycle, ShellCycle, @@ -131,6 +132,9 @@ pub(super) struct NebulaRuntimeSettings { /// powerline bridge file gets rewritten with the right name on boot /// (it used to be reset to the default theme every launch). pub(super) theme: NebulaTheme, + /// Automatically choose the light/dark member of the selected theme + /// family when the operating system appearance changes. + pub(super) follow_system_theme: bool, /// SSH host aliases pinned to the top of the sidebar's "SSH HOSTS" /// section (right-click a host row), in pinned order. pub(super) pinned_hosts: Vec, @@ -167,6 +171,9 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { background_image: None, background_image_opacity: 0.38, theme: NebulaTheme::default(), + // Preserve existing installations: automatic switching is opt-in so + // an update never replaces an explicitly selected theme unexpectedly. + follow_system_theme: false, pinned_hosts: Vec::new(), saved_hosts: Vec::new(), hidden_hosts: Vec::new(), @@ -230,6 +237,9 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { .map(str::to_owned) .collect(); }, + Some(("follow_system_theme", v)) => { + settings.follow_system_theme = parse_bool(v, false) + }, Some(("hidden_hosts", v)) => { settings.hidden_hosts = v .split(',') @@ -295,7 +305,8 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { let _ = std::fs::write( path, format!( - "theme={theme}\nghost={}\naccept={accept}\nshell={shell}\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", + "theme={theme}\nfollow_system_theme={}\nghost={}\naccept={accept}\nshell={shell}\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", + settings.follow_system_theme as u8, settings.ghost as u8, settings.fetch as u8, settings.powerline as u8, @@ -316,6 +327,7 @@ struct SettingsGeometry { content: (f32, f32, f32, f32), nav: [(NebulaSettingsSection, f32, f32, f32, f32); 4], options: [(NebulaTheme, f32, f32, f32, f32); 7], + system_theme: (f32, f32, f32, f32), shell: (f32, f32, f32, f32), /// Shell picker expanded rows (when open): first row Y, row height, max visible. shell_picker: (f32, f32, usize), @@ -443,7 +455,8 @@ fn settings_geometry( // 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). - let color_y0 = card_y0 + 2.0 * (64.0 + 48.0) + GROUP_ADVANCE; + 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; let appearance_h = s(iface_y0 + ROW_H + 32.0 - 72.0); // Opacity row controls, right-aligned with NO overlap: value · − · slider · +. @@ -504,6 +517,7 @@ 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), ], + 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), opacity_row, @@ -590,6 +604,9 @@ pub fn settings_hit( return SettingsHit::Theme(theme); } } + if contains_rect(geometry.system_theme, x, y) { + return SettingsHit::SystemThemeToggle; + } if contains_rect(geometry.background, x, y) { return SettingsHit::BackgroundColor; } @@ -664,6 +681,7 @@ pub(super) struct SettingsView { pub(super) section: NebulaSettingsSection, pub(super) hover: SettingsHit, pub(super) theme: NebulaTheme, + pub(super) follow_system_theme: bool, pub(super) ghost: bool, pub(super) accept: AcceptKey, /// Pre-rendered "默认 Shell" value (icon + name) — resolved by `Display` @@ -953,6 +971,14 @@ pub(super) fn push_quads( clip(quads, UiQuad::solid(ox, oy, ow, oh, s(8.0), card_bg)); } + 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); + // 自定义背景: one 2-row frame. 界面: one single-row frame. group_frame(quads, geometry.background, 2); row_hover(quads, geometry.background, view.hover == SettingsHit::BackgroundColor); @@ -1316,6 +1342,20 @@ pub(super) fn draw_text( gc, ); } + let (st_x, st_y, _, st_h) = geometry.system_theme; + if visible(group_y(st_y), title_h) { + section_title(r, gc, size, scale, &sk, st_x, group_y(st_y), "主题模式"); + } + if visible(st_y, st_h) { + r.draw_chrome_text( + size, + st_x + s(16.0), + st_y + (st_h - cell_h) / 2.0, + sk.ink, + "跟随系统明暗模式", + gc, + ); + } let (bg_x, bg_y, _, bg_h) = geometry.background; if visible(group_y(bg_y), title_h) { section_title(r, gc, size, scale, &sk, bg_x, group_y(bg_y), "自定义背景"); diff --git a/nebula_app/src/display/theme.rs b/nebula_app/src/display/theme.rs index 9638214..53f4f00 100644 --- a/nebula_app/src/display/theme.rs +++ b/nebula_app/src/display/theme.rs @@ -49,6 +49,26 @@ impl Default for NebulaTheme { } impl NebulaTheme { + /// Resolve the light/dark member of the user's chosen theme family. + /// + /// Nebula is an intentionally standalone dark theme. When automatic mode + /// needs a light counterpart, Silver is the closest neutral match; the + /// original Nebula preference is kept separately so switching back to a + /// dark system appearance restores it instead of silently changing the + /// user's choice to Steel. + pub(crate) fn for_system_appearance(self, is_light: bool) -> Self { + match (self, is_light) { + (Self::Nebula, true) => Self::SilverLight, + (Self::Nebula, false) => Self::Nebula, + (Self::SilverLight | Self::SteelDark, true) => Self::SilverLight, + (Self::SilverLight | Self::SteelDark, false) => Self::SteelDark, + (Self::LimestoneLight | Self::CoalDark, true) => Self::LimestoneLight, + (Self::LimestoneLight | Self::CoalDark, false) => Self::CoalDark, + (Self::LinenLight | Self::MossDark, true) => Self::LinenLight, + (Self::LinenLight | Self::MossDark, false) => Self::MossDark, + } + } + pub(crate) fn label(self) -> &'static str { match self { Self::Nebula => "Nebula", @@ -562,3 +582,20 @@ pub(crate) fn write_nebula_prompt_theme(theme: NebulaTheme) { }); } } + +#[cfg(test)] +mod tests { + use super::NebulaTheme; + + #[test] + fn system_appearance_keeps_the_selected_theme_family() { + assert_eq!(NebulaTheme::Nebula.for_system_appearance(true), NebulaTheme::SilverLight); + assert_eq!(NebulaTheme::Nebula.for_system_appearance(false), NebulaTheme::Nebula); + assert_eq!(NebulaTheme::SilverLight.for_system_appearance(false), NebulaTheme::SteelDark); + assert_eq!(NebulaTheme::SteelDark.for_system_appearance(true), NebulaTheme::SilverLight); + assert_eq!(NebulaTheme::LimestoneLight.for_system_appearance(false), NebulaTheme::CoalDark); + assert_eq!(NebulaTheme::CoalDark.for_system_appearance(true), NebulaTheme::LimestoneLight); + assert_eq!(NebulaTheme::LinenLight.for_system_appearance(false), NebulaTheme::MossDark); + assert_eq!(NebulaTheme::MossDark.for_system_appearance(true), NebulaTheme::LinenLight); + } +} diff --git a/nebula_app/src/display/window.rs b/nebula_app/src/display/window.rs index 8eca010..28d1f30 100644 --- a/nebula_app/src/display/window.rs +++ b/nebula_app/src/display/window.rs @@ -464,6 +464,13 @@ impl Window { self.window.set_theme(theme); } + /// Return the operating system's current application appearance when the + /// platform exposes it. Keeping this behind the window wrapper avoids + /// leaking the underlying winit window into the display state. + pub fn theme(&self) -> Option { + self.window.theme() + } + #[cfg(target_os = "macos")] pub fn toggle_simple_fullscreen(&self) { self.set_simple_fullscreen(!self.window.simple_fullscreen()); diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index fb079b2..911ef8f 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -436,7 +436,6 @@ impl Processor { | WindowEvent::PanGesture { .. } | WindowEvent::HoveredFileCancelled | WindowEvent::Destroyed - | WindowEvent::ThemeChanged(_) | WindowEvent::HoveredFile(_) | WindowEvent::Moved(_) ) @@ -2887,6 +2886,10 @@ impl input::Processor> { *self.ctx.dirty = true; }, }, + WindowEvent::ThemeChanged(theme) => { + self.ctx.display.system_theme_changed(theme); + *self.ctx.dirty = true; + }, WindowEvent::KeyboardInput { is_synthetic: true, .. } | WindowEvent::ActivationTokenDone { .. } | WindowEvent::DoubleTapGesture { .. } @@ -2898,7 +2901,6 @@ impl input::Processor> { | WindowEvent::PanGesture { .. } | WindowEvent::HoveredFileCancelled | WindowEvent::Destroyed - | WindowEvent::ThemeChanged(_) | WindowEvent::HoveredFile(_) | WindowEvent::RedrawRequested | WindowEvent::Moved(_) => (), diff --git a/nebula_app/src/input/mod.rs b/nebula_app/src/input/mod.rs index 1438b54..5c92727 100644 --- a/nebula_app/src/input/mod.rs +++ b/nebula_app/src/input/mod.rs @@ -704,6 +704,7 @@ impl> Processor { crate::display::SettingsHit::Toggle | crate::display::SettingsHit::Nav(_) | crate::display::SettingsHit::Theme(_) + | crate::display::SettingsHit::SystemThemeToggle | crate::display::SettingsHit::GhostToggle | crate::display::SettingsHit::AcceptCycle | crate::display::SettingsHit::ShellCycle @@ -1521,6 +1522,11 @@ impl> Processor { self.ctx.mark_dirty(); return; }, + crate::display::SettingsHit::SystemThemeToggle => { + self.ctx.display().toggle_system_theme_following(); + self.ctx.mark_dirty(); + return; + }, crate::display::SettingsHit::RestoreHiddenSsh(index) => { self.ctx.display().restore_hidden_ssh_host(index); self.ctx.mark_dirty(); diff --git a/nebula_app/src/message_bar.rs b/nebula_app/src/message_bar.rs index 1cdbcb5..73c3a7e 100644 --- a/nebula_app/src/message_bar.rs +++ b/nebula_app/src/message_bar.rs @@ -38,8 +38,10 @@ impl Message { /// Formatted message text lines. pub fn text(&self, size_info: &SizeInfo) -> Vec { let num_cols = size_info.columns(); - let total_lines = - (size_info.height() - 2. * size_info.padding_y()) / size_info.cell_height(); + let total_lines = (size_info.height() + - size_info.padding_y() + - size_info.padding_bottom()) + / size_info.cell_height(); let max_lines = (total_lines as usize).saturating_sub(MIN_FREE_LINES); let button_len = CLOSE_BUTTON_TEXT.chars().count(); diff --git a/nebula_app/src/renderer/mod.rs b/nebula_app/src/renderer/mod.rs index 82cdbb8..ad23bcd 100644 --- a/nebula_app/src/renderer/mod.rs +++ b/nebula_app/src/renderer/mod.rs @@ -522,7 +522,7 @@ impl Renderer { gl::BlendFuncSeparate(gl::SRC_ALPHA, gl::ONE_MINUS_SRC_ALPHA, gl::SRC_ALPHA, gl::ONE); } - self.rect_renderer.draw(size_info, metrics, rects); + self.rect_renderer.draw(size_info, metrics, rects, self.window_height.get()); // Activate regular state again. unsafe { @@ -705,24 +705,9 @@ impl Renderer { } pub fn set_viewport(&self, size: &SizeInfo) { - let content_h = size.height() - 2.0 * size.padding_y(); - // `padding_y` is measured from the top, but glViewport's origin is the - // bottom-left. Flip using the full window height so a pane occupying the - // top half of the screen isn't drawn in the bottom half. For a full-height - // pane (window height == size height) this reduces to `padding_y`. - let win_h = self.window_height.get(); - let gl_y = if win_h > 0.0 { - win_h - size.padding_y() - content_h - } else { - size.padding_y() - }; + let (x, y, width, height) = cell_viewport(size, self.window_height.get()); unsafe { - gl::Viewport( - size.padding_x() as i32, - gl_y as i32, - size.width() as i32 - size.padding_x() as i32 - size.padding_right() as i32, - content_h as i32, - ); + gl::Viewport(x, y, width, height); } } @@ -736,6 +721,25 @@ impl Renderer { } } +/// Calculate the cell viewport in OpenGL's bottom-left coordinate system. +fn cell_viewport(size: &SizeInfo, window_height: f32) -> (i32, i32, i32, i32) { + let content_width = size.width() - size.padding_x() - size.padding_right(); + let content_height = size.height() - size.padding_y() - size.padding_bottom(); + + // `padding_y` is measured from the top. A pane can encode only a subsection + // of the framebuffer, so flip against the real window height when available. + // 全窗口路径最终落在独立的 bottom padding 上,不能再镜像顶部标题栏留白。 + let framebuffer_height = if window_height > 0.0 { window_height } else { size.height() }; + let y = framebuffer_height - size.padding_y() - content_height; + + ( + size.padding_x() as i32, + y as i32, + content_width.max(0.0) as i32, + content_height.max(0.0) as i32, + ) +} + struct GlExtensions; impl GlExtensions { @@ -785,3 +789,27 @@ extern "system" fn gl_debug_log( let msg = unsafe { CStr::from_ptr(msg).to_string_lossy() }; debug!("[gl_render] {msg}"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn asymmetric_bottom_padding_is_the_cell_viewport_origin() { + let size = SizeInfo::new_fully_asymmetric( + 1000.0, 1000.0, 10.0, 20.0, 260.0, 20.0, 64.0, 16.0, + ); + + assert_eq!(cell_viewport(&size, 1000.0), (260, 16, 720, 920)); + // Startup calls resize before the renderer has recorded the window height. + assert_eq!(cell_viewport(&size, 0.0), (260, 16, 720, 920)); + } + + #[test] + fn split_viewport_flips_against_the_real_window_height() { + // Pane content rect: y=150, h=300. Split layout encodes it as + // height=h+2*y with symmetric local padding. + let pane = SizeInfo::new(900.0, 600.0, 10.0, 20.0, 100.0, 150.0, false); + assert_eq!(cell_viewport(&pane, 1000.0), (100, 550, 700, 300)); + } +} diff --git a/nebula_app/src/renderer/rects.rs b/nebula_app/src/renderer/rects.rs index fc9e1b2..c919ac2 100644 --- a/nebula_app/src/renderer/rects.rs +++ b/nebula_app/src/renderer/rects.rs @@ -317,7 +317,13 @@ impl RectRenderer { Ok(Self { vao, vbo, programs, vertices: Default::default() }) } - pub fn draw(&mut self, size_info: &SizeInfo, metrics: &Metrics, rects: Vec) { + pub fn draw( + &mut self, + size_info: &SizeInfo, + metrics: &Metrics, + rects: Vec, + window_height: f32, + ) { unsafe { // Bind VAO to enable vertex attribute slots. gl::BindVertexArray(self.vao); @@ -346,7 +352,7 @@ impl RectRenderer { let program = &self.programs[rect_kind as usize]; gl::UseProgram(program.id()); - program.update_uniforms(size_info, metrics); + program.update_uniforms(size_info, metrics, window_height); // Upload accumulated undercurl vertices. gl::BufferData( @@ -461,13 +467,13 @@ impl RectShaderProgram { self.program.id() } - pub fn update_uniforms(&self, size_info: &SizeInfo, metrics: &Metrics) { + pub fn update_uniforms(&self, size_info: &SizeInfo, metrics: &Metrics, window_height: f32) { let position = (0.5 * metrics.descent).abs(); let underline_position = metrics.descent.abs() - metrics.underline_position.abs(); - let viewport_height = size_info.height() - size_info.padding_y(); - let padding_y = viewport_height - - (viewport_height / size_info.cell_height()).floor() * size_info.cell_height(); + // gl_FragCoord 使用真实 framebuffer 的左下角坐标。这里必须对齐到 + // 实际网格底边;仅从顶部 padding 取余会在非对称底边和上下分屏时错位。 + let padding_y = grid_bottom(size_info, window_height); unsafe { if let Some(u_cell_width) = self.u_cell_width { @@ -494,3 +500,31 @@ impl RectShaderProgram { } } } + +fn grid_bottom(size_info: &SizeInfo, window_height: f32) -> f32 { + let framebuffer_height = if window_height > 0.0 { window_height } else { size_info.height() }; + framebuffer_height + - size_info.padding_y() + - size_info.screen_lines() as f32 * size_info.cell_height() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grid_bottom_uses_independent_bottom_reserve() { + let size = SizeInfo::new_fully_asymmetric( + 1000.0, 1000.0, 10.0, 20.0, 260.0, 20.0, 64.0, 16.0, + ); + assert_eq!(size.screen_lines(), 46); + assert_eq!(grid_bottom(&size, 1000.0), 16.0); + } + + #[test] + fn grid_bottom_uses_framebuffer_coordinates_for_split_panes() { + let pane = SizeInfo::new(900.0, 600.0, 10.0, 20.0, 100.0, 150.0, false); + assert_eq!(pane.screen_lines(), 15); + assert_eq!(grid_bottom(&pane, 1000.0), 550.0); + } +} diff --git a/nebula_app/src/renderer/text/gles2.rs b/nebula_app/src/renderer/text/gles2.rs index 08a987e..745bf21 100644 --- a/nebula_app/src/renderer/text/gles2.rs +++ b/nebula_app/src/renderer/text/gles2.rs @@ -403,7 +403,14 @@ impl TextRenderApi for RenderApi<'_> { // Draw background. self.program.set_rendering_pass(RenderingPass::Background); - gl::BlendFunc(gl::ONE, gl::ZERO); + // 背景着色器输出预乘 alpha;叠加可保住下层卡片的 alpha,避免 + // 半透明选区在透明窗口中变成能看到其他应用的“洞”。 + gl::BlendFuncSeparate( + gl::ONE, + gl::ONE_MINUS_SRC_ALPHA, + gl::ONE, + gl::ONE_MINUS_SRC_ALPHA, + ); gl::DrawElements(gl::TRIANGLES, num_indices, gl::UNSIGNED_SHORT, ptr::null()); self.program.set_rendering_pass(RenderingPass::SubpixelPass1); diff --git a/nebula_app/src/renderer/text/glsl3.rs b/nebula_app/src/renderer/text/glsl3.rs index 43084de..eed9bf6 100644 --- a/nebula_app/src/renderer/text/glsl3.rs +++ b/nebula_app/src/renderer/text/glsl3.rs @@ -249,6 +249,14 @@ impl TextRenderApi for RenderApi<'_> { unsafe { self.program.set_rendering_pass(RenderingPass::Background); + // 选区/主题光标背景是预乘 alpha。直接替换会把透明窗口的目标 + // alpha 打成 15%/20%,让窗口后的应用透进单元格;应叠加到已绘制的卡片。 + gl::BlendFuncSeparate( + gl::ONE, + gl::ONE_MINUS_SRC_ALPHA, + gl::ONE, + gl::ONE_MINUS_SRC_ALPHA, + ); gl::DrawElementsInstanced( gl::TRIANGLES, 6, @@ -257,6 +265,7 @@ impl TextRenderApi for RenderApi<'_> { self.batch.len() as GLsizei, ); self.program.set_rendering_pass(RenderingPass::SubpixelPass1); + gl::BlendFunc(gl::SRC1_COLOR, gl::ONE_MINUS_SRC1_COLOR); gl::DrawElementsInstanced( gl::TRIANGLES, 6, diff --git a/nebula_app/src/renderer/text/mod.rs b/nebula_app/src/renderer/text/mod.rs index 3abf906..8f3072d 100644 --- a/nebula_app/src/renderer/text/mod.rs +++ b/nebula_app/src/renderer/text/mod.rs @@ -202,10 +202,11 @@ fn update_projection(u_projection: GLint, size: &SizeInfo) { let padding_x = size.padding_x(); let padding_right = size.padding_right(); let padding_y = size.padding_y(); + let padding_bottom = size.padding_bottom(); // Bounds check. if (width as u32) < (padding_x as u32 + padding_right as u32) - || (height as u32) < (2 * padding_y as u32) + || (height as u32) < (padding_y as u32 + padding_bottom as u32) { return; } @@ -216,9 +217,9 @@ fn update_projection(u_projection: GLint, size: &SizeInfo) { // padding_x, so the projection only needs the content width, which is why // an asymmetric left/right split just works here. // [0, width - padding_x - padding_right] to [-1, 1] - // [height - 2 * padding_y, 0] to [-1, 1] + // [height - padding_y - padding_bottom, 0] to [-1, 1] let scale_x = 2. / (width - padding_x - padding_right); - let scale_y = -2. / (height - 2. * padding_y); + let scale_y = -2. / (height - padding_y - padding_bottom); let offset_x = -1.; let offset_y = 1.; diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index 0051ec1..effb496 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -1635,8 +1635,9 @@ impl WindowContext { self.display.pending_update.set_font(font); } - // Always reload the theme to account for auto-theme switching. - self.display.window.set_theme(self.config.window.theme()); + // Keep the decoration override in sync without suppressing winit's + // OS theme events while Nebula's automatic theme mode is enabled. + self.display.update_window_theme_override(self.config.window.theme()); // Update display if either padding options or resize increments were changed. let window_config = &old_config.window; @@ -1813,7 +1814,7 @@ impl WindowContext { view.padding_x(), view.padding_y(), view.width() - 2.0 * view.padding_x(), - view.height() - 2.0 * view.padding_y(), + view.height() - view.padding_y() - view.padding_bottom(), )); } let pane = &mut self.panes[idx]; diff --git a/nebula_app/src/window_context/split.rs b/nebula_app/src/window_context/split.rs index 6bab145..8245348 100644 --- a/nebula_app/src/window_context/split.rs +++ b/nebula_app/src/window_context/split.rs @@ -277,7 +277,7 @@ impl WindowContext { // (width - padding_right) — using 2*pad_x here would short the rightmost // pane by the sidebar width and leave a grey band down the right edge. let vw = (size.width() - pad_x - size.padding_right()).max(0.0); - let vh = (size.height() - 2.0 * pad_y).max(0.0); + let vh = (size.height() - pad_y - size.padding_bottom()).max(0.0); let mut panes = Vec::new(); let mut dividers = Vec::new(); @@ -306,7 +306,7 @@ impl WindowContext { let x0 = view.padding_x(); let y0 = view.padding_y(); let x1 = view.width() - view.padding_x(); - let y1 = view.height() - view.padding_y(); + let y1 = view.height() - view.padding_bottom(); (x >= x0 && x < x1 && y >= y0 && y < y1).then_some(id) }) } @@ -323,7 +323,8 @@ impl WindowContext { let Some((_, fview)) = rects.iter().find(|(id, _)| *id == focused) else { return }; let center = |v: &SizeInfo| { let cx = v.padding_x() + (v.width() - 2.0 * v.padding_x()) * 0.5; - let cy = v.padding_y() + (v.height() - 2.0 * v.padding_y()) * 0.5; + let cy = + v.padding_y() + (v.height() - v.padding_y() - v.padding_bottom()) * 0.5; (cx, cy) }; let (fcx, fcy) = center(fview); @@ -383,7 +384,7 @@ impl WindowContext { view.padding_x(), view.padding_y(), view.width() - 2.0 * view.padding_x(), - view.height() - 2.0 * view.padding_y(), + view.height() - view.padding_y() - view.padding_bottom(), direction, std::time::Instant::now(), )); diff --git a/nebula_config/Cargo.toml b/nebula_config/Cargo.toml index 547a7d3..1abd0f9 100644 --- a/nebula_config/Cargo.toml +++ b/nebula_config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_config" -version = "0.3.0" +version = "0.4.0" license = "GPL-3.0-or-later" description = "Nebula configuration abstractions" homepage = "https://github.com/Kuddev/nebulaTerminal" @@ -14,5 +14,5 @@ serde = "1.0.163" toml.workspace = true [dev-dependencies] -nebula_config_derive = { version = "0.3.0", path = "../nebula_config_derive" } +nebula_config_derive = { version = "0.4.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 f7f22ac..a31a96b 100644 --- a/nebula_config_derive/Cargo.toml +++ b/nebula_config_derive/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_config_derive" -version = "0.3.0" +version = "0.4.0" license = "GPL-3.0-or-later" description = "Failure resistant deserialization derive" homepage = "https://github.com/Kuddev/nebulaTerminal" @@ -18,7 +18,7 @@ syn = { version = "2.0.16", features = ["derive", "parsing", "proc-macro", "prin [dev-dependencies.nebula_config] path = "../nebula_config" -version = "0.3.0" +version = "0.4.0" [dev-dependencies] log = "0.4.11" diff --git a/nebula_hook/Cargo.toml b/nebula_hook/Cargo.toml index 5482bc6..45c399c 100644 --- a/nebula_hook/Cargo.toml +++ b/nebula_hook/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_hook" -version = "0.3.0" +version = "0.4.0" license = "GPL-3.0-or-later" description = "Tiny bridge: AI-CLI lifecycle hooks -> Nebula named pipe" publish = false diff --git a/nebula_terminal/Cargo.toml b/nebula_terminal/Cargo.toml index a63cedd..10f7494 100644 --- a/nebula_terminal/Cargo.toml +++ b/nebula_terminal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_terminal" -version = "0.3.0" +version = "0.4.0" license = "GPL-3.0-or-later" description = "Nebula terminal emulation core" readme = "../README.md"