chore: checkpoint before GPUI foundation

This commit is contained in:
kuddev committed 2026-08-12 13:34:12 +08:00
1 parent bb9527d1a9
commit 05bc8313cf
24 files changed
+2765 -215

No files matched your search

+454
View File
@@ -0,0 +1,454 @@
//! AI provider metadata and credential references.
//!
//! Provider metadata is deliberately kept separate from `nebula_settings.txt`:
//! the latter is a user-editable runtime file, while provider credentials need
//! a stable identity and must never be serialized beside ordinary settings.
use std::io;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::event::{Event, EventType};
const STORE_FILE: &str = "nebula_providers.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderKind {
OpenAi,
Anthropic,
Google,
Ollama,
OpenRouter,
Qwen,
DeepSeek,
Kimi,
Zhipu,
Doubao,
Mimo,
AzureOpenAi,
Custom,
}
impl ProviderKind {
pub const PRESETS: [Self; 13] = [
Self::OpenAi,
Self::Anthropic,
Self::Google,
Self::Ollama,
Self::OpenRouter,
Self::Qwen,
Self::DeepSeek,
Self::Kimi,
Self::Zhipu,
Self::Doubao,
Self::Mimo,
Self::AzureOpenAi,
Self::Custom,
];
pub fn label(self) -> &'static str {
match self {
Self::OpenAi => "OpenAI",
Self::Anthropic => "Anthropic",
Self::Google => "Google",
Self::Ollama => "Ollama",
Self::OpenRouter => "OpenRouter",
Self::Qwen => "Qwen",
Self::DeepSeek => "DeepSeek",
Self::Kimi => "Kimi",
Self::Zhipu => "Zhipu",
Self::Doubao => "Doubao",
Self::Mimo => "Xiaomi MiMo",
Self::AzureOpenAi => "Azure OpenAI",
Self::Custom => "OpenAI Compatible",
}
}
pub fn default_base_url(self) -> &'static str {
match self {
Self::OpenAi => "https://api.openai.com/v1",
Self::Anthropic => "https://api.anthropic.com/v1",
Self::Google => "https://generativelanguage.googleapis.com/v1beta",
Self::Ollama => "http://localhost:11434/v1",
Self::OpenRouter => "https://openrouter.ai/api/v1",
Self::Qwen => "https://dashscope.aliyuncs.com/compatible-mode/v1",
Self::DeepSeek => "https://api.deepseek.com/v1",
Self::Kimi => "https://api.moonshot.ai/v1",
Self::Zhipu => "https://open.bigmodel.cn/api/paas/v4",
Self::Doubao => "https://ark.cn-beijing.volces.com/api/v3",
Self::Mimo => "https://api.xiaomimimo.com/v1",
Self::AzureOpenAi => "https://{resource}.openai.azure.com/openai/deployments",
Self::Custom => "",
}
}
pub fn default_model(self) -> &'static str {
match self {
Self::OpenAi => "gpt-5.4-mini",
Self::Anthropic => "claude-sonnet-4-5",
Self::Google => "gemini-2.5-flash",
Self::Ollama => "qwen3",
Self::OpenRouter => "openai/gpt-5.4-mini",
Self::Qwen => "qwen3.7-plus",
Self::DeepSeek => "deepseek-chat",
Self::Kimi => "kimi-k2.6",
Self::Zhipu => "glm-5.1",
Self::Doubao => "doubao-seed-2-0-pro-260215",
Self::Mimo => "mimo-v2.5-pro",
Self::AzureOpenAi => "gpt-4o",
Self::Custom => "",
}
}
pub fn requires_api_key(self) -> bool {
!matches!(self, Self::Ollama)
}
pub fn uses_openai_protocol(self) -> bool {
!matches!(self, Self::Anthropic | Self::Google)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiProvider {
pub id: String,
pub name: String,
#[serde(default)]
pub note: String,
#[serde(default)]
pub website_url: String,
pub kind: ProviderKind,
pub base_url: String,
pub model: String,
#[serde(default)]
pub enabled: bool,
/// A credential reference only. The actual key is held by the OS store.
#[serde(default)]
pub api_key_set: bool,
#[serde(default)]
pub api_key_hint: String,
#[serde(default)]
pub full_url: bool,
/// Optional Codex feature flags applied only by the explicit Codex action.
#[serde(default)]
pub codex_goals: bool,
#[serde(default)]
pub codex_remote_compaction: bool,
}
impl AiProvider {
pub fn preset(kind: ProviderKind, id: impl Into<String>) -> Self {
let kind = kind;
Self {
id: id.into(),
name: kind.label().to_owned(),
note: String::new(),
website_url: String::new(),
kind,
base_url: kind.default_base_url().to_owned(),
model: kind.default_model().to_owned(),
enabled: true,
api_key_set: false,
api_key_hint: String::new(),
full_url: false,
codex_goals: false,
codex_remote_compaction: false,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderStore {
#[serde(default)]
pub active_id: String,
#[serde(default)]
pub providers: Vec<AiProvider>,
}
#[derive(Debug, Clone)]
pub struct ProviderTestRequest {
pub request_id: u64,
pub provider: AiProvider,
}
pub fn store_path() -> PathBuf {
crate::display::nebula_data_dir().join(STORE_FILE)
}
pub fn load() -> ProviderStore {
let mut store = std::fs::read_to_string(store_path())
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default();
normalize(&mut store);
store
}
/// Older builds only materialized a preset after it was clicked. Keep those
/// records intact, but fill the missing presets so the visible list can be a
/// real, index-addressable collection and multiple custom entries remain
/// independently editable.
fn normalize(store: &mut ProviderStore) {
let mut existing = std::mem::take(&mut store.providers);
let mut ordered = Vec::with_capacity(ProviderKind::PRESETS.len().max(existing.len()));
for kind in ProviderKind::PRESETS {
if kind == ProviderKind::Custom {
continue;
}
if let Some(index) = existing.iter().position(|provider| provider.kind == kind) {
ordered.push(existing.remove(index));
} else {
ordered.push(AiProvider::preset(kind, preset_id(kind)));
}
}
let had_custom = existing.iter().any(|provider| provider.kind == ProviderKind::Custom);
ordered.extend(existing.into_iter().filter(|provider| provider.kind == ProviderKind::Custom));
if !had_custom {
ordered.push(AiProvider::preset(ProviderKind::Custom, preset_id(ProviderKind::Custom)));
}
store.providers = ordered;
if !store.providers.iter().any(|provider| provider.id == store.active_id) {
store.active_id =
store.providers.first().map(|provider| provider.id.clone()).unwrap_or_default();
}
}
pub fn preset_id(kind: ProviderKind) -> String {
format!("preset-{}", kind.label().to_ascii_lowercase().replace(' ', "-"))
}
pub fn next_custom_id(store: &ProviderStore) -> String {
let mut sequence = 1usize;
loop {
let id = format!("custom-{sequence}");
if !store.providers.iter().any(|provider| provider.id == id) {
return id;
}
sequence += 1;
}
}
pub fn active_enabled(store: &ProviderStore) -> Option<&AiProvider> {
store.providers.iter().find(|provider| provider.id == store.active_id && provider.enabled)
}
pub fn save(store: &ProviderStore) -> io::Result<()> {
let path = store_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = serde_json::to_string_pretty(store)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
std::fs::write(path, text)
}
pub fn credential_target(id: &str) -> String {
format!("Nebula/AI/{id}")
}
pub fn api_key_hint(key: &str) -> String {
let key = key.trim();
if key.is_empty() {
return String::new();
}
let tail: String = key.chars().rev().take(4).collect::<String>().chars().rev().collect();
format!("••••{tail}")
}
pub fn save_api_key(id: &str, key: &str) -> io::Result<String> {
let key = key.trim();
if key.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "API key is empty"));
}
#[cfg(windows)]
crate::ssh_credentials::store_generic_secret(&credential_target(id), key.as_bytes())?;
#[cfg(not(windows))]
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"system credential storage is not available on this build",
));
Ok(api_key_hint(key))
}
pub fn delete_api_key(id: &str) -> io::Result<()> {
#[cfg(windows)]
crate::ssh_credentials::delete_generic_secret(&credential_target(id))?;
#[cfg(not(windows))]
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"system credential storage is not available on this build",
));
Ok(())
}
pub fn load_api_key(id: &str) -> io::Result<Option<Vec<u8>>> {
#[cfg(windows)]
return crate::ssh_credentials::load_generic_secret(&credential_target(id));
#[cfg(not(windows))]
{
let _ = id;
Ok(None)
}
}
fn test_url(provider: &AiProvider) -> Result<String, String> {
let base = provider.base_url.trim().trim_end_matches('/');
if !(base.starts_with("https://") || base.starts_with("http://")) {
return Err("请求地址必须以 http:// 或 https:// 开头".to_owned());
}
if provider.full_url {
return Ok(base.to_owned());
}
Ok(match provider.kind {
ProviderKind::AzureOpenAi => {
let resource = base.strip_suffix("/openai/deployments").unwrap_or(base);
format!("{resource}/openai/models?api-version=2024-10-21")
},
_ => format!("{base}/models"),
})
}
fn sanitized_error(error: &ureq::Error) -> String {
let message = match error {
ureq::Error::Timeout(_) => "连接超时".to_owned(),
ureq::Error::HostNotFound => "找不到服务器".to_owned(),
ureq::Error::ConnectionFailed => "无法连接服务器".to_owned(),
ureq::Error::Io(err) => format!("网络错误:{}", err.kind()),
ureq::Error::Tls(_) => "TLS 握手失败".to_owned(),
_ => "请求失败".to_owned(),
};
message.chars().take(180).collect()
}
fn run_test(provider: &AiProvider) -> (bool, String, u64) {
let started = Instant::now();
let elapsed = || started.elapsed().as_millis().min(u64::MAX as u128) as u64;
let url = match test_url(provider) {
Ok(url) => url,
Err(message) => return (false, message, elapsed()),
};
if provider.model.trim().is_empty() {
return (false, "请先填写默认模型".to_owned(), elapsed());
}
let key = if provider.kind.requires_api_key() {
let secret = match load_api_key(&provider.id) {
Ok(Some(secret)) if !secret.is_empty() => secret,
Ok(_) => return (false, "请先保存 API Key".to_owned(), elapsed()),
Err(_) => return (false, "无法从凭据管理器读取 API Key".to_owned(), elapsed()),
};
match String::from_utf8(secret) {
Ok(key) => Zeroizing::new(key),
Err(_) => return (false, "凭据管理器中的 API Key 编码无效".to_owned(), elapsed()),
}
} else {
Zeroizing::new(String::new())
};
let config = ureq::config::Config::builder()
.timeout_global(Some(Duration::from_secs(12)))
.http_status_as_error(false)
.build();
let agent: ureq::Agent = config.new_agent();
let mut request = agent.get(&url);
request = match provider.kind {
ProviderKind::Anthropic => {
request.header("x-api-key", key.as_str()).header("anthropic-version", "2023-06-01")
},
ProviderKind::Google => request.header("x-goog-api-key", key.as_str()),
ProviderKind::AzureOpenAi => request.header("api-key", key.as_str()),
_ if key.is_empty() => request,
_ => {
let bearer = Zeroizing::new(format!("Bearer {}", key.as_str()));
request.header("Authorization", bearer.as_str())
},
};
let response = match request.call() {
Ok(response) => response,
Err(error) => return (false, sanitized_error(&error), elapsed()),
};
let status = response.status().as_u16();
let (ok, message) = match status {
200..=299 => (true, format!("连接成功(HTTP {status})")),
401 | 403 => (false, format!("鉴权失败(HTTP {status}),请检查 API Key")),
404 => (false, "接口不存在(HTTP 404),请检查请求地址".to_owned()),
429 => (false, "服务限流(HTTP 429),连接可达但当前无法验证额度".to_owned()),
_ => (false, format!("服务返回 HTTP {status}")),
};
(ok, message, elapsed())
}
pub fn spawn_test(
request: ProviderTestRequest,
proxy: winit::event_loop::EventLoopProxy<Event>,
window_id: winit::window::WindowId,
) -> io::Result<()> {
std::thread::Builder::new()
.name("nebula-provider-test".into())
.spawn(move || {
let provider_id = request.provider.id.clone();
let (ok, message, elapsed_ms) = run_test(&request.provider);
let _ = proxy.send_event(Event::new(
EventType::ProviderTestDone {
request_id: request.request_id,
provider_id,
ok,
message,
elapsed_ms,
},
window_id,
));
})
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn presets_have_safe_defaults() {
for kind in ProviderKind::PRESETS {
let provider = AiProvider::preset(kind, "test");
if kind != ProviderKind::Custom {
assert!(!provider.base_url.is_empty());
}
assert!(!provider.api_key_set);
}
}
#[test]
fn key_hint_never_contains_the_full_secret() {
let hint = api_key_hint("sk-this-is-a-long-secret");
assert_eq!(hint, "••••cret");
assert!(!hint.contains("long-secret"));
}
#[test]
fn normalization_preserves_multiple_custom_entries() {
let mut store = ProviderStore {
active_id: "custom-2".to_owned(),
providers: vec![
AiProvider::preset(ProviderKind::Custom, "custom-1"),
AiProvider::preset(ProviderKind::Custom, "custom-2"),
],
};
normalize(&mut store);
assert_eq!(
store.providers.iter().filter(|provider| provider.kind == ProviderKind::Custom).count(),
2
);
assert_eq!(store.active_id, "custom-2");
assert_eq!(next_custom_id(&store), "custom-3");
}
#[test]
fn provider_test_urls_follow_each_protocol() {
let openai = AiProvider::preset(ProviderKind::OpenAi, "openai");
assert_eq!(test_url(&openai).unwrap(), "https://api.openai.com/v1/models");
let azure = AiProvider::preset(ProviderKind::AzureOpenAi, "azure");
assert!(test_url(&azure).unwrap().contains("/openai/models?api-version="));
}
}
+167
View File
@@ -0,0 +1,167 @@
//! Explicit export of one Nebula provider to Codex CLI configuration.
//!
//! Nebula normally keeps API keys in the Windows Credential Manager. Codex's
//! file auth mode consumes `OPENAI_API_KEY` from `auth.json`, so this module is
//! called only after a second user confirmation and always backs up both live
//! files before replacing them.
use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value as JsonValue};
use toml_edit::{DocumentMut, value};
use zeroize::Zeroizing;
use crate::ai_providers::AiProvider;
pub fn apply_provider(provider: &AiProvider) -> Result<PathBuf, String> {
if !provider.kind.uses_openai_protocol() {
return Err("Codex 需要 Responses/OpenAI 兼容供应商".to_owned());
}
let secret = crate::ai_providers::load_api_key(&provider.id)
.map_err(|_| "无法从凭据管理器读取 API Key".to_owned())?
.ok_or_else(|| "请先保存 API Key".to_owned())?;
let key = Zeroizing::new(
String::from_utf8(secret).map_err(|_| "凭据管理器中的 API Key 编码无效".to_owned())?,
);
let home = codex_home().ok_or_else(|| "无法确定 Codex 配置目录".to_owned())?;
apply_to_dir(&home, provider, key.as_str()).map_err(|error| error.to_string())?;
Ok(home)
}
fn codex_home() -> Option<PathBuf> {
std::env::var_os("CODEX_HOME").map(PathBuf::from).or_else(|| {
std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
.map(PathBuf::from)
.map(|home| home.join(".codex"))
})
}
fn provider_key(provider: &AiProvider) -> String {
let mut key = String::from("nebula_");
for character in provider.id.chars() {
if character.is_ascii_alphanumeric() {
key.push(character.to_ascii_lowercase());
} else if !key.ends_with('_') {
key.push('_');
}
}
key.trim_end_matches('_').to_owned()
}
fn backup_path(path: &Path) -> PathBuf {
let mut name: OsString = path.as_os_str().to_owned();
name.push(".nebula.bak");
PathBuf::from(name)
}
fn backup(path: &Path) -> io::Result<()> {
if path.exists() {
std::fs::copy(path, backup_path(path))?;
}
Ok(())
}
fn restore(path: &Path, old: Option<&[u8]>) {
match old {
Some(bytes) => {
let _ = std::fs::write(path, bytes);
},
None => {
let _ = std::fs::remove_file(path);
},
}
}
fn apply_to_dir(home: &Path, provider: &AiProvider, api_key: &str) -> io::Result<()> {
std::fs::create_dir_all(home)?;
let auth_path = home.join("auth.json");
let config_path = home.join("config.toml");
let old_auth = std::fs::read(&auth_path).ok();
let old_config = std::fs::read(&config_path).ok();
backup(&auth_path)?;
backup(&config_path)?;
let mut auth = match old_auth.as_deref() {
Some(bytes) => serde_json::from_slice::<JsonValue>(bytes)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
None => JsonValue::Object(Map::new()),
};
let object = auth.as_object_mut().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "Codex auth.json 顶层必须是 JSON 对象")
})?;
object.insert("OPENAI_API_KEY".to_owned(), JsonValue::String(api_key.to_owned()));
let auth_text = Zeroizing::new(
serde_json::to_string_pretty(&auth)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
);
let config_text = match old_config.as_deref() {
Some(bytes) => std::str::from_utf8(bytes)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
None => "",
};
let mut document = if config_text.trim().is_empty() {
DocumentMut::new()
} else {
config_text
.parse::<DocumentMut>()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
};
let provider_key = provider_key(provider);
document["model_provider"] = value(provider_key.clone());
document["model"] = value(provider.model.trim());
document["model_providers"][provider_key.as_str()]["name"] = value(provider.name.trim());
document["model_providers"][provider_key.as_str()]["base_url"] =
value(provider.base_url.trim_end_matches('/'));
document["model_providers"][provider_key.as_str()]["wire_api"] = value("responses");
document["model_providers"][provider_key.as_str()]["env_key"] = value("OPENAI_API_KEY");
document["model_providers"][provider_key.as_str()]["requires_openai_auth"] = value(true);
document["features"]["goals"] = value(provider.codex_goals);
document["features"]["remote_compaction_v2"] = value(provider.codex_remote_compaction);
std::fs::write(&auth_path, auth_text.as_bytes())?;
if let Err(error) = std::fs::write(&config_path, document.to_string().as_bytes()) {
restore(&auth_path, old_auth.as_deref());
restore(&config_path, old_config.as_deref());
return Err(error);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai_providers::ProviderKind;
#[test]
fn apply_preserves_unknown_config_and_auth_fields() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("config.toml"),
"# keep this comment\nunknown_setting = 7\n\n[features]\napps = true\n",
)
.unwrap();
std::fs::write(temp.path().join("auth.json"), r#"{"tokens":{"access_token":"keep"}}"#)
.unwrap();
let mut provider = AiProvider::preset(ProviderKind::Custom, "custom-7");
provider.name = "Example".to_owned();
provider.base_url = "https://example.test/v1".to_owned();
provider.model = "gpt-test".to_owned();
provider.codex_goals = true;
apply_to_dir(temp.path(), &provider, "sk-secret").unwrap();
let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap();
assert!(config.contains("# keep this comment"));
assert!(config.contains("unknown_setting = 7"));
assert!(config.contains("apps = true"));
assert!(config.contains("goals = true"));
let auth: JsonValue =
serde_json::from_slice(&std::fs::read(temp.path().join("auth.json")).unwrap()).unwrap();
assert_eq!(auth["OPENAI_API_KEY"], "sk-secret");
assert_eq!(auth["tokens"]["access_token"], "keep");
assert!(backup_path(&temp.path().join("config.toml")).exists());
assert!(backup_path(&temp.path().join("auth.json")).exists());
}
}
+39 -8
View File
@@ -4,6 +4,7 @@
//! hit-testing and animation live together here so pointer targets cannot drift
//! away from the pixels the user sees at non-integer DPI scales.
use super::ui::keycap;
use super::ui::surface::{self, fade as alpha};
use super::ui::tokens::{control, space};
use super::*;
@@ -469,7 +470,11 @@ pub(super) fn hit_test(
return ContextMenuHit::Action(*action);
}
}
if contains(layout.panel, x, y) { ContextMenuHit::Panel } else { ContextMenuHit::Outside }
if contains(layout.panel, x, y) {
ContextMenuHit::Panel
} else {
ContextMenuHit::Outside
}
}
fn fade_ink(base: Rgba, ink: Rgb, opacity: f32) -> Rgb {
@@ -554,6 +559,20 @@ pub(super) fn draw(display: &mut Display) {
alpha(Rgba::new(swatch.r, swatch.g, swatch.b, 255), progress),
));
}
// 右键菜单和命令面板共用同一颗键帽组件;快捷键不再是贴在右缘的一串
// 灰字,独立小键帽给出清晰的可按暗示,也让两类浮层形成同一套视觉语法。
for row in &layout.rows {
if !row.hint.is_empty() {
let combo = keycap::layout_combo(
row.hint,
row.rect.0 + row.rect.2 - s(10.0),
row.rect.1 + row.rect.3 * 0.5,
size.cell_width(),
scale,
);
keycap::push_combo_with_progress(&mut quads, &sk, &combo, scale, progress);
}
}
display.renderer.draw_ui(&size, &quads);
// Text has no alpha channel in the glyph pipeline, so fade it toward the
@@ -581,15 +600,27 @@ pub(super) fn draw(display: &mut Display) {
&mut display.glyph_cache,
);
if !row.hint.is_empty() {
let hint_w = row.hint.chars().count() as f32 * size.cell_width();
display.renderer.draw_chrome_text(
&size,
row.rect.0 + row.rect.2 - hint_w - s(10.0),
y,
dim,
let combo = keycap::layout_combo(
row.hint,
&mut display.glyph_cache,
row.rect.0 + row.rect.2 - s(10.0),
row.rect.1 + row.rect.3 * 0.5,
size.cell_width(),
scale,
);
let (_, key_y, _, key_h) = combo.bounds;
let key_text_y = key_y + (key_h - cell_h) * 0.5;
for (chip_x, chip_w, key) in &combo.chips {
let key_w = key.chars().map(|c| c.width().unwrap_or(0)).sum::<usize>() as f32
* size.cell_width();
display.renderer.draw_chrome_text(
&size,
chip_x + (chip_w - key_w) * 0.5,
key_text_y,
dim,
key,
&mut display.glyph_cache,
);
}
}
}
if let Some((x, y)) = layout.color_label {
+6 -8
View File
@@ -4229,8 +4229,7 @@ impl Display {
// 多级 fallback 列表(issue #33)在目录里以主族身份参与匹配与高亮;
// 链本身仍原样保存在设置值里。
let primary_family =
crate::renderer::text::glyph_cache::primary_font_family(&self.nebula_font_family)
.to_owned();
crate::renderer::primary_font_family(&self.nebula_font_family).to_owned();
let catalog = crate::font_install::font_catalog(
&system,
&imported,
@@ -4591,12 +4590,11 @@ impl Display {
// 字体选择器只换主族;用户手写的多级 fallback 链(逗号分隔,
// issue #33)原样保留在新值后面。
let family = {
let rest: Vec<&str> =
crate::renderer::text::glyph_cache::split_font_families(&self.nebula_font_family)
.into_iter()
.skip(1)
.filter(|fallback| *fallback != family)
.collect();
let rest: Vec<&str> = crate::renderer::split_font_families(&self.nebula_font_family)
.into_iter()
.skip(1)
.filter(|fallback| *fallback != family)
.collect();
if rest.is_empty() { family } else { format!("{family}, {}", rest.join(", ")) }
};
self.nebula_font_family = family;
+81 -49
View File
@@ -1853,12 +1853,13 @@ pub(crate) fn opacity_from_pointer(pointer_x: f32, slider: (f32, f32, f32, f32))
((pointer_x - slider.0) / slider.2.max(1.0)).clamp(0.0, 1.0)
}
/// 主机行右缘的三枚方形图标槽(连接 / 编辑 / 隐藏)。绘制与命中共用这一处
/// 推导,是"看得见的按钮点不中"的唯一防线。
/// 主机行右缘的动作槽。绘制与命中共用这一处推导,是"看得见的按钮点不中"的
/// 唯一防线。
///
/// 2026-08-11:从 54px 宽的描边文字按钮改成方形图标槽。文字按钮常态占掉右侧
/// 约 180px,把主机名挤到一半宽;图标槽只在 hover 时显形,静态那一行只剩身份
/// 信息。命中区保持正方且比墨迹宽,指针容差不被视觉尺寸绑死。
/// 2026-08-11 对齐原型:**主动作「连接」是文字按钮**(白底+描边+"连接"二字),
/// 编辑/隐藏是方形图标槽。原型里唯一带底的就是主动作——一行五个等价图标会让
/// 人逐个悬停去猜哪个是"进去",文字消掉这次猜测。三个槽都只在 hover 时显形,
/// 静态那一行只剩身份信息。命中区比墨迹宽,指针容差不被视觉尺寸绑死。
fn ssh_host_action_rect(
row: (f32, f32, f32, f32),
scale: f32,
@@ -1866,13 +1867,26 @@ fn ssh_host_action_rect(
) -> (f32, f32, f32, f32) {
let s = |v: f32| v * scale;
let (x, y, w, h) = row;
let gap = s(2.0);
let right = x + w - s(12.0);
let slot = s(28.0).min((h - s(10.0)).max(s(20.0)));
let bx = right
- slot * (3usize.saturating_sub(action) as f32)
- gap * (2usize.saturating_sub(action) as f32);
(bx, widgets::centered_y(y, h, slot), slot, slot)
let gap = s(4.0);
let right = x + w - s(14.0);
let slot = s(26.0).min((h - s(10.0)).max(s(18.0)));
let connect_w = s(52.0);
// 从右往左排:隐藏、编辑、连接。连接最宽,所以单独算。
match action {
2 => {
let bx = right - slot;
(bx, widgets::centered_y(y, h, slot), slot, slot)
},
1 => {
let bx = right - slot * 2.0 - gap;
(bx, widgets::centered_y(y, h, slot), slot, slot)
},
_ => {
let bh = s(26.0).min((h - s(10.0)).max(s(18.0)));
let bx = right - slot * 2.0 - gap * 2.0 - connect_w;
(bx, widgets::centered_y(y, h, bh), connect_w, bh)
},
}
}
/// Compact trailing command button inside an otherwise non-clickable settings row.
@@ -3218,8 +3232,7 @@ fn dropdown_selected_index(view: &SettingsView, dropdown: SettingsDropdown) -> O
// 加一:弹层第 0 行是搜索框,候选整体下移一行。
SettingsDropdown::Font => {
// 多级 fallback 列表按主族高亮(issue #33)。
let primary =
crate::renderer::text::glyph_cache::primary_font_family(&view.font_family);
let primary = crate::renderer::primary_font_family(&view.font_family);
view.fonts.iter().position(|family| family == primary).map(|slot| slot + 1)
},
SettingsDropdown::BackgroundFit => {
@@ -4009,6 +4022,10 @@ pub(super) fn push_quads(
// 2026-08-11 用户裁定:常态不画卡片底。原来每行都铺 accent_soft,
// 五行灰块叠起来就是「灰蒙蒙」的来源。底色只在 hover 时出现,
// 静态列表回到纯净的白底 + 文字层级。
//
// hover 底用 `surface`(8%)而不是 `hover`(13%):整行是个很大
// 的面,同一个 alpha 铺在 26px 的图标槽上是"轻轻一提",铺满一
// 整行就成了灰条。大面积要更淡,小面积才允许更重。
let row_hovered = matches!(
view.hover,
SettingsHit::SshHostRow(i)
@@ -4020,7 +4037,7 @@ pub(super) fn push_quads(
if row_hovered {
clip(
quads,
UiQuad::solid(rx, ry, rw, rh, s(tokens::radius::OVERLAY), sk.hover),
UiQuad::solid(rx, ry, rw, rh, s(tokens::radius::OVERLAY), sk.surface),
);
}
// 行左缘画主机的 OS 图标(文字 pass,os_icons 字形),不再是
@@ -4028,21 +4045,26 @@ pub(super) fn push_quads(
// 主机模型依旧没有可靠的在线态,图标标注的是「这是哪台机器」,
// 不发明绿点。
//
// 三枚动作图标同样只在 hover 时显形,且不再套描边方框——方框
// 加文字常态吃掉右侧 180px,把主机名挤到一半宽。命中区始终存在
// (见 ssh_host_action_rect),只是墨迹不画。
// 右缘动作整组只在 hover 时显形。命中区始终存在(见
// ssh_host_action_rect),只是墨迹不画。
if row_hovered {
// 眼睛图标要挖空,挖空色必须是这一枚图标脚下的实际底色,
// 否则半透明的 hover washes 叠起来会在瞳孔周围留一圈脏边。
let row_bg = surface::over(sk.hover, sk.panel);
for (action, icon) in [
(0usize, icons::RowActionIcon::Connect),
(1usize, icons::RowActionIcon::Edit),
(2usize, icons::RowActionIcon::Hide),
] {
let row_bg = surface::over(sk.surface, sk.panel);
// 主动作「连接」:白底 + 描边的真按钮,是这一行唯一带底的
// 东西,也是原型里唯一带底的那个。
let connect = ssh_host_action_rect(row, scale, 0);
let connect_hot = view.hover == SettingsHit::SshHostConnect(index);
let mut button = Vec::new();
widgets::push_outline_button(&mut button, connect, scale, &sk, connect_hot);
for quad in button {
clip(quads, quad);
}
// 次级动作用图标,挖空色取图标脚下的实际底色——半透明的
// hover wash 叠起来若用错基色,眼睛的瞳孔周围会留一圈脏边。
for (action, icon) in
[(1usize, icons::RowActionIcon::Edit), (2usize, icons::RowActionIcon::Hide)]
{
let rect = ssh_host_action_rect(row, scale, action);
let icon_hovered = match (action, view.hover) {
(0, SettingsHit::SshHostConnect(i)) => i == index,
(1, SettingsHit::SshHostEdit(i)) => i == index,
(2, SettingsHit::SshHostHide(i)) => i == index,
_ => false,
@@ -4056,22 +4078,15 @@ pub(super) fn push_quads(
rect.2,
rect.3,
s(tokens::radius::CONTROL),
sk.hover_strong,
sk.hover,
),
);
(sk.ink.into(), surface::over(sk.hover_strong, row_bg))
(Rgba::opaque(sk.ink), surface::over(sk.hover, row_bg))
} else {
(sk.ink_dim.into(), row_bg)
(Rgba::opaque(sk.ink_faint), row_bg)
};
let mut ink_quads = Vec::new();
icons::push_row_action_icon(
&mut ink_quads,
icon,
rect,
scale,
ink,
cutout,
);
icons::push_row_action_icon(&mut ink_quads, icon, rect, scale, ink, cutout);
for quad in ink_quads {
clip(quads, quad);
}
@@ -6358,15 +6373,6 @@ pub(super) fn draw_text(
gc,
);
}
let draw_centered_action = |r: &mut Renderer,
gc: &mut GlyphCache,
rect: (f32, f32, f32, f32),
label: &str,
ink: Rgb| {
let cols = label.chars().map(|ch| ch.width().unwrap_or(1)).sum::<usize>();
let tx = rect.0 + (rect.2 - cols as f32 * cell_w) * 0.5;
r.draw_chrome_text(size, tx, rect.1 + (rect.3 - cell_h) / 2.0, ink, label, gc);
};
for (index, host) in view.ssh_hosts.iter().enumerate() {
let row = (
host_x,
@@ -6410,8 +6416,34 @@ pub(super) fn draw_text(
if host.pinned {
r.draw_chrome_text(size, row.0 + s(27.0), title_y, sk.accent, "\u{eab4}", gc);
}
// 三枚动作图标改由 quad pass 绘制(icons::push_row_action_icon),
// 文字 pass 这里不再画「连接 / 编辑 / 隐藏」三个词。
// 编辑 / 隐藏是纯图标(quad pass 画),主动作「连接」保留文字:
// 一行全是等价图标会逼人逐个悬停去猜哪个是"进去"。整组同样只在
// hover 时显形。
let row_hovered = matches!(
view.hover,
SettingsHit::SshHostRow(i)
| SettingsHit::SshHostConnect(i)
| SettingsHit::SshHostEdit(i)
| SettingsHit::SshHostHide(i)
if i == index
);
if row_hovered {
let connect = ssh_host_action_rect(row, scale, 0);
let label = language.pick("连接", "Connect");
let cols = label.chars().map(|ch| ch.width().unwrap_or(1)).sum::<usize>();
r.draw_chrome_text(
size,
connect.0 + (connect.2 - cols as f32 * cell_w) * 0.5,
widgets::centered_y(connect.1, connect.3, cell_h),
if view.hover == SettingsHit::SshHostConnect(index) {
sk.accent
} else {
sk.ink
},
label,
gc,
);
}
}
if visible(geometry.ssh_import_config.1, geometry.ssh_import_config.3) {
row_label(
+22 -39
View File
@@ -335,13 +335,14 @@ pub(crate) fn push_check(
);
}
/// 主机行右缘的动作图标:hover 才显形,替代原来三个描边文字按钮。
/// 与设置导航图标同一套配方(线性圆润、真墨迹居中、整像素吸附),
/// 尺寸统一按 `rect` 短边推导,因此三枚在任何 DPI 下都等重。
/// 主机行右缘的动作图标:hover 才显形。主动作「连接」是文字按钮(原型里
/// 唯一带底的那个),这里只画次级动作——一行全是等价图标会逼人逐个悬停去
/// 猜哪个是"进去"。
///
/// 与设置导航图标同一套配方:线性圆润、真墨迹居中、外沿整像素吸附,尺寸统
/// 一按 `rect` 推导,因此几枚在任何 DPI 下都等重。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RowActionIcon {
/// 连接:右向三角,播放/前往的通用语义。
Connect,
/// 编辑:斜置笔杆 + 笔尖。
Edit,
/// 隐藏:眼睛加一道斜杠。
@@ -362,44 +363,26 @@ pub(crate) fn push_row_action_icon(
}
let cx = (x + width * 0.5).round();
let cy = (y + height * 0.5).round();
let stroke = (1.3 * scale).max(1.0);
let stroke = (1.25 * scale).max(1.0);
match icon {
// 实心三角比描边箭头更稳:这一枚尺寸最小,描边到 13px 见方时
// 两条斜边会各自吃掉半像素,糊成灰块。
RowActionIcon::Connect => {
let half_h = 4.4 * scale;
let reach = 4.0 * scale;
let left = cx - 2.6 * scale;
let steps = (half_h * 2.0).round().max(2.0) as i32;
for i in 0..steps {
let t = i as f32 / steps as f32;
let row_y = cy - half_h + t * half_h * 2.0;
// 三角在垂直中线处最宽,向上下两端线性收窄。
let w = reach * (1.0 - (t - 0.5).abs() * 2.0);
if w <= 0.0 {
continue;
}
quads.push(UiQuad::solid(left, row_y.round(), w.max(1.0), 1.0, 0.0, ink));
}
},
RowActionIcon::Edit => {
// 笔杆:从左下到右上的一道斜线,末端补一小段更粗的笔尖。
let tip = (cx - 4.6 * scale, cy + 4.6 * scale);
let tail = (cx + 4.2 * scale, cy - 4.2 * scale);
// 笔杆:左下到右上一道斜线;笔尖再补一小段加粗,让它读成"笔"
// 而不是"斜杠"(斜杠已经被 Hide 占用,两枚不能撞形)。
let tip = (cx - 4.4 * scale, cy + 4.4 * scale);
let tail = (cx + 4.4 * scale, cy - 4.4 * scale);
push_segment(quads, tip, tail, stroke, ink);
// 笔尖的小三角:一个贴着 tip 的短粗段,让斜线读成"笔"而非"斜杠"。
push_segment(
quads,
(tip.0, tip.1),
(tip.0 + 1.9 * scale, tip.1 - 1.9 * scale),
stroke * 2.0,
tip,
(tip.0 + 1.8 * scale, tip.1 - 1.8 * scale),
stroke * 2.1,
ink,
);
},
RowActionIcon::Hide => {
// 眼睛:外圈椭圆(用圆角矩形逼近)挖空 + 中心瞳孔,再压一道斜杠。
let w = 11.0 * scale;
let h = 6.6 * scale;
// 眼睛:圆角胶囊挖空当外圈轮廓 + 中心瞳孔,再压一道斜杠。
let w = 11.5 * scale;
let h = 7.0 * scale;
let ex = (cx - w * 0.5).round();
let ey = (cy - h * 0.5).round();
quads.push(UiQuad::solid(ex, ey, w, h, h * 0.5, ink));
@@ -411,7 +394,7 @@ pub(crate) fn push_row_action_icon(
(h * 0.5 - stroke).max(0.0),
cutout,
));
let pupil = 3.0 * scale;
let pupil = 2.8 * scale;
quads.push(UiQuad::solid(
cx - pupil * 0.5,
cy - pupil * 0.5,
@@ -420,10 +403,10 @@ pub(crate) fn push_row_action_icon(
pupil * 0.5,
ink,
));
// 斜杠先用底色垫一条更宽的,避免与眼睛的墨迹糊在一起。
let a = (cx - 5.4 * scale, cy + 5.4 * scale);
let b = (cx + 5.4 * scale, cy - 5.4 * scale);
push_segment(quads, a, b, stroke * 2.6, cutout);
// 斜杠先垫一条底色的更宽段,避免与眼睛墨迹糊成一团黑。
let a = (cx - 5.6 * scale, cy + 5.6 * scale);
let b = (cx + 5.6 * scale, cy - 5.6 * scale);
push_segment(quads, a, b, stroke * 2.8, cutout);
push_segment(quads, a, b, stroke, ink);
},
}
+52 -30
View File
@@ -1,17 +1,8 @@
//! 键帽规范:所有展示键位的地方共用一颗 chip——hairline 圈边 +
//! panel/surface 叠底、小圆角;不再各处自造键帽样式,也不叠加额外装饰。
//!
//! # 为什么是**一整串**而不是逐键拆分
//!
//! 2026-07-29 用户裁定,推翻了本文件的上一版。逐键 chip 是 macOS 的形式:
//! 那里修饰键是 `⌘ ⇧ ⌥` 单字符图形,一符号一方块天然成立、宽度还整齐。
//! Windows 上修饰键是 `Ctrl` `Shift` `Alt` 这种**单词**,拆开会得到一排
//! 宽度参差的方块,画面反而更碎。
//!
//! 更重要的是心智迁移:Windows 用户熟悉把组合键显示成
//! `Ctrl+Shift+P` 一整串。沿用这套系统惯例,用户能直接复用既有识别习惯;
//! 自造形式则要他们先解析
//! 一遍"这几个方块是一个组合还是三个选项"。
//! 组合键按物理按键拆成相邻的小键帽。每颗键帽都有独立的表面、底边和
//! 呼吸缝,长组合键因此仍然可扫读,不会变成一块写满文字的灰色胶囊。
//!
//! `layout_combo` 是几何唯一来源:quad pass 与 text pass 各调一次拿到
//! 相同矩形(同 settings 键位行 quad/text 共用几何的既有约定)。
@@ -25,7 +16,7 @@ use crate::renderer::ui::UiQuad;
/// Chip height in logical px(与 palette 底栏既有键帽一致)。
pub const KEY_H: f32 = 20.0;
/// chip 内的左右呼吸缝。整串比单键长,边距太小会显得字挤在框上。
/// 每颗键帽内的左右呼吸缝。单键也要留出足够边距,避免文字贴住轮廓。
const PAD_X: f32 = 7.0;
fn cols(text: &str) -> usize {
@@ -42,8 +33,8 @@ pub struct ComboChips {
pub bounds: (f32, f32, f32, f32),
}
/// Lay out a `Ctrl+Shift+P` style combo as one chip, right-aligned so it ends
/// at `right_x`, vertically centered on `center_y`.
/// Lay out a `Ctrl+Shift+P` style combo as separate keycaps, right-aligned so
/// the final cap ends at `right_x`, vertically centered on `center_y`.
pub fn layout_combo(
combo: &str,
right_x: f32,
@@ -54,12 +45,24 @@ pub fn layout_combo(
let s = |v: f32| v * scale;
let key_h = s(KEY_H);
let key_y = center_y - key_h / 2.0;
// 规范化:丢掉空段后用 "+" 重新连接。畸形输入(末尾多一个 "+"、
// 连续 "++")因此得到稳定结果,与上一版逐键实现的取舍保持一致。
let label = combo.split('+').filter(|k| !k.is_empty()).collect::<Vec<_>>().join("+");
let width = cols(&label) as f32 * cell_w + s(PAD_X) * 2.0;
let x = right_x - width;
ComboChips { chips: vec![(x, width, label)], bounds: (x, key_y, width, key_h) }
// 丢掉空段,避免配置里的末尾/连续 `+` 生成空键帽;加号是键帽之间的
// 视觉连接,不占任何一颗键帽的文字宽度。
let labels: Vec<&str> = combo.split('+').filter(|key| !key.is_empty()).collect();
let gap = s(4.0);
let widths: Vec<f32> =
labels.iter().map(|label| cols(label) as f32 * cell_w + s(PAD_X) * 2.0).collect();
let total = widths.iter().sum::<f32>() + gap * widths.len().saturating_sub(1) as f32;
let mut x = right_x - total;
let chips = labels
.into_iter()
.zip(widths)
.map(|(label, width)| {
let chip = (x, width, label.to_owned());
x += width + gap;
chip
})
.collect();
ComboChips { chips, bounds: (right_x - total, key_y, total, key_h) }
}
/// The one chip recipe: hairline ring + panel + surface, chip radius.
@@ -117,6 +120,23 @@ pub fn push_combo(quads: &mut Vec<UiQuad>, sk: &Skin, combo: &ComboChips, scale:
push_combo_with_hover(quads, sk, combo, scale, false);
}
/// Push a combo whose surface follows a transient overlay's alpha. Context
/// menus animate their panel and labels together; fading the three keycap
/// layers here prevents the shortcut caps from popping in a frame early.
pub fn push_combo_with_progress(
quads: &mut Vec<UiQuad>,
sk: &Skin,
combo: &ComboChips,
scale: f32,
progress: f32,
) {
let mut faded = *sk;
faded.panel = super::surface::fade(faded.panel, progress);
faded.surface = super::surface::fade(faded.surface, progress);
faded.hairline = super::surface::fade(faded.hairline, progress);
push_combo(quads, &faded, combo, scale);
}
/// Push every chip of a laid-out combo, with a subtle hover color only.
pub fn push_combo_with_hover(
quads: &mut Vec<UiQuad>,
@@ -136,24 +156,26 @@ mod tests {
use super::*;
#[test]
fn combo_is_one_chip_carrying_the_whole_string() {
// Windows 心智:`Ctrl+K` 是一颗键帽,不是两颗方块加一个加号。
fn combo_is_split_into_keycaps_and_right_aligned() {
let combo = layout_combo("Ctrl+K", 500.0, 100.0, 10.0, 1.0);
assert_eq!(combo.chips.len(), 1);
assert_eq!(combo.chips.len(), 2);
let (x, w, ref label) = combo.chips[0];
assert_eq!(label, "Ctrl+K");
assert!((x + w - 500.0).abs() < 0.01, "chip 右缘落在 right_x");
assert_eq!(w, 6.0 * 10.0 + PAD_X * 2.0, "宽 = 整串列宽 + 左右呼吸缝");
assert_eq!(label, "Ctrl");
assert!((combo.chips[1].0 + combo.chips[1].1 - 500.0).abs() < 0.01);
assert!(combo.chips[1].0 > x + w, "键帽之间要有稳定呼吸缝");
let (bx, _, bw, bh) = combo.bounds;
assert!((bx + bw - 500.0).abs() < 0.01);
assert_eq!(bh, KEY_H);
}
#[test]
fn three_key_combo_stays_a_single_chip() {
fn three_key_combo_has_three_caps() {
let combo = layout_combo("Ctrl+Shift+P", 400.0, 50.0, 8.0, 1.0);
assert_eq!(combo.chips.len(), 1);
assert_eq!(combo.chips[0].2, "Ctrl+Shift+P");
assert_eq!(combo.chips.len(), 3);
assert_eq!(
combo.chips.iter().map(|chip| chip.2.as_str()).collect::<Vec<_>>(),
["Ctrl", "Shift", "P"]
);
}
#[test]
@@ -161,7 +183,7 @@ mod tests {
// 末尾多一个 "+"、连续 "++" 都不该画出空键帽。
for raw in ["Ctrl+", "+Ctrl", "Ctrl++K"] {
let combo = layout_combo(raw, 300.0, 50.0, 8.0, 1.0);
assert_eq!(combo.chips.len(), 1);
assert!(!combo.chips.is_empty());
assert!(!combo.chips[0].2.is_empty());
assert!(!combo.chips[0].2.ends_with('+'));
}
+9 -13
View File
@@ -307,23 +307,18 @@ impl NebulaTheme {
shell_bg: Rgb::new(34, 38, 48),
},
// Cool silver — the light half of the steel pair. Chrome layers
// follow the premium-light sheet: sidebar over app-bg, terminal
// pure white for maximum contrast.
// follow the premium-light sheet: sidebar #f3f4f6 over app-bg
// #f9fafb, terminal pure white for maximum contrast.
Self::SilverLight => NebulaPalette {
// Neutral silver, blue removed: the panel/tab surfaces sit on a
// true-neutral gray ramp (was Tailwind's blue-leaning gray-100),
// and the active-tab halo is a soft neutral shadow instead of a
// blue wash — a pure-white pill lifting off a flat gray gutter.
//
// 2026-08-11:整条灰阶上移约 7 级。原来外壳落在 gray-100
// (243),与纯白终端差 12 级——这么大面积上的 12 级读出来
// 不是"另一层表面",是蒙了一层灰。外壳提到 250 后卡片与
// 沟槽的分离仍然看得出,但整窗回到"浅"而不是"灰"。
panel: Rgba::new(252, 252, 253, 236),
pill: Rgba::new(241, 241, 243, 230),
tab_stroke_l: Rgba::new(208, 208, 211, 140),
panel: Rgba::new(245, 245, 246, 236),
pill: Rgba::new(233, 233, 234, 230),
tab_stroke_l: Rgba::new(198, 198, 200, 150),
tab_bg_l: Rgba::new(255, 255, 255, 242),
tab_bg_r: Rgba::new(253, 253, 254, 236),
tab_bg_r: Rgba::new(250, 250, 251, 236),
edge_l: Rgba::new(110, 112, 116, 170),
edge_r: Rgba::new(118, 121, 126, 180),
edge_glow_l: Rgba::new(118, 121, 126, 18),
@@ -337,8 +332,9 @@ impl NebulaTheme {
// Pure white terminal on every light theme (premium-light
// sheet): highest contrast for the Primer ANSI ink set.
term_bg: Rgb::new(255, 255, 255),
// 近白的银灰:白色终端卡片浮在它上面。
shell_bg: Rgb::new(250, 250, 251),
// Premium-light app-bg layer (#f3f4f6-ish): the white terminal
// card floats on this neutral silver.
shell_bg: Rgb::new(243, 244, 246),
},
// Warm limestone — the light half of the coal pair.
Self::LimestoneLight => NebulaPalette {
+3
View File
@@ -1083,6 +1083,9 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
self.ctx.mark_dirty();
return;
},
// 主机行本体只承载 hover(底色 + 右缘三枚图标显形),本身
// 不是动作:点空白处不该悄悄连上一台机器。
crate::display::SettingsHit::SshHostRow(_) => return,
crate::display::SettingsHit::SshHostConnect(index) => {
let host = self.ctx.display().nebula_ssh_hosts.get(index).cloned();
if let Some(host) = host {
+1
View File
@@ -470,6 +470,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
| crate::display::SettingsHit::SshJumpHostDropdown
| crate::display::SettingsHit::SshJumpHostOption(_)
| crate::display::SettingsHit::SshProxyOverrideEdit(_)
| crate::display::SettingsHit::SshHostRow(_)
| crate::display::SettingsHit::Reset => {
self.ctx.window().set_mouse_cursor(CursorIcon::Pointer);
return;
+1
View File
@@ -38,6 +38,7 @@ pub mod ui;
#[cfg(feature = "wgpu-foundation")]
pub mod wgpu_foundation;
pub(crate) use text::Rasterizer;
pub(crate) use text::glyph_cache::{primary_font_family, split_font_families};
pub use text::{GlyphCache, LoaderApi};
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -190,7 +190,7 @@ pub struct WindowContext {
/// 上一帧的聚焦 pane。变化时给新聚焦终端补发 CursorBlinkingChange,
/// blink 定时器按它的样式重新起表——否则从"不闪"的 pane 切到"该闪"
/// 的 pane 后表根本没开,光标永远常亮。
blink_focus_pane: Option<usize>,
blink_focus_pane: Option<PaneId>,
prev_bell_cmd: Option<Instant>,
/// When the PTYs last learned their size. Drives the leading-edge check of
/// the resize debounce: a lone resize (startup, maximize, sidebar toggle)
@@ -680,7 +680,7 @@ impl WindowContext {
false
},
TabRequest::NewProfile(profile) => {
self.spawn_tab_profile_value(profile);
self.spawn_tab_profile_value(profile, TabPlacement::Created);
false
},
TabRequest::NewShell { name, shell } => {
+220
View File
@@ -0,0 +1,220 @@
//! Read-only runtime projection and narrowly-scoped control actions.
//!
//! Keeping this adapter beside `WindowContext` lets the public API observe the
//! same tab/layout/pane authority as the GUI without exposing those mutable
//! implementation types to transport threads.
use nebula_terminal::event::Notify;
use crate::display::SplitDirection;
use crate::event::TabRequest;
use crate::runtime_api::{
ApiError, RuntimeLayout, RuntimePane, RuntimeSplitDirection, RuntimeTab, RuntimeTaskState,
RuntimeWindow,
};
use super::{Layout, TabLaunch, WindowContext};
impl WindowContext {
pub(crate) fn runtime_snapshot(&self) -> RuntimeWindow {
let tabs = self
.tabs
.iter()
.enumerate()
.map(|(index, tab)| {
let special = tab.doc.is_some() || tab.image.is_some() || tab.settings;
let mut pane_ids = Vec::new();
if !special {
tab.layout.leaves(&mut pane_ids);
}
let panes = pane_ids
.iter()
.filter_map(|id| self.pane(*id))
.map(|pane| RuntimePane {
id: pane.id,
active: pane.id == tab.active_pane,
title: pane.title.clone(),
cwd: pane.nebula_state.cwd.clone(),
branch: pane.nebula_state.branch.clone(),
ssh_destination: pane.ssh_destination.clone(),
running_program: pane.nebula_state.running_program.clone(),
task_state: task_state(pane),
// Seeded to 0; the hub stamps the real transition
// counter at publish time, where the previous snapshot
// is available to compare against.
state_change_seq: 0,
})
.collect();
let label = tab.custom_name.clone().unwrap_or_else(|| {
self.pane(tab.active_pane)
.map(Self::chrome_tab_label)
.unwrap_or_else(|| tab_kind(&tab.launch).to_owned())
});
RuntimeTab {
index,
active: index == self.active_tab,
label,
kind: tab_kind(&tab.launch).to_owned(),
bell: tab.has_bell,
focused_pane_id: (!special).then_some(tab.active_pane),
layout: (!special).then(|| runtime_layout(&tab.layout)),
panes,
}
})
.collect();
RuntimeWindow {
id: self.id().into(),
focused: self.display.window.has_focus(),
session_exempt: self.session_exempt,
active_tab: self.active_tab,
focused_pane_id: self
.tabs
.get(self.active_tab)
.filter(|tab| tab.doc.is_none() && tab.image.is_none() && !tab.settings)
.map(|tab| tab.active_pane),
tabs,
}
}
pub(crate) fn runtime_contains_pane(&self, pane_id: u64) -> bool {
self.pane(pane_id).is_some()
}
pub(crate) fn runtime_focus(&mut self, pane_id: Option<u64>) -> Result<(), ApiError> {
if let Some(pane_id) = pane_id {
let tab_index = self.tabs.iter().position(|tab| {
let mut ids = Vec::new();
tab.layout.leaves(&mut ids);
ids.contains(&pane_id)
});
let Some(tab_index) = tab_index else {
return Err(ApiError::new(
"target_not_found",
format!("pane {pane_id} does not belong to the target window"),
));
};
self.select_tab(tab_index);
self.tabs[tab_index].active_pane = pane_id;
}
self.display.window.focus_window();
self.dirty = true;
self.display.window.request_redraw();
Ok(())
}
pub(crate) fn runtime_new_tab(&mut self) -> Result<u64, ApiError> {
let before = self.tabs.len();
self.handle_tab_request(TabRequest::New);
if self.tabs.len() == before {
return Err(ApiError::new(
"action_failed",
"the default shell could not be started for a new tab",
));
}
Ok(self.tabs[self.active_tab].active_pane)
}
pub(crate) fn runtime_split(
&mut self,
direction: RuntimeSplitDirection,
) -> Result<u64, ApiError> {
let special = self
.tabs
.get(self.active_tab)
.is_none_or(|tab| tab.doc.is_some() || tab.image.is_some() || tab.settings);
if special {
return Err(ApiError::new(
"invalid_state",
"document, image, and settings tabs cannot be split",
));
}
let before = self.panes.len();
let direction = match direction {
RuntimeSplitDirection::LeftRight => SplitDirection::LeftRight,
RuntimeSplitDirection::TopBottom => SplitDirection::TopBottom,
};
self.handle_tab_request(TabRequest::SplitToggle(direction));
if self.panes.len() == before {
return Err(ApiError::new(
"action_failed",
"the default shell could not be started for a new pane",
));
}
Ok(self.tabs[self.active_tab].active_pane)
}
pub(crate) fn runtime_prompt(
&mut self,
pane_id: u64,
text: String,
submit: bool,
) -> Result<(), ApiError> {
crate::runtime_api::validate_prompt(&text)?;
let Some(index) = self.pane_index(pane_id) else {
return Err(ApiError::new(
"target_not_found",
format!("pane {pane_id} does not belong to the target window"),
));
};
let pane = &mut self.panes[index];
pane.notifier.notify(text.into_bytes());
if submit {
pane.notifier.notify(vec![b'\r']);
}
// Direct API input has the same semantic effect as keyboard input:
// stale completion/attention badges must not survive a new turn.
pane.nebula_state.touched = true;
pane.nebula_state.awaiting_input = false;
pane.nebula_state.needs_attention = false;
pane.nebula_state.finished_unseen = false;
pane.nebula_state.failed_unseen = false;
self.dirty = true;
self.display.window.request_redraw();
Ok(())
}
}
fn task_state(pane: &super::Pane) -> RuntimeTaskState {
let state = &pane.nebula_state;
if state.needs_attention {
RuntimeTaskState::Attention
} else if state.awaiting_input {
RuntimeTaskState::WaitingInput
} else if state.command_started.is_some() {
RuntimeTaskState::Running
} else if state.failed_unseen {
RuntimeTaskState::Failed
} else if state.finished_unseen {
RuntimeTaskState::Finished
} else {
RuntimeTaskState::Idle
}
}
fn tab_kind(launch: &TabLaunch) -> &'static str {
match launch {
TabLaunch::Default => "shell",
TabLaunch::Profile(_) => "profile",
TabLaunch::Shell { .. } => "shell",
TabLaunch::Ssh(_) => "ssh",
TabLaunch::Document(_) => "document",
TabLaunch::Image(_) => "image",
TabLaunch::Settings => "settings",
}
}
fn runtime_layout(layout: &Layout) -> RuntimeLayout {
match layout {
Layout::Leaf(pane_id) => RuntimeLayout::Pane { pane_id: *pane_id },
Layout::Split { direction, ratio, first, second, .. } => RuntimeLayout::Split {
direction: match direction {
SplitDirection::LeftRight => RuntimeSplitDirection::LeftRight,
SplitDirection::TopBottom => RuntimeSplitDirection::TopBottom,
},
ratio: *ratio,
first: Box::new(runtime_layout(first)),
second: Box::new(runtime_layout(second)),
},
}
}
+201 -3
View File
@@ -1,6 +1,6 @@
//! Grid resize and reflow.
use std::cmp::{Ordering, max, min};
use std::cmp::{max, min, Ordering};
use std::mem;
use crate::index::{Boundary, Column, Line};
@@ -25,12 +25,48 @@ impl<T: GridCell + Default + PartialEq> Grid<T> {
Ordering::Equal => (),
}
match self.columns.cmp(&columns) {
// Anchor the cursor to the content *before* reflowing, and put it back
// afterwards, instead of trusting the running total that `grow_columns` /
// `shrink_columns` keep as they splice rows.
//
// Those two maintain the cursor by incremental bookkeeping
// (`cursor_line_delta`, `point.column -= columns`, and a couple of
// branches that `continue` before recording anything). A single step is
// right, but an interactive drag calls this once per column — around 80
// times for a 120→40 column drag, and 80 more on the way back — and the
// error compounds until the cursor sits in the middle of old output.
//
// Windows Terminal avoids the problem by not keeping a separate tally at
// all: `TextBuffer::Reflow` rewrites the content cell by cell and reads
// the cursor back off wherever its cell landed
// (`newCursorPos = { AdjustToGlyphStart(oldCursorPos.x - oldX + newX), newY }`,
// textBuffer.cpp:2879). The cursor rides the content. This does the same
// thing without touching the splice logic: remember which logical line
// the cursor is on and how far into it, then re-derive the position from
// the reflowed buffer.
//
// Only when the rows actually get spliced, though. Widening with reflow
// disabled just makes every row wider without moving a single cell, and
// anchoring through that would *introduce* a jump: the offset was
// measured in old columns and each row now spans more of them.
let column_order = self.columns.cmp(&columns);
let splices_rows = match column_order {
Ordering::Less => reflow && self.reflow_on_grow,
Ordering::Greater => reflow,
Ordering::Equal => false,
};
let anchor = if splices_rows { self.cursor_anchor() } else { None };
match column_order {
Ordering::Less => self.grow_columns(reflow && self.reflow_on_grow, columns),
Ordering::Greater => self.shrink_columns(reflow, columns),
Ordering::Equal => (),
}
if let Some(anchor) = anchor {
self.restore_cursor_anchor(anchor);
}
// Restore template cell.
self.cursor.template = template;
}
@@ -181,7 +217,25 @@ impl<T: GridCell + Default + PartialEq> Grid<T> {
// this will always be either `0` or `1`.
let line_delta = self.cursor.point.line - target.line;
if line_delta != 0 && row.is_clear() {
if row.is_clear() {
// The row was merged away in full, so it must go, exactly
// like any other emptied continuation row.
//
// Upstream only drops it when `line_delta != 0`, which never
// holds for a cursor on the top visible row: `Point::sub`
// clamps at `Line(0)` under `Boundary::Cursor` (index.rs:103),
// so the delta comes out `0` and the blank row survives. It
// then stands exactly where the content used to be and pushes
// that content up a row into the scrollback — which reads as
// the whole viewport having scrolled by itself.
//
// Dropping it unconditionally is safe now that the cursor is
// re-derived from `CursorAnchor` after the reflow rather than
// from these running deltas.
if i < self.display_offset {
self.display_offset = self.display_offset.saturating_sub(1);
}
continue;
}
@@ -386,4 +440,148 @@ impl<T: GridCell + Default + PartialEq> Grid<T> {
// Clamp the saved cursor to the grid.
self.saved_cursor.point.column = min(self.saved_cursor.point.column, Column(columns - 1));
}
/// Whether this row soft-wraps into the one below it.
///
/// The flag lives on the row's *physical* last cell, which is where both
/// `shrink_columns` writes it and `grow_columns` reads it.
fn has_wrapline(&self, line: Line) -> bool {
if line < self.topmost_line() || line > self.bottommost_line() {
return false;
}
let row = &self[line];
let len = row.len();
len > 0 && row[Column(len - 1)].flags().contains(Flags::WRAPLINE)
}
/// Columns this row contributes to its logical line.
///
/// A trailing [`Flags::LEADING_WIDE_CHAR_SPACER`] is padding inserted because
/// a wide char would not fit — the char itself lives on the continuation row,
/// so the spacer holds no content and must not shift the offset.
fn logical_width(&self, line: Line) -> usize {
let row = &self[line];
let len = row.len();
if len > 0 && row[Column(len - 1)].flags().contains(Flags::LEADING_WIDE_CHAR_SPACER) {
len - 1
} else {
len
}
}
/// Record where the cursor sits relative to the *content*, so it can be found
/// again after the rows underneath it have been split or merged.
///
/// Returns `None` when the cursor is outside the viewport, which leaves the
/// existing bookkeeping in charge rather than guessing.
fn cursor_anchor(&self) -> Option<CursorAnchor> {
let cursor = self.cursor.point;
if cursor.line < Line(0) || cursor.line > self.bottommost_line() {
return None;
}
// Walk up to the first row of the cursor's logical line, accumulating the
// columns every row above the cursor contributes. `input_needs_wrap`
// means "one past the last column", so it counts as one more column.
let topmost = self.topmost_line();
let mut start = cursor.line;
let mut offset = cursor.column.0 + usize::from(self.cursor.input_needs_wrap);
while start > topmost && self.has_wrapline(start - 1) {
start -= 1;
offset += self.logical_width(start);
}
// Find the logical line's last row, then count the logical lines below
// it. Reflow splits and merges rows *within* a logical line and never
// moves a hard line break, so this count is what survives it — physical
// row indices do not. Counting from the bottom also makes it immune to
// scrollback truncation, which only ever drops the oldest rows.
let bottommost = self.bottommost_line();
let mut end = cursor.line;
while end < bottommost && self.has_wrapline(end) {
end += 1;
}
let mut logical_lines_below = 0;
let mut line = end + 1;
while line <= bottommost {
// Every logical line ends in exactly one row without a wrap flag.
if !self.has_wrapline(line) {
logical_lines_below += 1;
}
line += 1;
}
Some(CursorAnchor { logical_lines_below, offset })
}
/// Put the cursor back on the cell it was anchored to, overriding whatever
/// the reflow's own bookkeeping arrived at.
fn restore_cursor_anchor(&mut self, anchor: CursorAnchor) {
let topmost = self.topmost_line();
let bottommost = self.bottommost_line();
// Walk up from the bottom of the buffer until `logical_lines_below`
// logical lines have been passed; the next row up ends the anchored one.
let mut end = bottommost;
let mut seen = 0;
while seen < anchor.logical_lines_below {
if !self.has_wrapline(end) {
seen += 1;
}
if end == topmost {
// The anchored line is no longer in the buffer; leave the
// cursor where the reflow put it rather than inventing a spot.
return;
}
end -= 1;
}
// Then back up to that logical line's first row and walk down through it,
// spending the recorded offset one row at a time.
let mut line = end;
while line > topmost && self.has_wrapline(line - 1) {
line -= 1;
}
let mut remaining = anchor.offset;
while line < end {
let width = self.logical_width(line);
if remaining < width {
break;
}
remaining -= width;
line += 1;
}
let last_column = Column(self.columns - 1);
if remaining > last_column.0 {
// Offset landed one past the end: the pending-wrap state, which is
// how a full row keeps the cursor addressable.
self.cursor.point.column = last_column;
self.cursor.input_needs_wrap = true;
} else {
self.cursor.point.column = Column(remaining);
self.cursor.input_needs_wrap = false;
}
// A logical line long enough to push its own start above the viewport
// leaves the cursor clamped to the top row, matching what the reflow
// paths already do in that case.
self.cursor.point.line = max(line, Line(0));
}
}
/// Where the cursor sits in the content, in terms that survive a reflow.
///
/// Both fields are measured against the *logical* line — the run of rows joined
/// by [`Flags::WRAPLINE`] — because that is the unit reflow preserves.
struct CursorAnchor {
/// Logical lines between the cursor's own and the bottom of the buffer.
logical_lines_below: usize,
/// Cursor's column offset from the start of its logical line.
offset: usize,
}
+106 -12
View File
@@ -270,16 +270,16 @@ fn grow_reflow() {
assert_eq!(grid.total_lines(), 2);
// '3' rejoins the row it was wrapped off of, and the wrap mark is consumed.
assert_eq!(grid[Line(1)].len(), 3);
assert_eq!(grid[Line(1)][Column(0)], cell('1'));
assert_eq!(grid[Line(1)][Column(1)], cell('2'));
assert_eq!(grid[Line(1)][Column(2)], cell('3'));
assert_eq!(grid[Line(0)].len(), 3);
assert_eq!(grid[Line(0)][Column(0)], cell('1'));
assert_eq!(grid[Line(0)][Column(1)], cell('2'));
assert_eq!(grid[Line(0)][Column(2)], cell('3'));
// The vacated row is blank, not a leftover copy.
assert_eq!(grid[Line(0)].len(), 3);
assert_eq!(grid[Line(0)][Column(0)], Cell::default());
assert_eq!(grid[Line(0)][Column(1)], Cell::default());
assert_eq!(grid[Line(0)][Column(2)], Cell::default());
assert_eq!(grid[Line(1)].len(), 3);
assert_eq!(grid[Line(1)][Column(0)], Cell::default());
assert_eq!(grid[Line(1)][Column(1)], Cell::default());
assert_eq!(grid[Line(1)][Column(2)], Cell::default());
}
/// The property that actually matters to the user: narrowing then widening back
@@ -300,6 +300,99 @@ fn shrink_then_grow_restores_the_original_layout() {
}
}
/// Restoring the *text* is only half the round-trip: the cursor has to land back
/// where it started too, or the next keystroke overwrites the wrong cell.
///
/// The cursor sits right after the text, which is where a shell prompt leaves
/// it. Alacritty tracks the cursor through reflow by incremental bookkeeping
/// (`cursor_line_delta` and friends in `grow_columns`) rather than deriving it
/// from where the content landed, and its grid tests never assert on the cursor
/// at all — so this is the property most likely to be wrong.
#[test]
fn shrink_then_grow_restores_the_cursor() {
let mut grid = Grid::<Cell>::new(3, 6, 4);
for (i, c) in "123456".chars().enumerate() {
grid[Line(0)][Column(i)] = cell(c);
}
grid.cursor.point = Point::new(Line(1), Column(0));
grid.resize(true, 3, 2);
grid.resize(true, 3, 6);
assert_eq!(grid.cursor.point, Point::new(Line(1), Column(0)), "cursor after round-trip");
}
/// Same round-trip with the cursor parked *inside* the text that gets wrapped
/// and re-merged, rather than on the empty line below it.
#[test]
fn shrink_then_grow_restores_a_cursor_inside_wrapped_text() {
let mut grid = Grid::<Cell>::new(3, 6, 4);
for (i, c) in "123456".chars().enumerate() {
grid[Line(0)][Column(i)] = cell(c);
}
// On '5' — column 4 of the logical line, which a shrink to 2 columns pushes
// onto the third wrapped row.
grid.cursor.point = Point::new(Line(0), Column(4));
grid.resize(true, 3, 2);
grid.resize(true, 3, 6);
assert_eq!(grid[Line(0)][Column(4)], cell('5'), "cursor's cell after round-trip");
assert_eq!(grid.cursor.point, Point::new(Line(0), Column(4)), "cursor after round-trip");
}
/// The round-trips above resize in one jump. An interactive drag does not: with
/// cell-sized resize increments the window emits one resize *per column*, so a
/// 20→4 drag calls `resize` 16 times and 16 more on the way back.
///
/// This is the case that actually broke. Each individual step looked right, but
/// the cursor used to be carried along by incremental bookkeeping inside
/// `grow_columns` / `shrink_columns`, and the per-step error compounded until the
/// cursor sat somewhere in the middle of old output.
#[test]
fn dragging_one_column_at_a_time_keeps_the_cursor_on_its_cell() {
let text = "0123456789abcdefghij";
let mut grid = Grid::<Cell>::new(3, 20, 8);
for (i, c) in text.chars().enumerate() {
grid[Line(0)][Column(i)] = cell(c);
}
grid.cursor.point = Point::new(Line(1), Column(0));
for columns in (4..20).rev() {
grid.resize(true, 3, columns);
}
for columns in 5..=20 {
grid.resize(true, 3, columns);
}
for (i, c) in text.chars().enumerate() {
assert_eq!(grid[Line(0)][Column(i)], cell(c), "column {i} after the drag");
}
assert_eq!(grid.cursor.point, Point::new(Line(1), Column(0)), "cursor after the drag");
}
/// The shape from the bug report: a shell prompt leaves the cursor just past the
/// text it printed, on the same row. Nothing is written below it, so the only
/// thing pinning the cursor is the content to its left.
#[test]
fn a_cursor_parked_after_the_text_survives_a_narrow_drag() {
let text = "0123456789";
let mut grid = Grid::<Cell>::new(3, 20, 8);
for (i, c) in text.chars().enumerate() {
grid[Line(0)][Column(i)] = cell(c);
}
grid.cursor.point = Point::new(Line(0), Column(text.len()));
for columns in (4..20).rev() {
grid.resize(true, 3, columns);
}
for columns in 5..=20 {
grid.resize(true, 3, columns);
}
assert_eq!(grid.cursor.point, Point::new(Line(0), Column(text.len())), "cursor after the drag");
}
/// Opting out keeps shrink-time wrapping but never re-merges, so rows stay
/// split — the legacy in-box ConPTY contract.
#[test]
@@ -335,14 +428,15 @@ fn grow_reflow_multiline() {
assert_eq!(grid.total_lines(), 3);
// All three rows were one logical line: it collapses back into one row.
assert_eq!(grid[Line(2)].len(), 6);
// All three rows were one logical line: it collapses back into one row, and
// stays on the row the logical line started on.
assert_eq!(grid[Line(0)].len(), 6);
for (i, c) in "123456".chars().enumerate() {
assert_eq!(grid[Line(2)][Column(i)], cell(c), "column {i}");
assert_eq!(grid[Line(0)][Column(i)], cell(c), "column {i}");
}
// The two rows it was spread across are left blank.
for r in (0..2).map(Line::from) {
for r in (1..3).map(Line::from) {
assert_eq!(grid[r].len(), 6);
for c in 0..6 {
assert_eq!(grid[r][Column(c)], Cell::default());
+3 -3
View File
@@ -111,7 +111,7 @@ impl CwdSniffer {
if let Some(event) = self.parse() {
events.push((i + 1, event));
}
self.to_ground();
self.reset_to_ground();
} else {
// The ESC belonged to the payload after all; keep it and
// reprocess this byte in the normal OSC state.
@@ -134,14 +134,14 @@ impl CwdSniffer {
if let Some(event) = self.parse() {
events.push((i + 1, event));
}
self.to_ground();
self.reset_to_ground();
},
0x1b => self.phase = Phase::OscEsc,
_ => self.push(b),
}
}
fn to_ground(&mut self) {
fn reset_to_ground(&mut self) {
self.phase = Phase::Ground;
self.payload.clear();
self.interested = false;
+1 -1
View File
@@ -1,5 +1,5 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use bitflags::bitflags;
#[cfg(feature = "serde")]
+67 -19
View File
@@ -8,8 +8,8 @@ use std::{cmp, mem, ptr, str};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as Base64;
use base64::Engine;
use bitflags::bitflags;
use log::{debug, trace};
use unicode_width::UnicodeWidthChar;
@@ -289,20 +289,15 @@ pub enum Osc52 {
/// Whether the grid may re-merge soft-wrapped rows when it grows wider.
///
/// The sideloaded passthrough ConPTY (the default host) leaves reflow entirely
/// to us, so re-merging is what makes a shrink→grow round-trip lossless. The
/// legacy in-box host reflows its own buffer on resize, and two engines
/// rewriting one stream mangle it — hence the capability check rather than a
/// blanket `true`.
#[cfg(windows)]
fn reflow_on_grow_supported() -> bool {
crate::tty::windows::conpty_sideload_enabled()
}
/// Unix ptys carry no reflow engine of their own; the grid is always the sole
/// owner.
#[cfg(not(windows))]
/// Nebula's grid is the terminal-side source of truth for cursor/content
/// coordinates on every backend. ConPTY also reflows its private buffer, but
/// Windows Terminal's `TextBuffer::Reflow` shows that this is intentionally
/// allowed: ConPTY asks the terminal for the current cursor position instead
/// of replacing the terminal's reflow state. Disabling grow reflow for one
/// ConPTY variant therefore leaves the grid permanently split after a narrow
/// resize and makes the next input use a stale logical row.
fn reflow_on_grow_supported() -> bool {
// 关键:ConPTY 的私有缓冲会自行重排,但终端网格仍是 shell 查询到的光标权威,不能关闭回合并。
true
}
@@ -406,10 +401,9 @@ impl<T> Term<T> {
let mut grid = Grid::new(num_lines, num_cols, history_size);
let mut inactive_grid = Grid::new(num_lines, num_cols, 0);
// Only re-merge soft-wrapped rows on grow when we own the sole reflow
// engine. The sideloaded passthrough ConPTY leaves reflow entirely to us
// (the default); the legacy in-box host reflows its own buffer, and two
// engines rewriting one stream mangle it.
// Keep the grid's logical rows reversible on both in-box and side-loaded
// ConPTY. The host has a private buffer, while the terminal grid remains
// the cursor/content authority queried by the shell after a resize.
let reflow_on_grow = reflow_on_grow_supported();
grid.set_reflow_on_grow(reflow_on_grow);
inactive_grid.set_reflow_on_grow(reflow_on_grow);
@@ -2346,7 +2340,11 @@ enum ModeState {
impl From<bool> for ModeState {
fn from(value: bool) -> Self {
if value { Self::Set } else { Self::Reset }
if value {
Self::Set
} else {
Self::Reset
}
}
}
@@ -3006,6 +3004,56 @@ mod tests {
assert_eq!(term.grid.cursor.point, Point::new(Line(4), Column(0)));
}
#[test]
fn reflow_keeps_prompt_input_at_the_logical_cursor() {
let size = TermSize::new(6, 3);
let mut term = Term::new(Config::default(), &size, VoidListener);
for c in "promp".chars() {
term.input(c);
}
for columns in (3..6).rev() {
term.resize(TermSize::new(columns, 3));
}
for columns in 4..=6 {
term.resize(TermSize::new(columns, 3));
}
term.input('!');
assert_eq!(term.grid()[Line(0)][Column(5)].c, '!');
assert_eq!(term.grid.cursor.point, Point::new(Line(0), Column(5)));
assert!(term.grid.cursor.input_needs_wrap);
}
#[test]
fn reflow_preserves_pending_wrap_before_the_next_input() {
let size = TermSize::new(6, 3);
let mut term = Term::new(Config::default(), &size, VoidListener);
for c in "123456".chars() {
term.input(c);
}
assert_eq!(term.grid.cursor.point, Point::new(Line(0), Column(5)));
assert!(term.grid.cursor.input_needs_wrap);
for columns in (3..6).rev() {
term.resize(TermSize::new(columns, 3));
}
for columns in 4..=6 {
term.resize(TermSize::new(columns, 3));
}
// The cursor is visually on the last cell, but the next byte must wrap
// before writing instead of overwriting that cell.
assert_eq!(term.grid.cursor.point, Point::new(Line(0), Column(5)));
assert!(term.grid.cursor.input_needs_wrap);
term.input('X');
assert_eq!(term.grid[Line(1)][Column(0)].c, 'X');
assert_eq!(term.grid.cursor.point, Point::new(Line(1), Column(1)));
assert!(!term.grid.cursor.input_needs_wrap);
}
#[test]
fn damage_public_usage() {
let size = TermSize::new(10, 10);
+3 -3
View File
@@ -4,16 +4,16 @@ use std::mem;
use std::ops::RangeInclusive;
use log::{debug, warn};
pub use regex_automata::hybrid::BuildError;
use regex_automata::hybrid::dfa::{Builder, Cache, Config, DFA};
pub use regex_automata::hybrid::BuildError;
use regex_automata::nfa::thompson::Config as ThompsonConfig;
use regex_automata::util::syntax::Config as SyntaxConfig;
use regex_automata::{Anchored, Input, MatchKind};
use crate::grid::{BidirectionalIterator, Dimensions, GridIterator, Indexed};
use crate::index::{Boundary, Column, Direction, Point, Side};
use crate::term::Term;
use crate::term::cell::{Cell, Flags};
use crate::term::Term;
/// Used to match equal brackets, when performing a bracket-pair selection.
const BRACKET_PAIRS: [(char, char); 4] = [('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')];
@@ -689,8 +689,8 @@ mod tests {
use super::*;
use crate::index::{Column, Line};
use crate::term::test::{mock_term, TermSize};
use crate::term::Config;
use crate::term::test::{TermSize, mock_term};
#[test]
fn regex_right() {
+1 -1
View File
@@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Wake, Waker};
use std::{io, thread};
use piper::{Reader, Writer, pipe};
use piper::{pipe, Reader, Writer};
use polling::os::iocp::{CompletionPacket, PollerIocpExt};
use polling::{Event, PollMode, Poller};
+2 -2
View File
@@ -5,14 +5,14 @@ use std::os::windows::process::ExitStatusExt;
use std::process::ExitStatus;
use std::ptr;
use std::sync::atomic::{AtomicPtr, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::sync::{mpsc, Arc, Mutex};
use polling::os::iocp::{CompletionPacket, PollerIocpExt};
use polling::{Event, Poller};
use windows_sys::Win32::Foundation::{BOOLEAN, FALSE, HANDLE};
use windows_sys::Win32::System::Threading::{
GetExitCodeProcess, GetProcessId, INFINITE, RegisterWaitForSingleObject, UnregisterWait,
GetExitCodeProcess, GetProcessId, RegisterWaitForSingleObject, UnregisterWait, INFINITE,
WT_EXECUTEINWAITTHREAD, WT_EXECUTEONLYONCE,
};
+17 -12
View File
@@ -7,25 +7,25 @@ use std::os::windows::io::IntoRawHandle;
use std::path::{Path, PathBuf};
use std::{mem, ptr};
use windows_sys::Win32::Foundation::{HANDLE, S_OK};
use windows_sys::Win32::System::Console::{
COORD, ClosePseudoConsole, CreatePseudoConsole, HPCON, ResizePseudoConsole,
};
use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
use windows_sys::core::{HRESULT, PWSTR};
use windows_sys::s;
use windows_sys::Win32::Foundation::{HANDLE, S_OK};
use windows_sys::Win32::System::Console::{
ClosePseudoConsole, CreatePseudoConsole, ResizePseudoConsole, COORD, HPCON,
};
use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
use windows_sys::Win32::System::Threading::{
CREATE_UNICODE_ENVIRONMENT, CreateProcessW, EXTENDED_STARTUPINFO_PRESENT,
InitializeProcThreadAttributeList, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, PROCESS_INFORMATION,
STARTF_USESTDHANDLES, STARTUPINFOEXW, STARTUPINFOW, UpdateProcThreadAttribute,
CreateProcessW, InitializeProcThreadAttributeList, UpdateProcThreadAttribute,
CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, PROCESS_INFORMATION,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, STARTF_USESTDHANDLES, STARTUPINFOEXW, STARTUPINFOW,
};
use crate::event::{OnResize, WindowSize};
use crate::tty::Options;
use crate::tty::windows::blocking::{UnblockedReader, UnblockedWriter};
use crate::tty::windows::child::ChildExitWatcher;
use crate::tty::windows::{Pty, cmdline, win32_string};
use crate::tty::windows::{cmdline, win32_string, Pty};
use crate::tty::Options;
const PIPE_CAPACITY: usize = crate::event_loop::READ_BUFFER_SIZE;
@@ -201,8 +201,13 @@ pub fn new(config: &Options, window_size: WindowSize) -> Result<Pty> {
);
crate::pty_trace("CreatePseudoConsole win32-input rejected; legacy retry");
result = unsafe {
(api.create)(window_size.into(), conin_handle, conout_handle, 0, &mut pty_handle
as *mut _)
(api.create)(
window_size.into(),
conin_handle,
conout_handle,
0,
&mut pty_handle as *mut _,
)
};
}
crate::pty_trace("CreatePseudoConsole done");
+7 -10
View File
@@ -3,8 +3,8 @@ use std::io::{self, Result};
use std::iter::once;
use std::os::windows::ffi::OsStrExt;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::mpsc::TryRecvError;
use std::sync::Arc;
use windows_sys::Win32::System::Threading::TerminateProcess;
@@ -269,9 +269,7 @@ fn nebula_find_bash() -> Option<String> {
}
}
for root in
["LOCALAPPDATA", "USERPROFILE"].into_iter().filter_map(|name| std::env::var_os(name))
{
for root in ["LOCALAPPDATA", "USERPROFILE"].into_iter().filter_map(std::env::var_os) {
let root = PathBuf::from(root);
for candidate in [
root.join("Programs").join("Git").join("bin").join("bash.exe"),
@@ -1006,9 +1004,9 @@ mod test {
use std::process::{Command, Stdio};
use crate::tty::windows::{
NEBULA_BASH_RC, NEBULA_PROMPT_PS1, NebulaRuntimeSettings, NebulaShellExecutor, cmdline,
explicit_bash_integration_args, nebula_default_shell, nebula_find_bash,
powershell_integration_args, push_escaped_arg,
cmdline, explicit_bash_integration_args, nebula_default_shell, nebula_find_bash,
powershell_integration_args, push_escaped_arg, NebulaRuntimeSettings, NebulaShellExecutor,
NEBULA_BASH_RC, NEBULA_PROMPT_PS1,
};
use crate::tty::{Options, Shell};
@@ -1072,9 +1070,8 @@ mod test {
/// guard already; this covers the path that actually regressed.
#[test]
fn default_powershell_loads_the_user_profile_and_ends_with_the_integration() {
let shell = nebula_default_shell(NebulaRuntimeSettings {
shell: NebulaShellExecutor::PowerShell,
});
let shell =
nebula_default_shell(NebulaRuntimeSettings { shell: NebulaShellExecutor::PowerShell });
let args = shell.args();
assert_eq!(shell.program(), "powershell");