|
1 | | -//! 帮助弹窗与 centered_rect 工具。 |
| 1 | +//! 帮助弹窗、编辑弹窗与 centered_rect 工具。 |
2 | 2 |
|
3 | 3 | use ratatui::layout::{Constraint, Layout, Margin, Rect}; |
4 | | -use ratatui::style::{Modifier, Style}; |
| 4 | +use ratatui::style::{Color, Modifier, Style}; |
5 | 5 | use ratatui::text::{Line, Span, Text}; |
6 | 6 | use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; |
7 | 7 |
|
8 | 8 | use super::theme::{ACCENT, INFO, PANEL_ALT}; |
9 | | -use crate::state::AppState; |
| 9 | +use crate::input::InputField; |
| 10 | +use crate::state::{ActiveTab, AppState, HostField, InputMode, JoinField}; |
10 | 11 |
|
11 | 12 | pub fn render_help_popup(frame: &mut ratatui::Frame<'_>, area: Rect, state: &AppState) { |
12 | 13 | if !state.show_help { |
@@ -101,3 +102,162 @@ pub fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rec |
101 | 102 | ]) |
102 | 103 | .split(vertical[1])[1] |
103 | 104 | } |
| 105 | + |
| 106 | +/// 编辑弹窗:InputMode::Editing 时覆盖主面板,lazyvim 风格。 |
| 107 | +pub fn render_edit_popup(frame: &mut ratatui::Frame<'_>, area: Rect, state: &AppState) { |
| 108 | + if state.input_mode != InputMode::Editing { |
| 109 | + return; |
| 110 | + } |
| 111 | + |
| 112 | + let fields = edit_fields(state); |
| 113 | + let popup_h = (fields.len() * 3 + 4) as u16; |
| 114 | + |
| 115 | + // 垂直居中(绝对高度) |
| 116 | + let vert = Layout::vertical([ |
| 117 | + Constraint::Fill(1), |
| 118 | + Constraint::Length(popup_h), |
| 119 | + Constraint::Fill(1), |
| 120 | + ]) |
| 121 | + .split(area); |
| 122 | + |
| 123 | + // 水平居中(60% 宽度) |
| 124 | + let horiz = Layout::horizontal([ |
| 125 | + Constraint::Fill(1), |
| 126 | + Constraint::Percentage(60), |
| 127 | + Constraint::Fill(1), |
| 128 | + ]) |
| 129 | + .split(vert[1]); |
| 130 | + |
| 131 | + let popup = horiz[1]; |
| 132 | + frame.render_widget(Clear, popup); |
| 133 | + |
| 134 | + let title = match state.tab { |
| 135 | + ActiveTab::Host => "编辑 · 建房配置", |
| 136 | + ActiveTab::Join => "编辑 · 加入配置", |
| 137 | + ActiveTab::Relay => "编辑 · 中继 URL", |
| 138 | + }; |
| 139 | + |
| 140 | + let block = Block::default() |
| 141 | + .title(title) |
| 142 | + .borders(Borders::ALL) |
| 143 | + .border_type(BorderType::Rounded) |
| 144 | + .style(Style::default().bg(PANEL_ALT)) |
| 145 | + .border_style(Style::default().fg(ACCENT)); |
| 146 | + frame.render_widget(block, popup); |
| 147 | + |
| 148 | + // inner 减去边框后再加 2 列内边距 |
| 149 | + let inner = popup.inner(Margin::new(2, 1)); |
| 150 | + |
| 151 | + // 1 顶部空行 + 每字段 3 行(label/value/spacer)+ 1 hint 行 |
| 152 | + let mut constraints = vec![Constraint::Length(1)]; |
| 153 | + for _ in &fields { |
| 154 | + constraints.push(Constraint::Length(1)); |
| 155 | + constraints.push(Constraint::Length(1)); |
| 156 | + constraints.push(Constraint::Length(1)); |
| 157 | + } |
| 158 | + constraints.push(Constraint::Length(1)); |
| 159 | + let rows = Layout::vertical(constraints).split(inner); |
| 160 | + |
| 161 | + for (i, (label, field, is_active)) in fields.iter().enumerate() { |
| 162 | + let base = 1 + i * 3; |
| 163 | + let label_row = rows[base]; |
| 164 | + let value_row = rows[base + 1]; |
| 165 | + |
| 166 | + let label_style = if *is_active { |
| 167 | + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD) |
| 168 | + } else { |
| 169 | + Style::default().fg(Color::DarkGray) |
| 170 | + }; |
| 171 | + frame.render_widget( |
| 172 | + Paragraph::new(Span::styled(*label, label_style)), |
| 173 | + label_row, |
| 174 | + ); |
| 175 | + |
| 176 | + let max_w = value_row.width as usize; |
| 177 | + let chars: Vec<char> = field.value.chars().collect(); |
| 178 | + let char_count = chars.len(); |
| 179 | + |
| 180 | + let (display, cursor_offset) = if *is_active { |
| 181 | + let cursor_char = field.value[..field.cursor].chars().count(); |
| 182 | + if char_count == 0 { |
| 183 | + // 空值时显示单空格让光标有位置 |
| 184 | + (" ".to_string(), 0usize) |
| 185 | + } else { |
| 186 | + // 保持光标可见的滑动窗口 |
| 187 | + let start = if cursor_char >= max_w { |
| 188 | + cursor_char - max_w + 1 |
| 189 | + } else { |
| 190 | + 0 |
| 191 | + }; |
| 192 | + let end = (start + max_w).min(char_count); |
| 193 | + let s: String = chars[start..end].iter().collect(); |
| 194 | + (s, cursor_char - start) |
| 195 | + } |
| 196 | + } else if field.value.is_empty() { |
| 197 | + ("(空)".to_string(), 0) |
| 198 | + } else if char_count <= max_w { |
| 199 | + (field.value.clone(), 0) |
| 200 | + } else { |
| 201 | + let mut s: String = chars[..max_w.saturating_sub(1)].iter().collect(); |
| 202 | + s.push('…'); |
| 203 | + (s, 0) |
| 204 | + }; |
| 205 | + |
| 206 | + let value_style = if *is_active { |
| 207 | + Style::default() |
| 208 | + .fg(Color::White) |
| 209 | + .add_modifier(Modifier::UNDERLINED) |
| 210 | + } else { |
| 211 | + Style::default().fg(Color::Gray) |
| 212 | + }; |
| 213 | + frame.render_widget( |
| 214 | + Paragraph::new(Span::styled(display, value_style)), |
| 215 | + value_row, |
| 216 | + ); |
| 217 | + |
| 218 | + if *is_active { |
| 219 | + frame.set_cursor_position((value_row.x + cursor_offset as u16, value_row.y)); |
| 220 | + } |
| 221 | + } |
| 222 | + |
| 223 | + let hint_row = rows[1 + fields.len() * 3]; |
| 224 | + frame.render_widget( |
| 225 | + Paragraph::new(Span::styled( |
| 226 | + "[↑/↓] 切换字段 [Esc] 保存", |
| 227 | + Style::default().fg(Color::DarkGray), |
| 228 | + )), |
| 229 | + hint_row, |
| 230 | + ); |
| 231 | +} |
| 232 | + |
| 233 | +/// 返回当前 tab 的可编辑字段列表:(标签, 字段引用, 是否活跃)。 |
| 234 | +fn edit_fields<'a>(state: &'a AppState) -> Vec<(&'static str, &'a InputField, bool)> { |
| 235 | + match state.tab { |
| 236 | + ActiveTab::Host => vec![ |
| 237 | + ("端口", &state.host_port, state.host_field == HostField::Port), |
| 238 | + ( |
| 239 | + "密码", |
| 240 | + &state.host_password, |
| 241 | + state.host_field == HostField::Password, |
| 242 | + ), |
| 243 | + ], |
| 244 | + ActiveTab::Join => vec![ |
| 245 | + ( |
| 246 | + "票据", |
| 247 | + &state.join_ticket, |
| 248 | + state.join_field == JoinField::Ticket, |
| 249 | + ), |
| 250 | + ( |
| 251 | + "端口", |
| 252 | + &state.join_port, |
| 253 | + state.join_field == JoinField::Port, |
| 254 | + ), |
| 255 | + ( |
| 256 | + "密码", |
| 257 | + &state.join_password, |
| 258 | + state.join_field == JoinField::Password, |
| 259 | + ), |
| 260 | + ], |
| 261 | + ActiveTab::Relay => vec![("中继 URL", &state.relay_url, true)], |
| 262 | + } |
| 263 | +} |
0 commit comments