chore: checkpoint before GPUI foundation
This commit is contained in:
1 parent
bb9527d1a9
commit
05bc8313cf
24 files changed
+2765
-215
No files matched your search
@@ -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="));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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('+'));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 } => {
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use bitflags::bitflags;
|
||||
#[cfg(feature = "serde")]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user