fix(tui): 修复 Esc 键在创建和编辑 memo 时的行为
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# MemosCLI
|
||||
|
||||
[](https://ratatui.rs/)
|
||||
|
||||
使用命令行管理自己部署的Memos。
|
||||
|
||||
[Memos](https://usememos.com/)
|
||||
[Memos](https://usememos.com/)
|
||||
|
||||
153
src/main.rs
153
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(())
|
||||
}
|
||||
169
src/tui.rs
169
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<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")
|
||||
.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<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);
|
||||
}
|
||||
Reference in New Issue
Block a user