From cf13e516b9a9d152a20df7d89afa3a395d845892 Mon Sep 17 00:00:00 2001 From: SidneyZhang Date: Wed, 15 Apr 2026 14:51:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(tui):=20=E4=BF=AE=E5=A4=8D=20Esc=20?= =?UTF-8?q?=E9=94=AE=E5=9C=A8=E5=88=9B=E5=BB=BA=E5=92=8C=E7=BC=96=E8=BE=91?= =?UTF-8?q?=20memo=20=E6=97=B6=E7=9A=84=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- src/main.rs | 153 ++++++++++++++++++++++++++++++++++------------- src/tui.rs | 169 +++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 274 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 444e80f..a54fa30 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # MemosCLI +[![Built With Ratatui](https://img.shields.io/badge/Built_With_Ratatui-000?logo=ratatui&logoColor=fff)](https://ratatui.rs/) + 使用命令行管理自己部署的Memos。 -[Memos](https://usememos.com/) \ No newline at end of file +[Memos](https://usememos.com/) diff --git a/src/main.rs b/src/main.rs index 0568676..ee4cb34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -142,6 +142,10 @@ async fn run_tui_mode() -> Result<()> { } if key.code == KeyCode::Esc && !app.loading { + if app.mode == AppMode::CreateMemo && app.is_editing { + app.is_editing = false; + continue; + } match app.mode { AppMode::MainMenu => continue, AppMode::Config => { @@ -152,6 +156,7 @@ async fn run_tui_mode() -> Result<()> { AppMode::CreateMemo => { app.input_content.clear(); app.input_visibility = "PRIVATE".to_string(); + app.is_editing = false; app.mode = AppMode::MainMenu; } AppMode::ListMemos => { @@ -160,6 +165,12 @@ async fn run_tui_mode() -> Result<()> { AppMode::ViewMemo => { app.mode = AppMode::ListMemos; } + AppMode::SelectVisibility => { + app.mode = AppMode::CreateMemo; + } + AppMode::Help => { + app.mode = AppMode::CreateMemo; + } } app.message = None; } @@ -211,6 +222,7 @@ async fn run_tui_mode() -> Result<()> { if app.config.is_configured() { app.input_content.clear(); app.input_visibility = "PRIVATE".to_string(); + app.is_editing = false; app.mode = AppMode::CreateMemo; } else { app.message = Some("Please configure first".to_string()); @@ -228,39 +240,6 @@ async fn run_tui_mode() -> Result<()> { } } } - KeyCode::Char('1') => { - if app.config.is_configured() { - app.loading = true; - terminal.draw(|f| tui::render_app(f, &mut app))?; - match load_memos(&app).await { - Ok(memos) => { - app.memos = memos; - app.mode = AppMode::ListMemos; - app.message = Some("Memos loaded successfully".to_string()); - } - Err(e) => { - app.message = Some(format!("Error: {}", e)); - } - } - app.loading = false; - } else { - app.message = Some("Please configure first".to_string()); - } - } - KeyCode::Char('2') => { - if app.config.is_configured() { - app.input_content.clear(); - app.input_visibility = "PRIVATE".to_string(); - app.mode = AppMode::CreateMemo; - } else { - app.message = Some("Please configure first".to_string()); - } - } - KeyCode::Char('3') => { - app.input_base_url = app.config.base_url.clone(); - app.input_token = app.config.user_token.clone(); - app.mode = AppMode::Config; - } KeyCode::Char('Q') => { break; } @@ -277,6 +256,29 @@ async fn run_tui_mode() -> Result<()> { handle_list_memos_input(&mut app, &key).await?; } AppMode::ViewMemo => {} + AppMode::SelectVisibility => { + handle_select_visibility_input(&mut app, &key).await?; + } + AppMode::Help => { + // 处理帮助模式下的滚动 + match key.code { + KeyCode::Up => { + if app.help_scroll > 0 { + app.help_scroll -= 1; + } + } + KeyCode::Down => { + // 帮助信息有 12 行,弹窗内容区域高度为 11 行 + if app.help_scroll < 12 - 11 { // 12 是帮助信息行数,11 是弹窗内容区域高度 + app.help_scroll += 1; + } + } + _ => { + // 按下其他键关闭帮助弹窗,返回 CreateMemo 模式 + app.mode = AppMode::CreateMemo; + } + } + } } } _ => {} @@ -332,17 +334,27 @@ async fn handle_config_input(app: &mut App, key: &KeyEvent) -> Result<()> { } async fn handle_create_memo_input(app: &mut App, key: &KeyEvent) -> Result<()> { - match key.code { - KeyCode::Char(c) => { - app.input_content.push(c); - } - KeyCode::Backspace => { - app.input_content.pop(); - } - KeyCode::Enter => { - if key.modifiers.contains(event::KeyModifiers::CONTROL) { - return Ok(()); + if app.is_editing { + match key.code { + KeyCode::Esc => { + app.is_editing = false; } + KeyCode::Enter => { + app.input_content.push('\n'); + } + KeyCode::Backspace => { + app.input_content.pop(); + } + KeyCode::Char(c) => { + app.input_content.push(c); + } + _ => {} + } + return Ok(()); + } + + match key.code { + KeyCode::Enter => { if app.input_content.is_empty() { app.message = Some("Content cannot be empty".to_string()); return Ok(()); @@ -351,6 +363,10 @@ async fn handle_create_memo_input(app: &mut App, key: &KeyEvent) -> Result<()> { let client = app.get_client().await?; let visibility = if app.input_visibility.to_uppercase() == "PUBLIC" { Some("PUBLIC") + } else if app.input_visibility.to_uppercase() == "PROTECTED" { + Some("PROTECTED") + } else if app.input_visibility.to_uppercase() == "VISIBILITY_UNSPECIFIED" { + Some("VISIBILITY_UNSPECIFIED") } else { Some("PRIVATE") }; @@ -367,6 +383,15 @@ async fn handle_create_memo_input(app: &mut App, key: &KeyEvent) -> Result<()> { } } } + KeyCode::Char('v') | KeyCode::Char('V') => { + app.mode = AppMode::SelectVisibility; + } + KeyCode::Char('i') => { + app.is_editing = true; + } + KeyCode::Char('/') => { + app.mode = AppMode::Help; + } _ => {} } Ok(()) @@ -417,4 +442,46 @@ async fn handle_list_memos_input(app: &mut App, key: &KeyEvent) -> Result<()> { _ => {} } Ok(()) +} + +async fn handle_select_visibility_input(app: &mut App, key: &KeyEvent) -> Result<()> { + match key.code { + KeyCode::Up => { + if let Some(selected) = app.visibility_list_state.selected() { + if selected > 0 { + app.visibility_list_state.select(Some(selected - 1)); + } + } else { + app.visibility_list_state.select(Some(0)); + } + } + KeyCode::Down => { + if let Some(selected) = app.visibility_list_state.selected() { + if selected < 3 { // 4 items, 0-3 index + app.visibility_list_state.select(Some(selected + 1)); + } + } else { + app.visibility_list_state.select(Some(0)); + } + } + KeyCode::Enter => { + let visibility_options = vec![ + "VISIBILITY_UNSPECIFIED", + "PRIVATE", + "PROTECTED", + "PUBLIC", + ]; + if let Some(selected) = app.visibility_list_state.selected() { + if selected < visibility_options.len() { + app.input_visibility = visibility_options[selected].to_string(); + } + } + app.mode = AppMode::CreateMemo; + } + KeyCode::Esc => { + app.mode = AppMode::CreateMemo; + } + _ => {} + } + Ok(()) } \ No newline at end of file diff --git a/src/tui.rs b/src/tui.rs index 313d19f..2d08fa1 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -5,7 +5,7 @@ use anyhow::Result; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListDirection, ListItem, ListState, Paragraph}; +use ratatui::widgets::{Block, Borders, Clear, List, ListDirection, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}; use ratatui::Frame; use std::sync::Arc; use tokio::sync::Mutex; @@ -18,6 +18,8 @@ pub enum AppMode { CreateMemo, ListMemos, ViewMemo, + SelectVisibility, + Help, } pub struct App { @@ -35,12 +37,19 @@ pub struct App { client: Arc>>, pub list_selected: Option, pub list_state: ListState, + pub visibility_list_state: ListState, + pub content_scroll: usize, + pub help_scroll: usize, + pub scrollbar_state: ScrollbarState, + pub is_editing: bool, } impl App { pub fn new(config: Config) -> Self { let mut list_state = ListState::default(); list_state.select(Some(0)); + let mut visibility_list_state = ListState::default(); + visibility_list_state.select(Some(0)); Self { mode: AppMode::MainMenu, config, @@ -56,6 +65,11 @@ impl App { client: Arc::new(Mutex::new(None)), list_selected: None, list_state, + visibility_list_state, + content_scroll: 0usize, + help_scroll: 0usize, + scrollbar_state: ScrollbarState::default(), + is_editing: false, } } @@ -90,6 +104,8 @@ pub fn render_app(frame: &mut Frame, app: &mut App) { AppMode::CreateMemo => render_create_memo(frame, app), AppMode::ListMemos => render_list_memos(frame, app), AppMode::ViewMemo => render_view_memo(frame, app), + AppMode::SelectVisibility => render_select_visibility(frame, app), + AppMode::Help => render_help(frame, app), } if let Some(msg) = &app.message { @@ -153,13 +169,15 @@ fn render_main_menu(frame: &mut Frame, app: &mut App) { frame.render_widget(title, chunks[0]); // 产品版本 - let version = Paragraph::new("Version: 1.0.0") + let version = Paragraph::new(format!("Version: {}", env!("CARGO_PKG_VERSION"))) .style(Style::default().fg(Color::White)) .alignment(Alignment::Center); frame.render_widget(version, chunks[1]); // 作者及联系方式 - let author = Paragraph::new("Author: Your Name ") + let authors = env!("CARGO_PKG_AUTHORS"); + let first_author = authors.split(';').next().unwrap_or(authors); + let author = Paragraph::new(format!("Author: {}", first_author)) .style(Style::default().fg(Color::White)) .alignment(Alignment::Center); frame.render_widget(author, chunks[2]); @@ -264,7 +282,7 @@ fn render_create_memo(frame: &mut Frame, app: &mut App) { let title = Paragraph::new("Create Memo") .style(Style::default().fg(Color::Cyan).bold()) .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL).title("New Memo")); + .block(Block::default().borders(Borders::ALL)); frame.render_widget(title, chunks[0]); let visibility_label = Paragraph::new("Visibility (PUBLIC/PRIVATE):") @@ -280,14 +298,26 @@ fn render_create_memo(frame: &mut Frame, app: &mut App) { .style(Style::default().fg(Color::White)); frame.render_widget(content_label, chunks[2]); + let content_title = if app.is_editing { + "Content [Editing]" + } else { + "Content" + }; let content = Paragraph::new(app.input_content.clone()) .style(Style::default().fg(Color::Yellow)) - .block(Block::default().borders(Borders::ALL).title("Content")); + .block(Block::default().borders(Borders::ALL).title(content_title)) + .scroll((app.content_scroll.try_into().unwrap(), 0)); frame.render_widget(content, chunks[2]); - let help = Paragraph::new("Enter: Submit | Esc: Cancel") - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center); + let help = if app.is_editing { + Paragraph::new("Esc: Exit Edit Mode | Enter: Newline | Type to input") + .style(Style::default().fg(Color::Green).bold()) + .alignment(Alignment::Center) + } else { + Paragraph::new("Enter: Submit | i: Edit Mode | v: Visibility | /: Help") + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center) + }; frame.render_widget(help, chunks[3]); } @@ -384,4 +414,127 @@ fn render_view_memo(frame: &mut Frame, app: &App) { .alignment(Alignment::Center); frame.render_widget(help, chunks[3]); } +} + +fn render_select_visibility(frame: &mut Frame, app: &mut App) { + // 创建居中的弹窗区域 + let width = 45; + let height = 12; + let x = (frame.area().width / 2).saturating_sub(width / 2); + let y = (frame.area().height / 2).saturating_sub(height / 2); + let area = Rect::new(x, y, width, height); + + // 清除弹窗区域 + frame.render_widget(Clear, area); + + // 渲染弹窗边框 + let block = Block::default() + .borders(Borders::ALL) + .title("Select Visibility") + .title_alignment(Alignment::Center); + frame.render_widget(block, area); + + // 定义 Visibility 选项 + let visibility_options = vec![ + "VISIBILITY_UNSPECIFIED", + "PRIVATE", + "PROTECTED", + "PUBLIC", + ]; + + // 创建列表项 + let list_items: Vec = visibility_options + .iter() + .map(|option| ListItem::new(*option)) + .collect(); + + // 创建列表 + let list = List::new(list_items) + .block(Block::default()) + .highlight_style(Style::default().bg(Color::DarkGray).fg(Color::Cyan).bold()) + .direction(ListDirection::TopToBottom); + + // 创建列表区域(留出标题栏和边框的空间) + let list_area = Rect::new(x + 2, y + 2, width - 4, height - 4); + + // 渲染列表 + frame.render_stateful_widget(list, list_area, &mut app.visibility_list_state); + + // 渲染帮助信息 + let help = Paragraph::new("↑↓: Navigate | Enter: Select | Esc: Cancel") + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + let help_area = Rect::new(x, y + height, width, 2); + frame.render_widget(help, help_area); +} + +fn render_help(frame: &mut Frame, app: &mut App) { + // 创建居中的弹窗区域 + let width = 60; + let height = 15; + let x = (frame.area().width / 2).saturating_sub(width / 2); + let y = (frame.area().height / 2).saturating_sub(height / 2); + let area = Rect::new(x, y, width, height); + + // 清除弹窗区域 + frame.render_widget(Clear, area); + + // 渲染弹窗边框 + let block = Block::default() + .borders(Borders::ALL) + .title("HELP") + .title_alignment(Alignment::Center); + frame.render_widget(block, area); + + // 帮助信息 + let help_lines = vec![ + Line::from("Input Help:"), + Line::from(""), + Line::from("- Enter: Submit memo"), + Line::from("- Esc: Cancel and return to main menu"), + Line::from("- Ctrl+Enter: Insert newline"), + Line::from("- v: Change visibility"), + Line::from("- /: Show this help"), + Line::from(""), + Line::from("Visibility Options:"), + Line::from("- PRIVATE: Only visible to you"), + Line::from("- PUBLIC: Visible to everyone"), + Line::from("- PROTECTED: Visible to shared users"), + ]; + + // 创建帮助信息区域(留出标题栏、边框和滚动条的空间) + let help_area = Rect::new(x + 2, y + 2, width - 6, height - 4); + + // 创建滚动条区域 + let scrollbar_area = Rect::new(x + width - 4, y + 2, 1, height - 4); + + // 创建帮助信息段落 + let help_text = Paragraph::new(help_lines.clone()) + .style(Style::default().fg(Color::White)) + .scroll((app.help_scroll.try_into().unwrap(), 0)); + + // 渲染帮助信息 + frame.render_widget(help_text, help_area); + + // 更新滚动条状态 + app.scrollbar_state = ScrollbarState::default() + .position(app.help_scroll) + .content_length(help_lines.len()) + .viewport_content_length((height - 4) as usize); + + frame.render_stateful_widget( + Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .thumb_style(Style::default().bg(Color::DarkGray)) + .track_style(Style::default().bg(Color::Black)), + scrollbar_area, + &mut app.scrollbar_state, + ); + + // 渲染底部提示 + let hint = Paragraph::new("Press any key to close | ↑↓: Scroll") + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + let hint_area = Rect::new(x, y + height, width, 2); + frame.render_widget(hint, hint_area); } \ No newline at end of file