Skip to content

Commit 98d6819

Browse files
committed
feat(ui): 添加编辑弹窗并优化字段切换逻辑
1 parent a3ef00e commit 98d6819

4 files changed

Lines changed: 252 additions & 84 deletions

File tree

tui/src/state.rs

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -180,16 +180,16 @@ impl AppState {
180180
match key.code {
181181
KeyCode::Esc => {
182182
self.input_mode = InputMode::Normal;
183-
// 中继 tab 退出编辑时自动保存
183+
// 中继弹窗关闭时仅持久化 URL 文本,不切换中继(Enter 才应用)
184184
if self.tab == ActiveTab::Relay {
185-
self.apply_relay();
185+
self.persist_relay_url();
186186
}
187187
}
188-
KeyCode::Tab => {
189-
self.next_field();
188+
KeyCode::Up => {
189+
self.prev_field_clamped();
190190
}
191-
KeyCode::BackTab => {
192-
self.prev_field();
191+
KeyCode::Down => {
192+
self.next_field_clamped();
193193
}
194194
KeyCode::Backspace => {
195195
self.active_input_mut().backspace();
@@ -264,7 +264,11 @@ impl AppState {
264264
Step::Continue
265265
}
266266
KeyCode::Char('i') => {
267-
if self.focus == FocusPane::Profile {
267+
let can_edit = match self.tab {
268+
ActiveTab::Relay => self.relay_state.selected() == Some(1),
269+
_ => true,
270+
};
271+
if can_edit {
268272
self.input_mode = InputMode::Editing;
269273
}
270274
Step::Continue
@@ -604,31 +608,71 @@ impl AppState {
604608
ActiveTab::Host => {
605609
self.host_field = match self.host_field {
606610
HostField::Port => HostField::Password,
607-
HostField::Password => HostField::Port,
611+
HostField::Password => HostField::Password,
608612
};
609613
}
610614
ActiveTab::Join => {
611615
self.join_field = match self.join_field {
612616
JoinField::Ticket => JoinField::Port,
613617
JoinField::Port => JoinField::Password,
614-
JoinField::Password => JoinField::Ticket,
618+
JoinField::Password => JoinField::Password,
615619
};
616620
}
617621
ActiveTab::Relay => {}
618622
}
619623
}
620624

621625
fn prev_field(&mut self) {
626+
match self.tab {
627+
ActiveTab::Host => {
628+
self.host_field = match self.host_field {
629+
HostField::Port => HostField::Port,
630+
HostField::Password => HostField::Port,
631+
};
632+
}
633+
ActiveTab::Join => {
634+
self.join_field = match self.join_field {
635+
JoinField::Ticket => JoinField::Ticket,
636+
JoinField::Port => JoinField::Ticket,
637+
JoinField::Password => JoinField::Port,
638+
};
639+
}
640+
ActiveTab::Relay => {}
641+
}
642+
}
643+
644+
/// 编辑模式下向后切换字段,到末尾停止。
645+
fn next_field_clamped(&mut self) {
622646
match self.tab {
623647
ActiveTab::Host => {
624648
self.host_field = match self.host_field {
625649
HostField::Port => HostField::Password,
650+
HostField::Password => HostField::Password,
651+
};
652+
}
653+
ActiveTab::Join => {
654+
self.join_field = match self.join_field {
655+
JoinField::Ticket => JoinField::Port,
656+
JoinField::Port => JoinField::Password,
657+
JoinField::Password => JoinField::Password,
658+
};
659+
}
660+
ActiveTab::Relay => {}
661+
}
662+
}
663+
664+
/// 编辑模式下向前切换字段,到首位停止。
665+
fn prev_field_clamped(&mut self) {
666+
match self.tab {
667+
ActiveTab::Host => {
668+
self.host_field = match self.host_field {
669+
HostField::Port => HostField::Port,
626670
HostField::Password => HostField::Port,
627671
};
628672
}
629673
ActiveTab::Join => {
630674
self.join_field = match self.join_field {
631-
JoinField::Ticket => JoinField::Password,
675+
JoinField::Ticket => JoinField::Ticket,
632676
JoinField::Port => JoinField::Ticket,
633677
JoinField::Password => JoinField::Port,
634678
};
@@ -637,6 +681,19 @@ impl AppState {
637681
}
638682
}
639683

684+
/// 弹窗关闭时将 relay URL 写入配置文件(仅持久化,不切换中继)。
685+
/// URL 为空或格式无效时记录日志但不中断流程。
686+
fn persist_relay_url(&mut self) {
687+
let url = self.relay_url.value.trim().to_string();
688+
if url.is_empty() {
689+
return;
690+
}
691+
let conf_path = config::default_relay_conf_path();
692+
if let Err(e) = config::save_relay_url(&conf_path, &url) {
693+
self.add_log(&format!("URL 格式无效,未保存: {e}"));
694+
}
695+
}
696+
640697
// ---- 日志与列表 ----
641698

642699
pub fn clear_logs(&mut self) {
@@ -684,14 +741,15 @@ impl AppState {
684741
pub fn next_relay_selection(&mut self) {
685742
let next = match self.relay_state.selected() {
686743
Some(i) if i + 1 < RELAYS.len() => i + 1,
687-
_ => 0,
744+
Some(i) => i,
745+
None => 0,
688746
};
689747
self.relay_state.select(Some(next));
690748
}
691749

692750
pub fn prev_relay_selection(&mut self) {
693751
let prev = match self.relay_state.selected() {
694-
Some(0) | None => RELAYS.len() - 1,
752+
Some(0) | None => 0,
695753
Some(i) => i - 1,
696754
};
697755
self.relay_state.select(Some(prev));

tui/src/ui/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub fn render(frame: &mut ratatui::Frame<'_>, state: &mut AppState) {
3131
render_main(frame, layout[1], state);
3232
footer::render_footer(frame, layout[2], state);
3333
popup::render_help_popup(frame, area, state);
34+
popup::render_edit_popup(frame, area, state);
3435
}
3536

3637
fn render_main(frame: &mut ratatui::Frame<'_>, area: ratatui::layout::Rect, state: &mut AppState) {

tui/src/ui/popup.rs

Lines changed: 163 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
//! 帮助弹窗与 centered_rect 工具。
1+
//! 帮助弹窗、编辑弹窗与 centered_rect 工具。
22
33
use ratatui::layout::{Constraint, Layout, Margin, Rect};
4-
use ratatui::style::{Modifier, Style};
4+
use ratatui::style::{Color, Modifier, Style};
55
use ratatui::text::{Line, Span, Text};
66
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
77

88
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};
1011

1112
pub fn render_help_popup(frame: &mut ratatui::Frame<'_>, area: Rect, state: &AppState) {
1213
if !state.show_help {
@@ -101,3 +102,162 @@ pub fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rec
101102
])
102103
.split(vertical[1])[1]
103104
}
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

Comments
 (0)