fix(tui): 修复 Esc 键在创建和编辑 memo 时的行为

This commit is contained in:
2026-04-15 14:51:00 +08:00
parent 69acb31465
commit cf13e516b9
3 changed files with 274 additions and 52 deletions

View File

@@ -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/)

View File

@@ -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<()> {
if app.is_editing {
match key.code {
KeyCode::Char(c) => {
app.input_content.push(c);
KeyCode::Esc => {
app.is_editing = false;
}
KeyCode::Enter => {
app.input_content.push('\n');
}
KeyCode::Backspace => {
app.input_content.pop();
}
KeyCode::Enter => {
if key.modifiers.contains(event::KeyModifiers::CONTROL) {
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(())
@@ -418,3 +443,45 @@ 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(())
}

View File

@@ -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<Mutex<Option<MemosClient>>>,
pub list_selected: Option<usize>,
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 <your@email.com>")
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")
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);
.alignment(Alignment::Center)
};
frame.render_widget(help, chunks[3]);
}
@@ -385,3 +415,126 @@ fn render_view_memo(frame: &mut Frame, app: &App) {
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<ListItem> = 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);
}