docs: 完善 README 并补充可见性配置说明

This commit is contained in:
2026-04-15 16:37:47 +08:00
parent cf13e516b9
commit 3271283c82
5 changed files with 717 additions and 296 deletions

141
README.md
View File

@@ -2,6 +2,143 @@
[![Built With Ratatui](https://img.shields.io/badge/Built_With_Ratatui-000?logo=ratatui&logoColor=fff)](https://ratatui.rs/)
使用命令行管理自己部署的Memos。
使用命令行管理自己部署的 [Memos](https://usememos.com/)
[Memos](https://usememos.com/)
## 功能特性
- **TUI 交互界面**:基于 Ratatui 的终端用户界面,支持键盘导航
- **CLI 命令行模式**:支持命令行直接操作,便于脚本集成
- **Memo 管理**:创建、查看、列出 Memos
- **多可见性选项**:支持 PRIVATE、PUBLIC、PROTECTED 等可见性设置
- **分页浏览**:支持分页查看 Memos 列表
- **配置持久化**:自动保存服务器地址和 Token 配置
## 安装
### 从源码编译
```bash
git clone https://github.com/your-repo/memoscli.git
cd memoscli
cargo build --release
```
编译后的二进制文件位于 `target/release/memoscli`
## 使用方法
### TUI 模式(默认)
直接运行程序进入 TUI 交互界面:
```bash
memoscli
```
#### 主菜单操作
| 按键 | 功能 |
|------|------|
| `↑` / `↓` | 导航菜单 |
| `Enter` | 选择菜单项 |
| `Q` | 退出程序 |
#### 创建 Memo
| 按键 | 功能 |
|------|------|
| `i` | 进入编辑模式 |
| `v` | 选择可见性 |
| `/` | 显示帮助 |
| `Enter` | 提交 Memo |
| `Esc` | 返回主菜单 |
编辑模式下:
| 按键 | 功能 |
|------|------|
| `Enter` | 换行 |
| `Esc` | 退出编辑模式 |
#### 查看 Memos 列表
| 按键 | 功能 |
|------|------|
| `↑` / `↓` | 选择 Memo |
| `←` / `→` | 翻页 |
| `Enter` | 查看详情 |
| `R` | 刷新列表 |
| `Esc` | 返回主菜单 |
#### 配置设置
| 按键 | 功能 |
|------|------|
| `Tab` | 切换输入字段 |
| `Enter` | 保存配置 |
| `Esc` | 取消并返回 |
### CLI 命令行模式
#### 配置
```bash
# 设置服务器地址和 Token
memoscli config --base-url https://your-memos-server.com --token your-token
# 查看当前配置
memoscli config --show
```
#### 创建 Memo
```bash
# 直接输入内容
memoscli create --content "Hello, Memos!"
# 从文件读取内容
memoscli create --file memo.md
# 指定可见性
memoscli create --content "Public memo" --visibility PUBLIC
```
#### 列出 Memos
```bash
# 列出最近的 Memos
memoscli list
# 指定数量和偏移
memoscli list --limit 50 --offset 10
```
## 配置文件
配置文件存储位置:
- **Linux/macOS**: `~/.config/memoscli/config.json`
- **Windows**: `%APPDATA%\memoscli\config\config.json`
配置文件格式:
```json
{
"base_url": "https://your-memos-server.com",
"user_token": "your-api-token"
}
```
## 依赖
- [ratatui](https://ratatui.rs/) - 终端 UI 框架
- [crossterm](https://github.com/crossterm-rs/crossterm) - 终端操作库
- [reqwest](https://github.com/seanmonstar/reqwest) - HTTP 客户端
- [tokio](https://tokio.rs/) - 异步运行时
- [clap](https://github.com/clap-rs/clap) - 命令行参数解析
- [chrono](https://github.com/chronotope/chrono) - 日期时间处理
- [anyhow](https://github.com/dtolnay/anyhow) - 错误处理
## 许可证
MIT License

View File

@@ -1,4 +1,4 @@
use crate::models::{CreateMemoRequest, CreateMemoResponse, ListMemosResponse, Memo};
use crate::models::{CreateMemoRequest, ListMemosResponse, Memo};
use anyhow::Result;
use reqwest::Client;
@@ -22,12 +22,21 @@ impl MemosClient {
pub async fn create_memo(&self, content: &str, visibility: Option<&str>) -> Result<Memo> {
let url = format!("{}/api/v1/memos", self.base_url);
let visibility_str = visibility.map(|v| {
if v.starts_with("VISIBILITY_") {
v.to_string()
} else {
format!("VISIBILITY_{}", v.to_uppercase())
}
});
let request = CreateMemoRequest {
content: content.to_string(),
visibility: visibility.map(|s| s.to_string()),
visibility: visibility_str,
};
let response = self.client
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.user_token))
.header("Content-Type", "application/json")
@@ -41,33 +50,16 @@ impl MemosClient {
anyhow::bail!("Failed to create memo: {} - {}", status, error_text);
}
let result: CreateMemoResponse = response.json().await?;
self.get_memo(result.id).await
}
pub async fn get_memo(&self, id: i64) -> Result<Memo> {
let url = format!("{}/api/v1/memos/{}", self.base_url, id);
let response = self.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.user_token))
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
anyhow::bail!("Failed to get memo: {} - {}", status, error_text);
}
Ok(response.json().await?)
let mut memo: Memo = response.json().await?;
memo.id = Memo::extract_id(&memo.name);
Ok(memo)
}
pub async fn list_memos(&self, limit: Option<i32>, offset: Option<i32>) -> Result<Vec<Memo>> {
let url = format!("{}/api/v1/memos", self.base_url);
let mut request = self.client
let mut request = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.user_token));

View File

@@ -11,7 +11,9 @@ use client::MemosClient;
use config::Config;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen,
};
use models::Memo;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
@@ -69,26 +71,29 @@ async fn run_cli_command(command: Command) -> Result<()> {
}
}
}
Command::List(cmd) => {
match client.list_memos(Some(cmd.limit), Some(cmd.offset)).await {
Ok(memos) => {
if memos.is_empty() {
println!("No memos found.");
} else {
for memo in &memos {
let created = chrono::DateTime::from_timestamp_millis(memo.created_ts)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "Unknown".to_string());
println!("[{}] {} - {}", memo.id, created, &memo.content[..memo.content.len().min(60)]);
}
println!("\nTotal: {} memos", memos.len());
Command::List(cmd) => match client.list_memos(Some(cmd.limit), Some(cmd.offset)).await {
Ok(memos) => {
if memos.is_empty() {
println!("No memos found.");
} else {
for memo in &memos {
let created = chrono::DateTime::from_timestamp_millis(memo.created_ts)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "Unknown".to_string());
println!(
"[{}] {} - {}",
memo.id,
created,
&memo.content[..memo.content.len().min(60)]
);
}
}
Err(e) => {
println!("Error listing memos: {}", e);
println!("\nTotal: {} memos", memos.len());
}
}
}
Err(e) => {
println!("Error listing memos: {}", e);
}
},
Command::Config(cmd) => {
if cmd.show {
if config.is_configured() {
@@ -130,163 +135,206 @@ async fn run_tui_mode() -> Result<()> {
loop {
terminal.draw(|f| tui::render_app(f, &mut app))?;
if let Event::Key(key) = event::read()? {
match key.kind {
KeyEventKind::Press => {
if key.code == KeyCode::Char('q') && !app.loading {
if app.mode == AppMode::MainMenu {
break;
}
app.mode = AppMode::MainMenu;
app.message = None;
let event = event::read()?;
match event {
Event::Paste(text) => match app.mode {
AppMode::Config => {
if app.current_field == 0 {
app.input_base_url.push_str(&text);
} else {
app.input_token.push_str(&text);
}
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 => {
app.input_base_url = app.config.base_url.clone();
app.input_token = app.config.user_token.clone();
app.mode = AppMode::MainMenu;
}
AppMode::CreateMemo => {
app.input_content.clear();
app.input_visibility = "PRIVATE".to_string();
app.is_editing = false;
app.mode = AppMode::MainMenu;
}
AppMode::ListMemos => {
app.mode = AppMode::MainMenu;
}
AppMode::ViewMemo => {
app.mode = AppMode::ListMemos;
}
AppMode::SelectVisibility => {
app.mode = AppMode::CreateMemo;
}
AppMode::Help => {
app.mode = AppMode::CreateMemo;
}
}
app.message = None;
}
match app.mode {
AppMode::MainMenu => {
match key.code {
KeyCode::Up => {
if let Some(selected) = app.list_state.selected() {
if selected > 0 {
app.list_state.select(Some(selected - 1));
}
} else {
app.list_state.select(Some(0));
}
}
KeyCode::Down => {
if let Some(selected) = app.list_state.selected() {
if selected < 3 { // 4 items, 0-3 index
app.list_state.select(Some(selected + 1));
}
} else {
app.list_state.select(Some(0));
}
}
KeyCode::Enter => {
if let Some(selected) = app.list_state.selected() {
match selected {
0 => { // List Memos
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());
}
}
1 => { // Create Memo
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());
}
}
2 => { // Configuration
app.input_base_url = app.config.base_url.clone();
app.input_token = app.config.user_token.clone();
app.mode = AppMode::Config;
}
3 => { // Quit
break;
}
_ => {}
}
}
}
KeyCode::Char('Q') => {
break;
}
_ => {}
}
}
AppMode::Config => {
handle_config_input(&mut app, &key).await?;
}
AppMode::CreateMemo => {
handle_create_memo_input(&mut app, &key).await?;
}
AppMode::ListMemos => {
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;
}
}
}
}
AppMode::CreateMemo => {
if app.is_editing {
app.input_content.push_str(&text);
}
}
_ => {}
},
Event::Key(key) => {
match key.kind {
KeyEventKind::Press => {
if key.code == KeyCode::Char('q') && !app.loading {
if app.mode == AppMode::MainMenu {
break;
}
app.mode = AppMode::MainMenu;
app.message = None;
}
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 => {
app.input_base_url = app.config.base_url.clone();
app.input_token = app.config.user_token.clone();
app.mode = AppMode::MainMenu;
}
AppMode::CreateMemo => {
app.input_content.clear();
app.input_visibility = "PRIVATE".to_string();
app.is_editing = false;
app.mode = AppMode::MainMenu;
}
AppMode::ListMemos => {
app.mode = AppMode::MainMenu;
}
AppMode::ViewMemo => {
app.mode = AppMode::ListMemos;
}
AppMode::SelectVisibility => {
app.mode = AppMode::CreateMemo;
}
AppMode::Help => {
app.mode = AppMode::CreateMemo;
}
}
app.message = None;
}
match app.mode {
AppMode::MainMenu => {
match key.code {
KeyCode::Up => {
if let Some(selected) = app.list_state.selected() {
if selected > 0 {
app.list_state.select(Some(selected - 1));
}
} else {
app.list_state.select(Some(0));
}
}
KeyCode::Down => {
if let Some(selected) = app.list_state.selected() {
if selected < 3 {
// 4 items, 0-3 index
app.list_state.select(Some(selected + 1));
}
} else {
app.list_state.select(Some(0));
}
}
KeyCode::Enter => {
if let Some(selected) = app.list_state.selected() {
match selected {
0 => {
// List Memos
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.current_page = 0;
app.list_selected = Some(0);
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(),
);
}
}
1 => {
// Create Memo
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(),
);
}
}
2 => {
// Configuration
app.input_base_url =
app.config.base_url.clone();
app.input_token = app.config.user_token.clone();
app.mode = AppMode::Config;
}
3 => {
// Quit
break;
}
_ => {}
}
}
}
KeyCode::Char('Q') => {
break;
}
_ => {}
}
}
AppMode::Config => {
handle_config_input(&mut app, &key).await?;
}
AppMode::CreateMemo => {
handle_create_memo_input(&mut app, &key).await?;
}
AppMode::ListMemos => {
handle_list_memos_input(&mut app, &key).await?;
}
AppMode::ViewMemo => {
handle_view_memo_input(&mut app, &key).await?;
}
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;
}
}
}
}
}
_ => {}
}
}
_ => {}
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), Clear(ClearType::All))?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
@@ -398,12 +446,22 @@ async fn handle_create_memo_input(app: &mut App, key: &KeyEvent) -> Result<()> {
}
async fn handle_list_memos_input(app: &mut App, key: &KeyEvent) -> Result<()> {
let total_items = app.memos.len();
let items_per_page = app.items_per_page;
let total_pages = if total_items == 0 {
1
} else {
(total_items + items_per_page - 1) / items_per_page
};
match key.code {
KeyCode::Char('r') | KeyCode::Char('R') => {
let client = app.get_client().await?;
match client.list_memos(None, None).await {
Ok(memos) => {
app.memos = memos;
app.current_page = 0;
app.list_selected = Some(0);
app.message = Some("Memos refreshed".to_string());
}
Err(e) => {
@@ -412,33 +470,59 @@ async fn handle_list_memos_input(app: &mut App, key: &KeyEvent) -> Result<()> {
}
}
KeyCode::Enter => {
app.message = None;
if !app.memos.is_empty() {
if let Some(idx) = app.list_selected {
if idx < app.memos.len() {
app.selected_memo = Some(app.memos[idx].clone());
app.memo_content_scroll = 0;
app.mode = AppMode::ViewMemo;
}
}
}
}
KeyCode::Up => {
app.message = None;
if let Some(idx) = app.list_selected {
if idx > 0 {
app.list_selected = Some(idx - 1);
let new_idx = idx - 1;
app.list_selected = Some(new_idx);
if new_idx < app.current_page * items_per_page {
app.current_page = new_idx / items_per_page;
}
}
} else {
app.list_selected = Some(0);
}
}
KeyCode::Down => {
app.message = None;
if let Some(idx) = app.list_selected {
if idx < app.memos.len() - 1 {
app.list_selected = Some(idx + 1);
if idx < app.memos.len().saturating_sub(1) {
let new_idx = idx + 1;
app.list_selected = Some(new_idx);
if new_idx >= (app.current_page + 1) * items_per_page {
app.current_page = new_idx / items_per_page;
}
}
} else if !app.memos.is_empty() {
app.list_selected = Some(0);
}
}
KeyCode::Left => {
app.message = None;
if app.current_page > 0 {
app.current_page -= 1;
app.list_selected = Some(app.current_page * items_per_page);
}
}
KeyCode::Right => {
app.message = None;
if app.current_page < total_pages.saturating_sub(1) {
app.current_page += 1;
app.list_selected = Some(app.current_page * items_per_page);
}
}
_ => {}
}
Ok(())
@@ -457,7 +541,8 @@ async fn handle_select_visibility_input(app: &mut App, key: &KeyEvent) -> Result
}
KeyCode::Down => {
if let Some(selected) = app.visibility_list_state.selected() {
if selected < 3 { // 4 items, 0-3 index
if selected < 3 {
// 4 items, 0-3 index
app.visibility_list_state.select(Some(selected + 1));
}
} else {
@@ -465,12 +550,8 @@ async fn handle_select_visibility_input(app: &mut App, key: &KeyEvent) -> Result
}
}
KeyCode::Enter => {
let visibility_options = vec![
"VISIBILITY_UNSPECIFIED",
"PRIVATE",
"PROTECTED",
"PUBLIC",
];
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();
@@ -485,3 +566,25 @@ async fn handle_select_visibility_input(app: &mut App, key: &KeyEvent) -> Result
}
Ok(())
}
async fn handle_view_memo_input(app: &mut App, key: &KeyEvent) -> Result<()> {
match key.code {
KeyCode::Up => {
if app.memo_content_scroll > 0 {
app.memo_content_scroll -= 1;
}
}
KeyCode::Down => {
if let Some(memo) = &app.selected_memo {
let content_width = 80;
let wrapped_lines = tui::wrap_text(&memo.content, content_width);
let total_lines = wrapped_lines.len();
if app.memo_content_scroll < total_lines.saturating_sub(1) {
app.memo_content_scroll += 1;
}
}
}
_ => {}
}
Ok(())
}

View File

@@ -3,40 +3,100 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateMemoRequest {
pub content: String,
#[serde(rename = "visibility")]
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memo {
#[serde(skip)]
pub id: i64,
pub creator_id: i64,
pub name: String,
#[serde(default)]
pub state: String,
pub creator: String,
#[serde(rename = "createTime", deserialize_with = "parse_timestamp")]
pub created_ts: i64,
#[serde(rename = "updateTime", deserialize_with = "parse_timestamp", default)]
pub updated_ts: i64,
#[serde(default)]
pub content: String,
#[serde(default)]
pub visibility: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub resources: Vec<Resource>,
pub attachments: Vec<Attachment>,
#[serde(default)]
pub pinned: bool,
}
impl Memo {
pub fn extract_id(name: &str) -> i64 {
name.rsplit('/')
.next()
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0)
}
}
fn parse_timestamp<'de, D>(deserializer: D) -> Result<i64, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde_json::Value;
let value = Value::deserialize(deserializer)?;
match value {
Value::String(s) => {
if s.is_empty() {
return Ok(0);
}
chrono::DateTime::parse_from_rfc3339(&s)
.map(|dt| dt.timestamp_millis())
.or_else(|_| {
s.parse::<i64>()
.map(|ts| if ts < 10000000000 { ts * 1000 } else { ts })
})
.map_err(|_| serde::de::Error::custom(format!("Invalid timestamp format: {}", s)))
}
Value::Number(n) => {
let ts = n.as_i64().unwrap_or(0);
Ok(if ts < 10000000000 { ts * 1000 } else { ts })
}
Value::Null => Ok(0),
_ => Err(serde::de::Error::custom("Invalid timestamp type")),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Resource {
pub id: i64,
pub struct Attachment {
pub name: String,
#[serde(default)]
pub filename: String,
#[serde(rename = "type")]
pub resource_type: String,
pub size: i64,
pub url: String,
#[serde(rename = "type", default)]
pub attachment_type: String,
#[serde(default)]
pub size: String,
#[serde(default)]
pub external_link: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListMemosResponse {
#[serde(deserialize_with = "deserialize_memos")]
pub memos: Vec<Memo>,
#[serde(default)]
pub next_page_token: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateMemoResponse {
pub id: i64,
fn deserialize_memos<'de, D>(deserializer: D) -> Result<Vec<Memo>, D::Error>
where
D: serde::Deserializer<'de>,
{
let mut memos: Vec<Memo> = Vec::deserialize(deserializer)?;
for memo in &mut memos {
memo.id = Memo::extract_id(&memo.name);
}
Ok(memos)
}

View File

@@ -1,15 +1,61 @@
use crate::config::Config;
use crate::client::MemosClient;
use crate::config::Config;
use crate::models::Memo;
use anyhow::Result;
use chrono::Local;
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, Scrollbar, ScrollbarOrientation, ScrollbarState};
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;
use chrono::Local;
pub fn wrap_text(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
for line in text.lines() {
if line.is_empty() {
lines.push(String::new());
continue;
}
let mut current_line = String::new();
let mut current_width = 0;
for ch in line.chars() {
let char_width = if ch.is_ascii() { 1 } else { 2 };
if current_width + char_width > width {
if !current_line.is_empty() {
lines.push(current_line.clone());
}
current_line = ch.to_string();
current_width = char_width;
} else {
current_line.push(ch);
current_width += char_width;
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
@@ -40,8 +86,12 @@ pub struct App {
pub visibility_list_state: ListState,
pub content_scroll: usize,
pub help_scroll: usize,
pub memo_content_scroll: usize,
pub memo_scrollbar_state: ScrollbarState,
pub scrollbar_state: ScrollbarState,
pub is_editing: bool,
pub current_page: usize,
pub items_per_page: usize,
}
impl App {
@@ -68,8 +118,12 @@ impl App {
visibility_list_state,
content_scroll: 0usize,
help_scroll: 0usize,
memo_content_scroll: 0usize,
memo_scrollbar_state: ScrollbarState::default(),
scrollbar_state: ScrollbarState::default(),
is_editing: false,
current_page: 0,
items_per_page: 10,
}
}
@@ -93,7 +147,9 @@ impl App {
pub async fn get_client(&self) -> Result<MemosClient> {
let guard = self.client.lock().await;
guard.clone().ok_or_else(|| anyhow::anyhow!("Client not initialized"))
guard
.clone()
.ok_or_else(|| anyhow::anyhow!("Client not initialized"))
}
}
@@ -118,8 +174,7 @@ pub fn render_app(frame: &mut Frame, app: &mut App) {
.style(Style::default().fg(Color::Yellow));
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let text = Paragraph::new(msg.as_str())
.alignment(Alignment::Center);
let text = Paragraph::new(msg.as_str()).alignment(Alignment::Center);
frame.render_widget(text, area);
}
}
@@ -184,8 +239,8 @@ fn render_main_menu(frame: &mut Frame, app: &mut App) {
// 数字时钟
let current_time = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let clock = Paragraph::new(format!("🕒 {}", current_time))
.style(Style::default().fg(Color::Yellow));
let clock =
Paragraph::new(format!("🕒 {}", current_time)).style(Style::default().fg(Color::Yellow));
frame.render_widget(clock, chunks[3]);
// 功能选择
@@ -285,8 +340,8 @@ fn render_create_memo(frame: &mut Frame, app: &mut App) {
.block(Block::default().borders(Borders::ALL));
frame.render_widget(title, chunks[0]);
let visibility_label = Paragraph::new("Visibility (PUBLIC/PRIVATE):")
.style(Style::default().fg(Color::White));
let visibility_label =
Paragraph::new("Visibility (PUBLIC/PRIVATE):").style(Style::default().fg(Color::White));
frame.render_widget(visibility_label, chunks[1]);
let visibility_input = Paragraph::new(app.input_visibility.clone())
@@ -294,8 +349,7 @@ fn render_create_memo(frame: &mut Frame, app: &mut App) {
.block(Block::default().borders(Borders::ALL).title("Visibility"));
frame.render_widget(visibility_input, chunks[1]);
let content_label = Paragraph::new("Content:")
.style(Style::default().fg(Color::White));
let content_label = Paragraph::new("Content:").style(Style::default().fg(Color::White));
frame.render_widget(content_label, chunks[2]);
let content_title = if app.is_editing {
@@ -331,47 +385,90 @@ fn render_list_memos(frame: &mut Frame, app: &mut App) {
])
.split(frame.area());
let title = Paragraph::new("Memos List")
let total_items = app.memos.len();
let available_height = chunks[1].height.saturating_sub(2) as usize;
app.items_per_page = if available_height > 0 {
available_height
} else {
10
};
let total_pages = if total_items == 0 {
1
} else {
(total_items + app.items_per_page - 1) / app.items_per_page
};
if app.current_page >= total_pages {
app.current_page = total_pages.saturating_sub(1);
}
let start_idx = app.current_page * app.items_per_page;
let end_idx = std::cmp::min(start_idx + app.items_per_page, total_items);
let page_info = format!(
"Memos List - Page {}/{} ({} items)",
app.current_page + 1,
total_pages,
total_items
);
let title = Paragraph::new(page_info)
.style(Style::default().fg(Color::Cyan).bold())
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Memos"));
frame.render_widget(title, chunks[0]);
let memos_list: Vec<ListItem> = app.memos.iter().enumerate().map(|(idx, memo)| {
let content_preview = if memo.content.len() > 50 {
format!("{}...", &memo.content[..50])
} else {
memo.content.clone()
};
let created = chrono::DateTime::from_timestamp_millis(memo.created_ts)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "Unknown".to_string());
if app.memos.is_empty() {
let empty_msg = Paragraph::new("No memos found")
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(empty_msg, chunks[1]);
} else {
let memos_list: Vec<ListItem> = app.memos[start_idx..end_idx]
.iter()
.enumerate()
.map(|(idx, memo)| {
let actual_idx = start_idx + idx;
let content_preview = if memo.content.chars().count() > 50 {
format!("{}...", memo.content.chars().take(50).collect::<String>())
} else {
memo.content.clone()
};
let created = chrono::DateTime::from_timestamp_millis(memo.created_ts)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "Unknown".to_string());
let style = if app.list_selected == Some(idx) {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let style = if app.list_selected == Some(actual_idx) {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(vec![
Span::raw(format!("[{}] ", memo.id)),
Span::styled(content_preview, Style::default().fg(Color::White)),
Span::raw(" - ").style(Style::default().fg(Color::DarkGray)),
Span::raw(created).style(Style::default().fg(Color::Green)),
])).style(style)
}).collect();
ListItem::new(Line::from(vec![
Span::raw(format!("[{}] ", memo.id)),
Span::styled(content_preview, Style::default().fg(Color::White)),
Span::raw(" - ").style(Style::default().fg(Color::DarkGray)),
Span::raw(created).style(Style::default().fg(Color::Green)),
]))
.style(style)
})
.collect();
let list = List::new(memos_list)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(list, chunks[1]);
let list = List::new(memos_list).block(Block::default().borders(Borders::ALL));
frame.render_widget(list, chunks[1]);
}
let help = Paragraph::new("Enter: View | R: Refresh | Esc: Back | ↑↓: Navigate")
let help = Paragraph::new("Enter: View | R: Refresh | ←→: Page | Esc: Back | ↑↓: Navigate")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[2]);
}
fn render_view_memo(frame: &mut Frame, app: &App) {
fn render_view_memo(frame: &mut Frame, app: &mut App) {
if let Some(memo) = &app.selected_memo {
let chunks = Layout::default()
.direction(Direction::Vertical)
@@ -404,12 +501,49 @@ fn render_view_memo(frame: &mut Frame, app: &App) {
.alignment(Alignment::Center);
frame.render_widget(meta_para, chunks[1]);
let content = Paragraph::new(memo.content.clone())
let content_width = chunks[2].width.saturating_sub(2) as usize;
let wrapped_lines = wrap_text(&memo.content, content_width);
let total_lines = wrapped_lines.len();
let content_lines: Vec<Line> = wrapped_lines
.iter()
.map(|line| Line::from(line.clone()))
.collect();
let viewport_height = chunks[2].height.saturating_sub(2) as usize;
if app.memo_content_scroll >= total_lines {
app.memo_content_scroll = total_lines.saturating_sub(1);
}
let content = Paragraph::new(content_lines)
.style(Style::default().fg(Color::White))
.block(Block::default().borders(Borders::ALL).title("Content"));
.block(Block::default().borders(Borders::ALL).title("Content"))
.scroll((app.memo_content_scroll as u16, 0));
frame.render_widget(content, chunks[2]);
let help = Paragraph::new("Esc: Back")
let scrollbar_area = Rect::new(
chunks[2].x + chunks[2].width - 1,
chunks[2].y + 1,
1,
chunks[2].height.saturating_sub(2),
);
app.memo_scrollbar_state = ScrollbarState::default()
.position(app.memo_content_scroll)
.content_length(total_lines)
.viewport_content_length(viewport_height);
frame.render_stateful_widget(
Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.thumb_style(Style::default().bg(Color::Cyan))
.track_style(Style::default().bg(Color::DarkGray)),
scrollbar_area,
&mut app.memo_scrollbar_state,
);
let help = Paragraph::new("↑↓: Scroll | Esc: Back")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[3]);
@@ -435,12 +569,7 @@ fn render_select_visibility(frame: &mut Frame, app: &mut App) {
frame.render_widget(block, area);
// 定义 Visibility 选项
let visibility_options = vec![
"VISIBILITY_UNSPECIFIED",
"PRIVATE",
"PROTECTED",
"PUBLIC",
];
let visibility_options = vec!["VISIBILITY_UNSPECIFIED", "PRIVATE", "PROTECTED", "PUBLIC"];
// 创建列表项
let list_items: Vec<ListItem> = visibility_options