fix: cursor style application, math overlay backing and sizing, combobox overlap; add update check

This commit is contained in:
kuddev committed 2026-07-24 16:27:58 +08:00
1 parent 3535c29cd2
commit 0b974b5441
7 files changed
+559 -97

No files matched your search

+27 -8
View File
@@ -4482,6 +4482,11 @@ impl Display {
terminal.is_focused = focused;
}
// 把设置页的光标默认值同步进每一个被渲染的终端。事件路径只覆盖
// "当前聚焦"的那一个 Term,新建 tab、分屏或后台 pane 都会漏掉;
// set_default_cursor_style 内部有相等短路,逐帧调用无重绘代价。
terminal.set_default_cursor_style(self.nebula_default_cursor_style());
// Tell the renderer the full window height so pane viewports flip
// correctly into OpenGL's bottom-left origin — matters for top/bottom
// splits, where panes occupy different vertical bands of the window.
@@ -4542,12 +4547,13 @@ impl Display {
Some(Self::nebula_raw_grid_row_preview(&terminal, cursor_point))
};
// 打字(含 IME 组词)不影响网格内容,扫描保持开启;一旦这里随
// preedit 关断,中文输入的每次拼音组合都会让全部公式闪回原文。
let terminal_math_overlays = if pane_state.terminal_math.inline_dollar_enabled()
&& !alt_screen
&& !vi_mode
&& search_state.regex().is_none()
&& selection_range.is_none()
&& self.ime.preedit().is_none()
{
let visible_cursor = term::point_to_viewport(display_offset, cursor_point);
terminal_math::scan_visible(
@@ -4558,11 +4564,21 @@ impl Display {
true,
visible_cursor,
foreground_color,
background_color,
)
} else {
Vec::new()
};
let math_pixel_size = self.glyph_cache.font_size.as_px();
let math_pixels_per_point = self.window.scale_factor as f32 * 96.0 / 72.27;
let prepared_math = terminal_math::prepare_overlays(
&mut pane_state.terminal_math,
&terminal_math_overlays,
&view,
math_pixel_size,
math_pixels_per_point,
);
let math_coverage =
terminal_math::CoverageMask::build(&terminal_math_overlays, &prepared_math);
// Add damage from the terminal.
match terminal.damage() {
@@ -4710,7 +4726,12 @@ impl Display {
let damage_tracker = &mut self.damage_tracker;
let mut clickable_index = 0usize;
let cells = grid_cells.into_iter().map(|mut cell| {
let cells = grid_cells.into_iter().filter_map(|mut cell| {
// 会被公式覆盖的源码字形直接不画:公式落在真实窗口背景上,
// 不再需要事后用不透明色块把源文本盖掉。
if !math_coverage.is_empty() && math_coverage.covers(cell.point) {
return None;
}
match cell.character {
NEBULA_FOLDER_ICON_MARKER => {
powerline_icons.push(NebulaPowerlineIcon {
@@ -4767,7 +4788,7 @@ impl Display {
// Update underline/strikeout.
lines.update(&cell);
cell
Some(cell)
});
self.renderer.draw_cells(&size_info, glyph_cache, cells);
}
@@ -4919,16 +4940,14 @@ impl Display {
self.renderer.draw_rects(&size_info, &metrics, rects);
}
let math_pixel_size = self.glyph_cache.font_size.as_px();
let pixels_per_point = self.window.scale_factor as f32 * 96.0 / 72.27;
terminal_math::draw_overlays(
&mut self.renderer,
&mut self.glyph_cache,
&mut pane_state.terminal_math,
&terminal_math_overlays,
&prepared_math,
&size_info,
math_pixel_size,
pixels_per_point,
math_pixels_per_point,
);
self.draw_powerline_icons(&powerline_icons, size_info);
+4 -1
View File
@@ -2351,9 +2351,12 @@ pub(super) fn draw_text(
&sk,
geometry.background,
language.pick("背景色", "Background color"),
&background_v,
"",
sk.accent,
);
// 值画进 combobox 控件框内(chevron 井之前),右对齐到行缘
// 会压住下拉箭头(浅色模式下重叠尤其明显)。
combobox_value(r, gc, geometry.background, &background_v, sk.accent);
}
let (img_x, img_y, _, img_h) = geometry.background_image;
let _ = img_x;
+303 -78
View File
@@ -22,7 +22,6 @@ use crate::math::cache::{FormulaCacheKey, MathLayoutCache};
use crate::math::layout::MathLayout;
use crate::math::{DEFAULT_LIMITS, compile_formula};
use crate::renderer::math::MathClip;
use crate::renderer::ui::{Rgba, UiQuad};
use crate::renderer::{GlyphCache, Renderer};
const MAX_VISIBLE_FORMULAS: usize = 64;
@@ -139,25 +138,41 @@ impl TerminalMathState {
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),
column: position.column,
},
kind,
let current = FormulaAnchor {
row: grid.absolute_top.saturating_add(position.row),
column: position.column,
};
match self.pending_display {
None => {
self.pending_display = Some(current);
self.pending_display =
Some(PendingDisplayFormula { anchor: current, kind, attempted_at: None });
None
},
Some(pending) if pending == current => None,
Some(pending) if pending.kind == current.kind && pending.anchor < current.anchor => {
Some(pending.anchor)
// 同一位置的孤立定界符稳定存在:它既可能是仍在流式输出的开头,
// 也可能是"公式被误删后只剩下的闭合"。对这个位置回看一次历史,
// 尝试把完整公式重新组装出来。
Some(pending) if pending.anchor == current && pending.kind == kind => {
if pending.attempted_at == Some(current) {
None
} else {
self.pending_display =
Some(PendingDisplayFormula { attempted_at: Some(current), ..pending });
Some(current)
}
},
Some(pending) if pending.kind == kind && pending.anchor < current => {
if pending.attempted_at == Some(current) {
None
} else {
self.pending_display =
Some(PendingDisplayFormula { attempted_at: Some(current), ..pending });
Some(pending.anchor)
}
},
Some(_) => {
self.pending_display = Some(current);
self.pending_display =
Some(PendingDisplayFormula { anchor: current, kind, attempted_at: None });
None
},
}
@@ -173,11 +188,31 @@ impl TerminalMathState {
};
for overlay in scan_grid_result(history, allow_inline_dollar).overlays {
if overlay.display && overlay_anchor(history, &overlay) == Some(pending.anchor) {
if !overlay.display {
continue;
}
// 原场景:pending 记录的是已滚入历史的开头。孤立闭合场景:
// pending 记录的是视口里那个落单的 `$$`,此时要求组装出的公式
// 恰好以它收尾,才能证明它确实是被误删公式的闭合定界符。
let opens_at_pending = overlay_anchor(history, &overlay) == Some(pending.anchor);
let closes_at_pending = overlay.spans.last().is_some_and(|span| {
usize::try_from(span.row).ok().and_then(|row| history.absolute_top.checked_add(row))
== Some(pending.anchor.row)
&& span.end >= 2
&& span.end - 2 == pending.anchor.column
});
if opens_at_pending || closes_at_pending {
self.remember(history, &overlay);
// 放回带 attempted 标记的 pending:孤立闭合在网格里仍会被
// 扫成 unmatched(persisted 覆盖对扫描不可见),清空会让它
// 每两帧重建并再次触发历史回看。
self.pending_display = Some(pending);
return true;
}
}
// 失败时放回(保留单次尝试标记),否则同一个孤立定界符每帧都会
// 触发一轮历史扫描。
self.pending_display = Some(pending);
false
}
@@ -304,6 +339,11 @@ enum DisplayDelimiterKind {
struct PendingDisplayFormula {
anchor: FormulaAnchor,
kind: DisplayDelimiterKind,
/// The unmatched-delimiter position that already triggered one history
/// reconstruction. Each position gets a single attempt: without this, a
/// delimiter that stays unmatched (e.g. its formula was already persisted)
/// would re-scan history every frame.
attempted_at: Option<FormulaAnchor>,
}
fn overlay_anchor(grid: &TextGrid, overlay: &FormulaOverlay) -> Option<FormulaAnchor> {
@@ -389,12 +429,19 @@ impl PersistedFormula {
if row >= grid.rows.len() {
continue;
}
compared = true;
if grid.span_fingerprint(row, span.start, span.end, span.include_wrap)
!= Some(span.fingerprint)
== Some(span.fingerprint)
{
return false;
compared = true;
continue;
}
// 整段被清空是 TUI 重绘的中间帧(先清行再重画):跳过比较,
// 让公式在这一两帧里继续渲染,避免输入期间不停闪回原文。
// 只要所有可比行都空白(真清屏),compared 保持 false 仍会淘汰。
if grid.span_is_blank(row, span.start, span.end) {
continue;
}
return false;
}
compared
}
@@ -415,8 +462,9 @@ impl PersistedFormula {
formula_id: self.formula_id,
spans,
foreground: Rgb::default(),
background: Rgb::default(),
fallback: Vec::new(),
expand_top: false,
expand_bottom: false,
}
}
}
@@ -474,8 +522,13 @@ pub(super) struct FormulaOverlay {
formula_id: u64,
spans: Vec<RowSpan>,
foreground: Rgb,
background: Rgb,
fallback: Vec<RenderableCell>,
/// Half-row breathing room above/below granted when the neighbouring grid
/// row is entirely blank. Display sources that fit on one terminal row
/// would otherwise squeeze tall layouts (`\sum` limits, fractions) into a
/// single cell height, making formula sizes wildly inconsistent.
expand_top: bool,
expand_bottom: bool,
}
impl FormulaOverlay {
@@ -500,8 +553,14 @@ impl FormulaOverlay {
let right_col = self.spans.iter().map(|span| span.end).max()?;
let left = size.padding_x() + left_col as f32 * size.cell_width();
let right = size.padding_x() + right_col as f32 * size.cell_width();
let top = size.padding_y() + first.row as f32 * size.cell_height();
let bottom = size.padding_y() + (last.row + 1) as f32 * size.cell_height();
let mut top = size.padding_y() + first.row as f32 * size.cell_height();
let mut bottom = size.padding_y() + (last.row + 1) as f32 * size.cell_height();
if self.expand_top {
top -= size.cell_height() * 0.5;
}
if self.expand_bottom {
bottom += size.cell_height() * 0.5;
}
(right > left && bottom > top).then_some(FormulaBounds { left, top, right, bottom })
}
}
@@ -707,6 +766,21 @@ impl TextGrid {
Some(hasher.finish())
}
/// Whether a span currently holds nothing but blanks. TUI redraws clear a
/// line before repainting it, so a blank span is treated as a transient
/// state rather than proof that a persisted formula is gone.
fn span_is_blank(&self, row: usize, start: usize, end: usize) -> bool {
self.rows.get(row).and_then(|cells| cells.get(start..end)).is_some_and(|cells| {
cells.iter().all(|cell| cell.is_none_or(|character| character == ' '))
})
}
fn row_is_blank(&self, row: usize) -> bool {
self.rows
.get(row)
.is_some_and(|cells| cells.iter().all(|cell| cell.is_none_or(|c| c == ' ')))
}
fn spans(&self, start: GridPosition, end: GridPosition) -> Vec<RowSpan> {
if start.row == end.row {
return vec![RowSpan { row: start.row as i32, start: start.column, end: end.column }];
@@ -744,7 +818,6 @@ pub(super) fn scan_visible<T>(
allow_inline_dollar: bool,
cursor: Option<Point<usize>>,
default_foreground: Rgb,
default_background: Rgb,
) -> Vec<FormulaOverlay> {
let grid = TextGrid::from_term(terminal, size);
let active_edit_rows = cursor.and_then(|cursor| grid.logical_rows_containing(cursor.line));
@@ -752,7 +825,6 @@ pub(super) fn scan_visible<T>(
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
// grid near 64 KiB of cell data instead of scanning all scrollback.
@@ -761,6 +833,14 @@ pub(super) fn scan_visible<T>(
.div_ceil(grid.columns.max(1))
.saturating_add(4)
.min(MAX_HISTORY_FORMULA_ROWS);
// A viewport anchor means the pending delimiter is an orphan closer
// whose opener sits somewhere above; its depth is unknown, so look
// back by the full source budget instead of an exact distance.
let lookback = if anchor.row >= grid.absolute_top {
source_rows
} else {
grid.absolute_top - anchor.row
};
if lookback <= source_rows {
let history = TextGrid::from_term_with_lookback(terminal, size, lookback);
state.complete_pending_from_history(&history, allow_inline_dollar);
@@ -793,11 +873,35 @@ pub(super) fn scan_visible<T>(
.iter()
.find(|cell| !cell.character.is_whitespace())
.map_or(default_foreground, |cell| cell.fg);
overlay.background = overlay
.fallback
.iter()
.find(|cell| cell.bg_alpha > 0.0)
.map_or(default_background, |cell| cell.bg);
// Grant tall layouts half a row of blank neighbour space so their
// rendered size stays consistent regardless of how many terminal
// rows the source happened to occupy. Display formulas may expand
// one-sided; inline formulas share a row with prose, so only a
// symmetric expansion keeps them optically on the text baseline.
if let (Some(first), Some(last)) = (overlay.spans.first(), overlay.spans.last()) {
let above_blank = match first.row.checked_sub(1) {
Some(above) if above >= 0 => match usize::try_from(above) {
Ok(row) => grid.row_is_blank(row),
Err(_) => false,
},
// 上一行已滚入历史:clip 会截到视口,扩展无害。
_ => overlay.display,
};
let below = last.row + 1;
let below_blank = match usize::try_from(below) {
Ok(row) if row < grid.rows.len() => grid.row_is_blank(row),
Ok(_) => overlay.display,
Err(_) => false,
};
if overlay.display {
overlay.expand_top = above_blank;
overlay.expand_bottom = below_blank;
} else {
overlay.expand_top = above_blank && below_blank;
overlay.expand_bottom = above_blank && below_blank;
}
}
Some(overlay)
})
.collect()
@@ -1080,85 +1184,157 @@ fn make_overlay(
formula_id,
spans: grid.spans(open, after),
foreground: Rgb::default(),
background: Rgb::default(),
fallback: Vec::new(),
expand_top: false,
expand_bottom: false,
}
}
/// Draw prepared overlays after terminal rectangles. A layout is fully built
/// before its source-covering quads are submitted, so parser/layout failures
/// leave the original grid untouched.
/// A formula that passed layout pre-flight: the grid pass omits its source
/// cells and [`draw_overlays`] paints the compiled layout in their place.
#[derive(Clone, Copy)]
pub(super) struct PreparedFormula {
fitted_pixel_size: f32,
}
/// Pre-compile every overlay before the cell pass so the renderer knows which
/// source cells to skip. Returns one entry per overlay; `None` keeps the raw
/// source visible (layout failure, or the fitted size would be unreadable).
pub(super) fn prepare_overlays(
state: &mut TerminalMathState,
overlays: &[FormulaOverlay],
size: &SizeInfo,
font_pixel_size: f32,
pixels_per_point: f32,
) -> Vec<Option<PreparedFormula>> {
overlays
.iter()
.map(|overlay| {
let bounds = overlay.bounds(size)?;
let available_width = (bounds.width() - FORMULA_INSET * 2.0).max(1.0);
let available_height = (bounds.height() - FORMULA_INSET * 2.0).max(1.0);
let base_metrics = state
.layout(
overlay.formula_id,
&overlay.source,
font_pixel_size,
pixels_per_point,
overlay.display,
)
.ok()?
.metrics;
let total_height = (base_metrics.height + base_metrics.depth).max(1.0);
let fit = (available_width / base_metrics.width.max(1.0))
.min(available_height / total_height)
.min(1.0);
let fitted_pixel_size = font_pixel_size * fit * 0.96;
if !fitted_pixel_size.is_finite() || fitted_pixel_size < MIN_MATH_PIXEL_SIZE {
return None;
}
state
.layout(
overlay.formula_id,
&overlay.source,
fitted_pixel_size,
pixels_per_point,
overlay.display,
)
.ok()?;
Some(PreparedFormula { fitted_pixel_size })
})
.collect()
}
/// Row→span lookup of cells covered by formulas that WILL render, so the grid
/// pass can skip those glyphs. Painting them and covering the area with an
/// opaque patch afterwards is what produced the black/white formula slabs on
/// transparent and background-image windows.
pub(super) struct CoverageMask {
rows: BTreeMap<usize, Vec<(usize, usize)>>,
}
impl CoverageMask {
pub(super) fn build(
overlays: &[FormulaOverlay],
prepared: &[Option<PreparedFormula>],
) -> Self {
let mut rows: BTreeMap<usize, Vec<(usize, usize)>> = BTreeMap::new();
for (overlay, prepared) in overlays.iter().zip(prepared) {
if prepared.is_none() {
continue;
}
for span in &overlay.spans {
let Ok(row) = usize::try_from(span.row) else { continue };
rows.entry(row).or_default().push((span.start, span.end));
}
}
Self { rows }
}
pub(super) fn covers(&self, point: Point<usize>) -> bool {
self.rows.get(&point.line).is_some_and(|spans| {
spans.iter().any(|&(start, end)| (start..end).contains(&point.column.0))
})
}
pub(super) fn is_empty(&self) -> bool {
self.rows.is_empty()
}
}
/// Draw prepared overlays after terminal rectangles. The source cells were
/// already skipped during the grid pass, so the formula renders directly on
/// the window background — no cover quad, transparency and wallpaper intact.
#[allow(clippy::too_many_arguments)]
pub(super) fn draw_overlays(
renderer: &mut Renderer,
glyph_cache: &mut GlyphCache,
state: &mut TerminalMathState,
overlays: &[FormulaOverlay],
prepared: &[Option<PreparedFormula>],
size: &SizeInfo,
font_pixel_size: f32,
pixels_per_point: f32,
) {
for overlay in overlays {
for (overlay, prepared) in overlays.iter().zip(prepared) {
let Some(prepared) = prepared else {
continue;
};
let Some(bounds) = overlay.bounds(size) else {
continue;
};
let available_width = (bounds.width() - FORMULA_INSET * 2.0).max(1.0);
let available_height = (bounds.height() - FORMULA_INSET * 2.0).max(1.0);
let base_metrics = match state.layout(
overlay.formula_id,
&overlay.source,
font_pixel_size,
pixels_per_point,
overlay.display,
) {
Ok(layout) => layout.metrics,
Err(_) => continue,
};
let total_height = (base_metrics.height + base_metrics.depth).max(1.0);
let fit = (available_width / base_metrics.width.max(1.0))
.min(available_height / total_height)
.min(1.0);
let fitted_pixel_size = font_pixel_size * fit * 0.96;
if !fitted_pixel_size.is_finite() || fitted_pixel_size < MIN_MATH_PIXEL_SIZE {
continue;
}
let layout = match state.layout(
overlay.formula_id,
&overlay.source,
fitted_pixel_size,
prepared.fitted_pixel_size,
pixels_per_point,
overlay.display,
) {
Ok(layout) => layout,
Err(_) => continue,
Err(_) => {
// Pre-flight succeeded, so this is unreachable in practice;
// repaint the skipped source cells rather than leave a hole.
renderer.draw_cells(size, glyph_cache, overlay.fallback.iter().cloned());
continue;
},
};
let total_height = layout.metrics.height + layout.metrics.depth;
let origin_x = bounds.left + (bounds.width() - layout.metrics.width) / 2.0;
// Display math centers like block typography. Inline math whose
// rendered width is far below its source span (`$\xi$` is five cells
// of source, one glyph of output) hugs the preceding prose instead:
// centering would strand it in the middle of a large gap, detached
// from both sides. Near-full-width results still center so the
// leftover cell splits evenly.
let centered = overlay.display || layout.metrics.width >= bounds.width() * 0.75;
let origin_x = if centered {
bounds.left + (bounds.width() - layout.metrics.width) / 2.0
} else {
bounds.left + FORMULA_INSET
};
let baseline_y =
bounds.top + (bounds.height() - total_height) / 2.0 + layout.metrics.height;
let quads: Vec<_> = overlay
.spans
.iter()
.filter(|span| {
span.row >= 0
&& usize::try_from(span.row).is_ok_and(|row| row < size.screen_lines())
})
.map(|span| {
UiQuad::solid(
size.padding_x() + span.start as f32 * size.cell_width(),
size.padding_y() + span.row as f32 * size.cell_height(),
(span.end - span.start) as f32 * size.cell_width(),
size.cell_height(),
0.0,
Rgba::opaque(overlay.background),
)
})
.collect();
renderer.draw_ui(size, &quads);
let viewport_right = size.padding_x() + size.columns() as f32 * size.cell_width();
let viewport_bottom = size.padding_y() + size.screen_lines() as f32 * size.cell_height();
let clip = MathClip {
@@ -1168,6 +1344,7 @@ pub(super) fn draw_overlays(
bottom: bounds.bottom.min(viewport_bottom),
};
if clip.right <= clip.left || clip.bottom <= clip.top {
renderer.draw_cells(size, glyph_cache, overlay.fallback.iter().cloned());
continue;
}
if renderer.draw_math(size, layout, origin_x, baseline_y, overlay.foreground, clip).is_err()
@@ -1178,7 +1355,7 @@ pub(super) fn draw_overlays(
let base_ascent = (size.cell_height() + glyph_cache.font_metrics().descent).max(1.0);
for operation in &layout.text {
let scale = operation.pixel_size / fitted_pixel_size;
let scale = operation.pixel_size / prepared.fitted_pixel_size;
let x = origin_x + operation.x;
let y = baseline_y + operation.baseline_y - base_ascent * scale;
let width = size.cell_width() * scale;
@@ -1479,6 +1656,54 @@ d & -b \\
assert!(state.formulas.is_empty());
}
#[test]
fn tui_redraw_blank_interim_frame_keeps_the_persisted_formula() {
let mut state = TerminalMathState::default();
let initial = grid_at(&["$$ ", "x^2 ", "$$ ", "tail "], 40, 0);
remember_visible(&mut state, &initial);
assert_eq!(state.formulas.len(), 1);
// A TUI clears a line before repainting it: a partially blanked frame
// must not evict the formula (this was the input-time flicker).
let interim = grid_at(&["$$ ", " ", "$$ ", "tail "], 40, 0);
state.synchronize_grid(&interim);
assert_eq!(state.visible_overlays(&interim).len(), 1);
assert_eq!(state.formulas.len(), 1);
// Rewritten with different visible content -> genuinely gone.
let changed = grid_at(&["other", "words", "here ", "tail "], 40, 0);
state.synchronize_grid(&changed);
assert!(state.visible_overlays(&changed).is_empty());
}
#[test]
fn orphan_closing_delimiter_recovers_the_formula_from_history() {
let mut state = TerminalMathState::default();
// Viewport starts below the formula opener: only the closing `$$` is
// visible (e.g. the persisted copy was evicted by a redraw glitch).
let visible = grid_at(&["$$ ", "tail "], 45, 0);
state.synchronize_grid(&visible);
assert!(state.scan_visible_grid(&visible, false, None).is_none());
let anchor = state
.scan_visible_grid(&visible, false, None)
.expect("stable orphan delimiter requests one history pass");
assert_eq!(anchor, FormulaAnchor { row: 45, column: 0 });
let history = grid_at(&["$$ ", "x^2 ", "$$ ", "tail "], 43, 0);
assert!(state.complete_pending_from_history(&history, false));
let overlays = state.visible_overlays(&visible);
assert_eq!(overlays.len(), 1);
assert_eq!(overlays[0].source.as_ref(), "x^2");
assert_eq!(overlays[0].spans.first().map(|span| span.row), Some(-2));
// The attempt is consumed: the same orphan never re-triggers, even
// though the scanner keeps reporting it as unmatched every frame.
assert!(state.scan_visible_grid(&visible, false, None).is_none());
assert!(state.scan_visible_grid(&visible, false, None).is_none());
}
#[test]
fn reflow_history_pruning_and_memory_limits_invalidate_bounded_state() {
let mut state = TerminalMathState::default();
+3 -2
View File
@@ -64,8 +64,7 @@ mod scheduler;
mod session;
mod shell_detect;
#[cfg(windows)]
mod ssh;
#[cfg(windows)]
mod ssh;#[cfg(windows)]
mod ssh_credentials;
#[cfg(windows)]
mod ssh_profiles;
@@ -74,6 +73,7 @@ mod ssh_session;
#[cfg(windows)]
mod ssh_sftp;
mod string;
mod update_check;
mod ux;
mod window_context;
@@ -227,6 +227,7 @@ fn nebula(mut options: Options) -> Result<(), Box<dyn Error>> {
// re-installs them whenever another tool (ccswitch…) rewrites the config.
// The notify proxy powers toast click-to-focus. See ai_hook / notify.
notify::init_proxy(window_event_loop.create_proxy());
update_check::spawn_once(window_event_loop.create_proxy());
#[cfg(windows)]
{
ai_hook::spawn_server(window_event_loop.create_proxy());
+113
View File
@@ -0,0 +1,113 @@
//! Startup update check against GitHub Releases.
//!
//! Zero new dependencies: Windows 10+ ships `curl.exe`, so the probe is a
//! short-lived child process instead of an HTTP stack baked into the binary.
//! Everything is best-effort — no network, no curl, malformed JSON, or a
//! GitHub outage all degrade to "no banner", never to an error the user sees.
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use winit::event_loop::EventLoopProxy;
use crate::event::{Event, EventType};
use crate::message_bar::{Message, MessageType};
const RELEASES_API: &str = "https://api.github.com/repos/Kuddev/nebula/releases/latest";
const RELEASES_PAGE: &str = "https://github.com/Kuddev/nebula/releases";
/// Kick off the once-per-process background check. The result (if any)
/// arrives as a regular [`EventType::Message`] banner, which the message bar
/// already knows how to display, deduplicate and dismiss.
pub fn spawn_once(proxy: EventLoopProxy<Event>) {
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::SeqCst) {
return;
}
let spawned = std::thread::Builder::new().name("update-check".into()).spawn(move || {
// 等窗口与首个会话安顿好再查,别和启动抢磁盘/网络。
std::thread::sleep(Duration::from_secs(12));
let Some(latest) = fetch_latest_version() else { return };
let current = env!("CARGO_PKG_VERSION");
if !is_newer(&latest, current) {
log::debug!("update-check: v{current} is current (latest v{latest})");
return;
}
let text = format!("Nebula v{latest} 已发布(当前 v{current}),下载:{RELEASES_PAGE}");
let _ = proxy.send_event(Event::new(
EventType::Message(Message::new(text, MessageType::Warning)),
None,
));
});
if let Err(error) = spawned {
log::debug!("update-check: thread spawn failed: {error}");
}
}
fn fetch_latest_version() -> Option<String> {
let mut command = Command::new("curl");
command.args([
"-fsSL",
"--max-time",
"10",
"-H",
"User-Agent: nebula-terminal",
"-H",
"Accept: application/vnd.github+json",
RELEASES_API,
]);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
let tag = json.get("tag_name")?.as_str()?;
let version = tag.trim().trim_start_matches(['v', 'V']);
(!version.is_empty()).then(|| version.to_owned())
}
/// Compare dotted numeric prefixes ("0.7.10" > "0.7.9"); anything after the
/// digits in a segment is ignored, so "1.0.0-rc1" reads as `[1, 0, 0]`.
fn is_newer(latest: &str, current: &str) -> bool {
fn segments(version: &str) -> Vec<u64> {
version
.split('.')
.map(|segment| {
let digits: String =
segment.chars().take_while(char::is_ascii_digit).collect();
digits.parse().unwrap_or(0)
})
.collect()
}
let (latest, current) = (segments(latest), segments(current));
for index in 0..latest.len().max(current.len()) {
let new = latest.get(index).copied().unwrap_or(0);
let old = current.get(index).copied().unwrap_or(0);
if new != old {
return new > old;
}
}
false
}
#[cfg(test)]
mod tests {
use super::is_newer;
#[test]
fn version_comparison_is_numeric_per_segment() {
assert!(is_newer("0.7.1", "0.7.0"));
assert!(is_newer("0.10.0", "0.9.9"));
assert!(is_newer("1.0", "0.9.9"));
assert!(!is_newer("0.7.0", "0.7.0"));
assert!(!is_newer("0.6.9", "0.7.0"));
assert!(!is_newer("0.7.0-rc1", "0.7.0"));
}
}
+22 -8
View File
@@ -181,6 +181,14 @@ pub struct Term<T> {
/// Current style of the cursor.
cursor_style: Option<CursorStyle>,
/// Blinking state requested via DECSET/DECRST 12, tracked separately from
/// [`Self::cursor_style`]: folding it into the DECSCUSR override would pin
/// the shape that was the default at that moment, and later changes to
/// [`Config::default_cursor_style`] (the runtime settings page) would
/// silently stop applying. ConPTY shells emit mode 12 on startup, so this
/// was the rule rather than the exception on Windows.
cursor_blinking_override: Option<bool>,
/// Proxy for sending events to the event loop.
event_proxy: T,
@@ -399,6 +407,7 @@ impl<T> Term<T> {
active_charset: Default::default(),
vi_mode_cursor: Default::default(),
cursor_style: Default::default(),
cursor_blinking_override: Default::default(),
colors: color::Colors::default(),
title_stack: Default::default(),
is_focused: Default::default(),
@@ -476,8 +485,9 @@ impl<T> Term<T> {
/// 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() {
if self.cursor_style.is_some() || self.cursor_blinking_override.is_some() {
self.cursor_style = None;
self.cursor_blinking_override = None;
self.mark_fully_damaged();
}
}
@@ -931,7 +941,10 @@ impl<T> Term<T> {
/// While vi mode is active, this will automatically return the vi mode cursor style.
#[inline]
pub fn cursor_style(&self) -> CursorStyle {
let cursor_style = self.cursor_style.unwrap_or(self.config.default_cursor_style);
let mut cursor_style = self.cursor_style.unwrap_or(self.config.default_cursor_style);
if let Some(blinking) = self.cursor_blinking_override {
cursor_style.blinking = blinking;
}
if self.mode.contains(TermMode::VI) {
self.config.vi_mode_cursor_style.unwrap_or(cursor_style)
@@ -1842,6 +1855,7 @@ impl<T: EventListener> Handler for Term<T> {
}
self.active_charset = Default::default();
self.cursor_style = None;
self.cursor_blinking_override = None;
self.grid.reset();
self.inactive_grid.reset();
self.nebula_prompt_marks.clear();
@@ -1990,8 +2004,7 @@ impl<T: EventListener> Handler for Term<T> {
},
NamedPrivateMode::ColumnMode => self.deccolm(),
NamedPrivateMode::BlinkingCursor => {
let style = self.cursor_style.get_or_insert(self.config.default_cursor_style);
style.blinking = true;
self.cursor_blinking_override = Some(true);
self.event_proxy.send_event(Event::CursorBlinkingChange);
},
NamedPrivateMode::SyncUpdate => (),
@@ -2039,8 +2052,7 @@ impl<T: EventListener> Handler for Term<T> {
NamedPrivateMode::Origin => self.mode.remove(TermMode::ORIGIN),
NamedPrivateMode::ColumnMode => self.deccolm(),
NamedPrivateMode::BlinkingCursor => {
let style = self.cursor_style.get_or_insert(self.config.default_cursor_style);
style.blinking = false;
self.cursor_blinking_override = Some(false);
self.event_proxy.send_event(Event::CursorBlinkingChange);
},
NamedPrivateMode::SyncUpdate => (),
@@ -2056,8 +2068,10 @@ impl<T: EventListener> Handler for Term<T> {
NamedPrivateMode::Origin => self.mode.contains(TermMode::ORIGIN).into(),
NamedPrivateMode::LineWrap => self.mode.contains(TermMode::LINE_WRAP).into(),
NamedPrivateMode::BlinkingCursor => {
let style = self.cursor_style.get_or_insert(self.config.default_cursor_style);
style.blinking.into()
let blinking = self.cursor_blinking_override.unwrap_or_else(|| {
self.cursor_style.unwrap_or(self.config.default_cursor_style).blinking
});
blinking.into()
},
NamedPrivateMode::ShowCursor => self.mode.contains(TermMode::SHOW_CURSOR).into(),
NamedPrivateMode::ReportMouseClicks => {
+87
View File
@@ -0,0 +1,87 @@
# Interactive UI probe pinned to a specific nebula PID so the user's own
# instance is never touched. Uses PrintWindow so screenshots capture the target
# window even when it is occluded. Clicks require foreground; retry + verify.
param(
[int]$ProcId = 0,
[string]$Click = "",
[int]$Scroll = 0,
[string]$Shot = "",
[string]$TypeText = "",
[switch]$Kill
)
Add-Type @'
using System;
using System.Runtime.InteropServices;
public class W {
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern void mouse_event(uint f, int dx, int dy, uint d, UIntPtr e);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint flags);
[DllImport("user32.dll")] public static extern uint GetDpiForWindow(IntPtr h);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
public struct RECT { public int L, T, R, B; }
}
'@
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
if ($ProcId -eq 0) {
$p = Start-Process "D:\temp_build\nebula\target\release\nebula.exe" -ArgumentList '--working-directory','D:\temp_build' -PassThru
Start-Sleep -Seconds 6
$p.Refresh()
Write-Output "started pid=$($p.Id)"
$ProcId = $p.Id
}
$proc = Get-Process -Id $ProcId -ErrorAction SilentlyContinue
if (-not $proc) { Write-Output "PROCESS $ProcId GONE"; exit 1 }
if ($Kill) { $proc.Kill(); Write-Output "killed $ProcId"; exit 0 }
$h = $proc.MainWindowHandle
if ($h -eq 0) { Write-Output "NO WINDOW for $ProcId"; exit 1 }
$r = New-Object W+RECT
[void][W]::GetWindowRect($h, [ref]$r)
Write-Output "pid=$ProcId rect=($($r.L),$($r.T),$($r.R),$($r.B)) size=$($r.R-$r.L)x$($r.B-$r.T) dpi=$([W]::GetDpiForWindow($h))"
if ($Click -ne "" -or $Scroll -ne 0 -or $TypeText -ne "") {
# Activation needed for input. Retry until we really own the foreground.
for ($i = 0; $i -lt 5; $i++) {
[void][W]::ShowWindow($h, 9) # SW_RESTORE
[void][W]::SetForegroundWindow($h)
Start-Sleep -Milliseconds 400
if ([W]::GetForegroundWindow() -eq $h) { break }
}
if ([W]::GetForegroundWindow() -ne $h) { Write-Output "FOREGROUND FAILED"; exit 1 }
}
if ($Click -ne "") {
$parts = $Click.Split(','); $cx = [int]$parts[0] + $r.L; $cy = [int]$parts[1] + $r.T
Write-Output "click client($($parts[0]),$($parts[1])) -> screen($cx,$cy)"
[void][W]::SetCursorPos($cx, $cy); Start-Sleep -Milliseconds 250
[W]::mouse_event(2,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 60
[W]::mouse_event(4,0,0,0,[UIntPtr]::Zero)
Start-Sleep -Milliseconds 700
}
if ($Scroll -ne 0) {
$sx = [int]($r.L + ($r.R - $r.L) * 0.6); $sy = [int]($r.T + ($r.B - $r.T) * 0.5)
[void][W]::SetCursorPos($sx, $sy); Start-Sleep -Milliseconds 150
[W]::mouse_event(0x0800, 0, 0, [uint32]($Scroll * 120), [UIntPtr]::Zero)
Start-Sleep -Milliseconds 500
}
if ($TypeText -ne "") {
[System.Windows.Forms.SendKeys]::SendWait($TypeText)
Start-Sleep -Milliseconds 500
}
if ($Shot -ne "") {
$wr = New-Object W+RECT
[void][W]::GetWindowRect($h, [ref]$wr)
$w = $wr.R - $wr.L; $ht = $wr.B - $wr.T
$bmp = New-Object System.Drawing.Bitmap($w, $ht)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$dc = $g.GetHdc()
# PW_RENDERFULLCONTENT (2) captures GPU-composited windows.
[void][W]::PrintWindow($h, $dc, 2)
$g.ReleaseHdc($dc)
$bmp.Save($Shot, [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
Write-Output "shot=$Shot"
}