//! App 状态与交互逻辑:命令、键盘/鼠标、后台任务粘合、会话持久化。 //! App state and interaction logic: commands, keyboard/mouse, job glue, and //! session persistence. use crate::config::{ProviderKind, TuiConfig}; use crate::format::display_width; use crate::job::{JobEvent, RunJob}; use crate::state::*; use focus_core::model::*; use focus_harness::estimate_messages; use focus_harness::prompt::{default_context, ContextUsage, SystemPromptTemplate}; use focus_harness::session::{SessionEntry, SessionStore}; use focus_providers::config::{known_context_window, resolve_context_window}; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::mpsc::{channel, Receiver}; use std::thread::JoinHandle; use std::time::{SystemTime, UNIX_EPOCH}; /// 一个已渲染块的几何信息(行范围,供点击映射)。 /// Geometry of one rendered block (line range, for click mapping). #[derive(Debug, Clone)] pub struct BlockGeometry { pub id: BlockId, /// 内容坐标中的起始行(含)。 /// Start line in content coordinates (inclusive). pub start: u16, /// 内容坐标中的结束行(不含)。 /// End line in content coordinates (exclusive). pub end: u16, } /// 配置表单的字段。 /// A config-form field. #[derive(Debug, Clone)] pub enum FormField { /// 单选。 /// A choice. Choice { label: String, options: Vec, selected: usize, }, /// 文本输入。 /// Text input. Text { label: String, value: String }, } impl FormField { pub fn label(&self) -> &str { match self { FormField::Choice { label, .. } => label, FormField::Text { label, .. } => label, } } } /// 配置编辑表单。 /// The config-editing form. #[derive(Debug, Clone)] pub struct ConfigForm { pub fields: Vec, pub active: usize, } impl ConfigForm { /// 从当前配置构建表单。 /// Build the form from the current config. pub fn from_config(cfg: &TuiConfig) -> Self { let protocol = match cfg.openai_protocol { focus_providers::openai::OpenAiProtocol::Responses => "responses", focus_providers::openai::OpenAiProtocol::ChatCompletions => "chatCompletions", }; Self { fields: vec![ FormField::Choice { label: "provider".into(), options: vec!["anthropic".into(), "openai".into()], selected: match cfg.provider { ProviderKind::Anthropic => 0, ProviderKind::OpenAI => 1, }, }, FormField::Choice { label: "openaiProtocol".into(), options: vec!["responses".into(), "chatCompletions".into()], selected: if protocol == "chatCompletions" { 1 } else { 0 }, }, FormField::Text { label: "baseUrl".into(), value: cfg.base_url.clone().unwrap_or_default(), }, FormField::Text { label: "apiKey".into(), value: cfg.api_key.clone(), }, FormField::Text { label: "model".into(), value: cfg.model.clone(), }, FormField::Text { label: "contextWindow".into(), value: cfg .context_window .map(|w| w.to_string()) .unwrap_or_default(), }, ], active: 0, } } /// 把表单写回配置(空字符串 → None)。 /// Write the form back into the config (empty string → None). pub fn apply(self, cfg: &mut TuiConfig) { for field in self.fields { match field { FormField::Choice { label, selected, .. } => match label.as_str() { "provider" => { cfg.provider = if selected == 1 { ProviderKind::OpenAI } else { ProviderKind::Anthropic } } "openaiProtocol" => { cfg.openai_protocol = if selected == 1 { focus_providers::openai::OpenAiProtocol::ChatCompletions } else { focus_providers::openai::OpenAiProtocol::Responses } } _ => {} }, FormField::Text { label, value } => { let value = value.trim().to_string(); match label.as_str() { "baseUrl" => { cfg.base_url = if value.is_empty() { None } else { Some(value) } } "apiKey" => cfg.api_key = value, "model" => { if !value.is_empty() { cfg.model = value; } } "contextWindow" => { cfg.context_window = value.parse::().ok().filter(|w| *w > 0) } _ => {} } } } } } } /// 会话列表表单。 /// The session-list form. #[derive(Debug, Clone)] pub struct SessionsForm { pub sessions: Vec, pub selected: usize, } /// 打开的模态框。 /// An open modal. #[derive(Debug, Clone)] pub enum Modal { Config(ConfigForm), Sessions(SessionsForm), Help, } /// 交互式应用状态。 /// The interactive app state. #[derive(Debug)] pub struct App { pub config: TuiConfig, pub transcript: Vec, /// 已写入会话的消息数。 /// Messages already written to the session. pub persisted: usize, pub session_id: Option, pub last_entry_id: Option, pub session_seq: u64, pub store: SessionStore, pub input: String, pub caret: usize, pub run_rx: Option>, pub run_join: Option>, pub run_state: Option, /// 已提交 assistant 消息的思考耗时((消息索引, 内容索引) → 毫秒)。 /// Committed assistant thinking durations ((message idx, content idx) → ms). pub thinking_durations: HashMap<(usize, usize), u64>, /// 已提交 tool_result 消息的执行耗时((消息索引, 0) → 毫秒)。 /// Committed tool_result execution durations ((message idx, 0) → ms). pub tool_durations: HashMap<(usize, usize), u64>, pub expanded: HashSet, /// 二级展开(显示全部内容)的块。 /// Blocks expanded to the full level (show everything). pub full: HashSet, pub notes: Vec, pub status: String, pub scroll: u16, pub follow: bool, pub modal: Option, pub last_usage: Option, /// 最近一次渲染的块几何(供点击映射),由 draw 填充。 /// Geometry of the last render (for click mapping); filled by draw. pub geometry: Vec, /// 消息区视图高度(由 draw 填充)。 /// Messages-area view height (filled by draw). pub view_height: u16, /// 消息区总内容行数(由 draw 填充;滚动上限依据)。 /// Total content lines of the message area (filled by draw; the scroll /// limit is based on it). pub content_lines: u16, /// 帧计数(供 spinner 动画)。 /// Frame counter (drives the spinner animation). pub frame: u64, pub quit: bool, } impl App { /// 创建一个新应用(加载配置与默认数据目录)。 /// Create a new app (loading the config and default data dir). pub fn new() -> Self { Self { config: TuiConfig::load(), transcript: Vec::new(), persisted: 0, session_id: None, last_entry_id: None, session_seq: 0, store: SessionStore::default(), input: String::new(), caret: 0, run_rx: None, run_join: None, run_state: None, thinking_durations: HashMap::new(), tool_durations: HashMap::new(), expanded: HashSet::new(), full: HashSet::new(), notes: Vec::new(), status: String::new(), scroll: 0, follow: true, modal: None, last_usage: None, geometry: Vec::new(), view_height: 0, content_lines: 0, frame: 0, quit: false, } } /// 推送一条系统说明(显示在消息区顶部)。 /// Push a system note (shown at the top of the message area). pub fn push_note(&mut self, text: impl Into) { self.notes.push(text.into()); } /// 是否正在运行任务。 /// Whether a job is running. pub fn is_running(&self) -> bool { self.run_rx.is_some() } /// 会话是否已存在(否则在首次发送时创建)。 /// Whether a session exists (created on first send otherwise). pub fn ensure_session(&mut self) { if self.session_id.is_none() { let secs = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); self.session_id = Some(format!("s{}-{}", secs, self.session_seq)); self.session_seq += 1; } } /// 把消息写入当前会话(增量)。 /// Write messages into the current session (incrementally). pub fn persist_entries(&mut self, messages: &[Message]) { let Some(sid) = self.session_id.clone() else { return; }; for m in messages { let id = match self.store.next_entry_id(&sid) { Ok(i) => i, Err(_) => return, }; let entry = SessionEntry::new(id.clone(), self.last_entry_id.clone(), m.clone()); if self.store.append(&sid, &entry).is_err() { return; } self.last_entry_id = Some(id); self.persisted += 1; } } /// 渲染当前系统提示(含 Usage/上下文占用)。 /// Render the current system prompt (with usage / context occupancy). fn render_system_prompt(&self) -> String { let model = self.config.model.clone(); let window = resolve_context_window(self.config.context_window, &model); let window_known = self.config.context_window.is_some() || known_context_window(&model).is_some(); let mut ctx = default_context(); ctx.context_usage = Some(ContextUsage { estimated_tokens: estimate_messages(&self.transcript), context_window: window, window_known, }); ctx.last_usage = self.last_usage.clone(); SystemPromptTemplate::default().render(&ctx) } /// 发送一条消息(若以 `/` 开头则视为命令)。 /// Send a message (commands start with `/`). pub fn send(&mut self, text: &str) { let trimmed = text.trim(); if trimmed.is_empty() { return; } if let Some(cmd) = trimmed.strip_prefix('/') { self.handle_command(cmd); return; } if !self.config.is_ready() { self.status = "⚠ 未配置:请先运行 /config".into(); return; } if self.is_running() { self.status = "⚠ 已有任务在运行".into(); return; } self.ensure_session(); // 用户消息先入 UI 的 transcript 用于即时显示;持久化由后台任务完成 // (job 的 to_persist 会包含它),避免与 agent 循环重复写入。 // The user message enters the UI transcript for immediate display; // persistence happens in the background job (its to_persist includes // it), avoiding a duplicate write with the agent loop. let user = Message::user_text(text.to_string()); self.transcript.push(user); self.input.clear(); self.caret = 0; self.follow = true; self.status.clear(); // 动画 spinner 指示运行中 / the animated spinner shows "working" self.spawn_job(Some(text.to_string())); } /// 继续对话(对最后一条 assistant 消息再追问)。 /// Continue the conversation (re-prompt after an assistant turn). pub fn r#continue(&mut self) { self.send("continue"); } /// 启动后台任务(prompt 或仅压缩)。 /// Spawn a background job (prompt or compact-only). fn spawn_job(&mut self, user_text: Option) { let (tx, rx) = channel::(); let system_prompt = self.render_system_prompt(); // 给任务的 transcript 不含刚加入的用户消息——agent 循环会把它加入并 // 计入 to_persist,从而避免重复持久化。 // The job's transcript excludes the just-added user message — the // agent loop adds it back and it lands in to_persist, avoiding // duplicate persistence. let job_transcript = match &user_text { Some(_) => self.transcript[..self.transcript.len().saturating_sub(1)].to_vec(), None => self.transcript.clone(), }; let job = RunJob { transcript: job_transcript, user_text, config: self.config.clone(), cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), system_prompt, provider: None, }; let join = crate::job::spawn(job, tx); self.run_rx = Some(rx); self.run_join = Some(join); self.run_state = Some(RunState::new(real_clock())); } /// 排空后台任务事件。 /// Drain background job events. pub fn drain_job_events(&mut self) { let rx = match self.run_rx.take() { Some(rx) => rx, None => return, }; let mut finished = false; while let Ok(ev) = rx.try_recv() { if matches!(ev, JobEvent::Done { .. }) { finished = true; } self.on_job_event(ev); } if finished { self.run_join = None; self.run_state = None; } else { self.run_rx = Some(rx); } } /// 处理一个后台事件。 /// Handle one background event. fn on_job_event(&mut self, ev: JobEvent) { match ev { JobEvent::Agent(ev) => { if let Some(rs) = &mut self.run_state { rs.on_agent_event(&ev); } } JobEvent::Note(n) => self.status = n, JobEvent::Done { messages, to_persist, appended_count, error, } => { // 收尾思考计时并映射到已提交消息。 // Finalize thinking timings and map them onto committed // messages. if let Some(rs) = &mut self.run_state { rs.finalize(); let tail_start = messages.len().saturating_sub(appended_count); let mut turn = 0usize; for (off, msg) in messages.iter().enumerate().skip(tail_start) { match msg { Message::Assistant(a) => { for (bi, block) in a.content.iter().enumerate() { if let ContentBlock::Thinking(_) = block { if let Some(timing) = rs.thinking.get(&(turn, bi)) { self.thinking_durations .insert((off, bi), timing.duration_ms()); } } } turn += 1; } Message::ToolResult(t) => { // 把该轮的工具执行耗时映射到 tool_result 消息。 // Map the tool-execution duration onto the // tool_result message. if let Some(exec) = rs .tool_execs .iter() .find(|e| e.tool_call_id == t.tool_call_id) { if let Some(d) = exec.duration_ms() { self.tool_durations.insert((off, 0), d); } } } _ => {} } } } self.persist_entries(&to_persist); self.transcript = messages; // 错误优先取 job 报告的 error;若错误被编码成消息(如 provider 的 // HTTP 400 → StreamEvent::Error → assistant error 消息), // 从最后一条 assistant 消息的 error_message 提取。 // Prefer the job's reported error; when the error is encoded as // a message (provider HTTP 400 → StreamEvent::Error → an // assistant error message), extract it from the last assistant. let surfaced = error.or_else(|| { self.transcript.iter().rev().find_map(|m| match m { Message::Assistant(a) if a.stop_reason == StopReason::Error => { a.error_message.clone() } _ => None, }) }); if let Some(err) = surfaced { self.status = format!("⚠ {}", crate::format::truncate(&err, 80)); self.push_note(format!("运行出错:{}", err)); } else { self.status = "✓ 完成".into(); } if let Some(last) = self.transcript.iter().rev().find_map(|m| match m { Message::Assistant(a) => Some(a.usage.clone()), _ => None, }) { self.last_usage = Some(last); } self.follow = true; } } } /// 中止当前运行(放弃该任务,线程随超时自然结束)。 /// Abort the current run (abandon the job; the thread ends within its /// timeout). pub fn abort_run(&mut self) { if self.run_rx.is_none() { return; } self.run_rx = None; self.run_join = None; let aborted = if let Some(rs) = &mut self.run_state { rs.finalize(); match rs.partial.clone() { Some(mut p) => { p.stop_reason = StopReason::Aborted; p.error_message = Some("aborted by user".into()); p } None => AssistantMessage { content: Vec::new(), model: self.config.model.clone(), usage: Usage::default(), stop_reason: StopReason::Aborted, error_message: Some("aborted by user".into()), timestamp: now_ms(), }, } } else { AssistantMessage { content: Vec::new(), model: self.config.model.clone(), usage: Usage::default(), stop_reason: StopReason::Aborted, error_message: Some("aborted by user".into()), timestamp: now_ms(), } }; self.transcript.push(Message::Assistant(aborted)); let tail = self .transcript .get(self.persisted..) .unwrap_or_default() .to_vec(); self.persist_entries(&tail); self.run_state = None; self.status = "⏹ 已中止".into(); self.follow = true; } /// 开始新会话。 /// Start a new session. pub fn new_session(&mut self) { if self.is_running() { self.status = "⚠ 任务运行中,无法切换会话".into(); return; } self.transcript.clear(); self.persisted = 0; self.session_id = None; self.last_entry_id = None; self.thinking_durations.clear(); self.tool_durations.clear(); self.expanded.clear(); self.full.clear(); self.last_usage = None; self.notes.clear(); self.push_note("新会话已开始。"); self.scroll = 0; self.follow = true; } /// 加载一个会话(替换 transcript)。 /// Load a session (replacing the transcript). pub fn load_session(&mut self, session_id: &str) { let entries = match self.store.load(session_id) { Ok(e) => e, Err(e) => { self.status = format!("⚠ 加载会话失败:{}", e); return; } }; let messages: Vec = entries.iter().map(|e| e.message.clone()).collect(); self.transcript = messages; self.persisted = entries.len(); self.session_id = Some(session_id.to_string()); self.last_entry_id = entries.last().map(|e| e.id.clone()); self.thinking_durations.clear(); self.tool_durations.clear(); self.expanded.clear(); self.full.clear(); self.notes.clear(); self.push_note(format!( "已加载会话 {}({} 条消息)。", session_id, entries.len() )); if let Some(last) = self.transcript.iter().rev().find_map(|m| match m { Message::Assistant(a) => Some(a.usage.clone()), _ => None, }) { self.last_usage = Some(last); } self.scroll = 0; self.follow = true; } /// 执行一条命令(不含前导 `/`)。 /// Run a command (without the leading `/`). fn handle_command(&mut self, cmd: &str) { match cmd.trim() { "new" => self.new_session(), "sessions" => { if self.is_running() { self.status = "⚠ 任务运行中".into(); return; } match self.store.list_sessions() { Ok(sessions) => { if sessions.is_empty() { self.status = "暂无历史会话".into(); } else { self.modal = Some(Modal::Sessions(SessionsForm { sessions, selected: 0, })); } } Err(e) => self.status = format!("⚠ {}", e), } } "config" => { if self.is_running() { self.status = "⚠ 任务运行中".into(); return; } self.modal = Some(Modal::Config(ConfigForm::from_config(&self.config))); } "compact" => { if !self.config.is_ready() { self.status = "⚠ 未配置".into(); return; } if self.is_running() { self.status = "⚠ 已有任务在运行".into(); return; } if self.transcript.is_empty() { self.status = "无内容可压缩".into(); return; } self.ensure_session(); self.status = "▶ compacting…".into(); self.spawn_job(None); } "help" => { self.modal = Some(Modal::Help); } other => { self.status = format!("未知命令:/{}(/help 查看帮助)", other); } } } /// 处理一个键盘事件。返回 true 表示退出。 /// Handle a key event. Returns true to quit. pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; if key.kind != KeyEventKind::Press { return false; } // Ctrl+C 永远退出。 // Ctrl+C always quits. if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { self.quit = true; return true; } if let Some(modal) = self.modal.take() { if let Some(kept) = self.handle_modal_key(modal, key) { self.modal = Some(kept); } return false; } match key.code { KeyCode::Esc => { if self.is_running() { self.abort_run(); } else { self.quit = true; } } KeyCode::Enter => { if key.modifiers.contains(KeyModifiers::SHIFT) { self.insert_char('\n'); } else { let text = self.input.clone(); if !text.trim().is_empty() { self.send(&text); } } } KeyCode::Tab => self.toggle_latest_collapsible(), KeyCode::Backspace => self.backspace(), KeyCode::Delete => self.delete(), KeyCode::Left => self.move_caret(-1), KeyCode::Right => self.move_caret(1), KeyCode::Home => { self.caret = self.line_start(self.caret); } KeyCode::End => { self.caret = self.line_end(self.caret); } KeyCode::PageUp => { // 向上翻页:减小内容偏移(scroll 为内容偏移量)。 // Page up: decrease the content offset. self.follow = false; self.scroll = self.scroll.saturating_sub(self.view_height.max(1)); } KeyCode::PageDown => { self.scroll = (self.scroll + self.view_height.max(1)).min(self.max_scroll()); self.follow = self.scroll >= self.max_scroll(); } KeyCode::Up => { if self.input.is_empty() { // 输入为空时 ↑↓ 滚动消息区(否则用于移动光标)。 // Up/Down scroll the messages when the input is empty // (otherwise they move the caret). self.follow = false; self.scroll = self.scroll.saturating_sub(1); } else { self.move_caret_line(-1); } } KeyCode::Down => { if self.input.is_empty() { self.scroll = (self.scroll + 1).min(self.max_scroll()); self.follow = self.scroll >= self.max_scroll(); } else { self.move_caret_line(1); } } KeyCode::Char(c) => { // Ctrl+U 逐行向上滚动(vim 风格)。 // Ctrl+U scrolls up line-by-line (vim style). if c == 'u' && key.modifiers.contains(KeyModifiers::CONTROL) { self.follow = false; self.scroll = self.scroll.saturating_sub(1); return false; } // Ctrl+D 继续。 // Ctrl+D continues. if c == 'd' && key.modifiers.contains(KeyModifiers::CONTROL) { if self.is_running() { self.status = "⚠ 任务运行中".into(); } else if self.config.is_ready() && !self.transcript.is_empty() { self.r#continue(); } return false; } self.insert_char(c); } _ => {} } false } /// 处理模态框内的键盘事件。返回 `Some` 表示保持打开,`None` 表示关闭。 /// Handle a key event inside a modal. Returns `Some` to keep it open, /// `None` to close it. fn handle_modal_key(&mut self, modal: Modal, key: crossterm::event::KeyEvent) -> Option { use crossterm::event::{KeyCode, KeyModifiers}; match modal { Modal::Help => { // 任意键关闭帮助。 // Any key closes help. None } Modal::Config(mut form) => match key.code { KeyCode::Esc => None, KeyCode::Enter => { form.apply(&mut self.config); if let Err(e) = self.config.save() { self.status = format!("⚠ 保存配置失败:{}", e); } else { self.status = "✓ 配置已保存".into(); } None } KeyCode::Up | KeyCode::BackTab => { form.active = (form.active + form.fields.len() - 1) % form.fields.len(); Some(Modal::Config(form)) } KeyCode::Down | KeyCode::Tab => { form.active = (form.active + 1) % form.fields.len(); Some(Modal::Config(form)) } KeyCode::Left => { if let FormField::Choice { selected, .. } = &mut form.fields[form.active] { *selected = selected.saturating_sub(1); } Some(Modal::Config(form)) } KeyCode::Right => { if let FormField::Choice { options, selected, .. } = &mut form.fields[form.active] { *selected = (*selected + 1).min(options.len().saturating_sub(1)); } Some(Modal::Config(form)) } KeyCode::Backspace => { if let FormField::Text { value, .. } = &mut form.fields[form.active] { value.pop(); } Some(Modal::Config(form)) } KeyCode::Char(c) => { if c == 'c' && key.modifiers.contains(KeyModifiers::CONTROL) { return None; } if let FormField::Text { value, .. } = &mut form.fields[form.active] { value.push(c); } Some(Modal::Config(form)) } _ => Some(Modal::Config(form)), }, Modal::Sessions(mut form) => match key.code { KeyCode::Esc => None, KeyCode::Up => { form.selected = form.selected.saturating_sub(1); Some(Modal::Sessions(form)) } KeyCode::Down => { form.selected = (form.selected + 1).min(form.sessions.len().saturating_sub(1)); Some(Modal::Sessions(form)) } KeyCode::Enter => { if let Some(id) = form.sessions.get(form.selected) { let id = id.clone(); self.load_session(&id); } None } _ => Some(Modal::Sessions(form)), }, } } /// 处理鼠标事件(点击展开/折叠,滚轮滚动)。 /// Handle a mouse event (click toggles expand, wheel scrolls). pub fn handle_mouse(&mut self, m: crossterm::event::MouseEvent) { use crossterm::event::MouseEventKind; match m.kind { MouseEventKind::Down(crossterm::event::MouseButton::Left) => { // 只处理消息区内的点击。 // Only clicks inside the messages area. if m.row < self.view_height { let content_row = m.row + self.scroll; if let Some(block) = self .geometry .iter() .find(|g| g.start <= content_row && content_row < g.end) { cycle_expansion(&mut self.expanded, &mut self.full, &block.id); } } } // crossterm 把 Linux(xterm 协议)与 Windows(ConPTY)的滚轮事件 // 都归一化为 ScrollUp/ScrollDown。注意方向:scroll 是内容偏移量, // 向上滚(看更早内容)要减小偏移,向下滚才增大;每格 1 行,逐行滚动。 // 向上滚必须取消 follow,否则每次绘制会被吸回底部。 // crossterm normalizes wheel events from both Linux (xterm) and // Windows (ConPTY) to ScrollUp/ScrollDown. Direction: `scroll` is // the content offset, so scrolling up (older content) decreases // it and scrolling down increases it; one line per notch. // Scrolling up must clear follow, otherwise every draw snaps back. MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => { self.follow = false; self.scroll = self.scroll.saturating_sub(1); } MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => { self.scroll = (self.scroll + 1).min(self.max_scroll()); self.follow = self.scroll >= self.max_scroll(); } _ => {} } } /// 把输入框的滚动对齐到底部(跟随模式)。 /// Align the scroll to the bottom when following. pub fn clamp_scroll(&mut self) { if self.follow { self.scroll = self.max_scroll(); } } /// 最大滚动偏移(由最近渲染的总行数决定;几何只含可展开块,不能用作依据)。 /// Max scroll offset (based on the last render's total line count; the /// geometry only covers expandable blocks and is unusable for this). fn max_scroll(&self) -> u16 { self.content_lines.saturating_sub(self.view_height.max(1)) } /// 切换最近一个可折叠块(几何只含可展开块:思考与工具)。 /// Toggle the most recent collapsible block (geometry only holds /// expandable blocks: thinking and tools). fn toggle_latest_collapsible(&mut self) { if let Some(id) = self.geometry.last().map(|g| g.id.clone()) { cycle_expansion(&mut self.expanded, &mut self.full, &id); } } // ---- 输入编辑 ---- // ---- input editing ---------------------------------------------------- fn insert_char(&mut self, c: char) { self.input.insert(self.caret, c); self.caret += c.len_utf8(); } fn backspace(&mut self) { if let Some((idx, _len)) = self.input[..self.caret].char_indices().next_back() { self.input.replace_range(idx..self.caret, ""); self.caret = idx; } } fn delete(&mut self) { if self.caret < self.input.len() { let len = self.input[self.caret..] .chars() .next() .map(|c| c.len_utf8()) .unwrap_or(0); self.input.replace_range(self.caret..self.caret + len, ""); } } fn move_caret(&mut self, dir: i32) { if dir < 0 { if let Some((idx, _)) = self.input[..self.caret].char_indices().next_back() { self.caret = idx; } } else if self.caret < self.input.len() { let len = self.input[self.caret..] .chars() .next() .map(|c| c.len_utf8()) .unwrap_or(0); self.caret += len; } } fn line_start(&self, pos: usize) -> usize { self.input[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0) } fn line_end(&self, pos: usize) -> usize { self.input[pos..] .find('\n') .map(|i| pos + i) .unwrap_or(self.input.len()) } fn move_caret_line(&mut self, dir: i32) { let line_start = self.line_start(self.caret); let line_end = self.line_end(self.caret); let col = display_width(&self.input[line_start..self.caret]); if dir < 0 { if line_start == 0 { self.caret = 0; return; } let prev_line_end = line_start - 1; // '\n' 的位置 / the '\n' position let prev_start = self.input[..prev_line_end] .rfind('\n') .map(|i| i + 1) .unwrap_or(0); self.caret = prev_start + position_at_col(&self.input[prev_start..prev_line_end], col); } else { if line_end == self.input.len() { self.caret = self.input.len(); return; } let next_start = line_end + 1; let next_end = self.input[next_start..] .find('\n') .map(|i| next_start + i) .unwrap_or(self.input.len()); self.caret = next_start + position_at_col(&self.input[next_start..next_end], col); } } } /// 在 `s` 中定位显示宽度 `col` 对应的字节偏移(尽力而为)。 /// Byte offset in `s` for a display column (best effort). fn position_at_col(s: &str, col: usize) -> usize { let mut w = 0usize; for (i, c) in s.char_indices() { let cw = crate::format::display_width_char(c); if w + cw > col { return i; } w += cw; } s.len() } impl Default for App { fn default() -> Self { Self::new() } }