chore: re-organized tui and server 5b486f49
Steve Simkins · 2026-04-16 16:30 8 file(s) · +1243 −1305
apps/jotts/src/backend.rs +65 −66
1 -
use crate::db::{self, Db, Note};
1 +
use crate::db::{self, Db, Note, NoteInput};
2 +
use reqwest::StatusCode;
3 +
use reqwest::blocking::{Client, RequestBuilder, Response};
2 4
use std::fmt;
3 5
4 6
#[derive(Debug)]
29 31
    }
30 32
}
31 33
34 +
fn net<E: fmt::Display>(e: E) -> BackendError {
35 +
    BackendError::Network(e.to_string())
36 +
}
37 +
38 +
fn with_key(req: RequestBuilder, key: &Option<String>) -> RequestBuilder {
39 +
    match key {
40 +
        Some(k) => req.header("x-api-key", k),
41 +
        None => req,
42 +
    }
43 +
}
44 +
45 +
fn send_request(req: RequestBuilder) -> Result<Response, BackendError> {
46 +
    let resp = req.send().map_err(net)?;
47 +
    match resp.status().as_u16() {
48 +
        401 => Err(BackendError::Unauthorized("Invalid API key".into())),
49 +
        403 => Err(BackendError::Unauthorized(
50 +
            "No API key configured on server".into(),
51 +
        )),
52 +
        _ => Ok(resp),
53 +
    }
54 +
}
55 +
56 +
fn unexpected(status: StatusCode) -> BackendError {
57 +
    BackendError::Network(format!("HTTP {}", status))
58 +
}
59 +
32 60
pub enum Backend {
33 61
    Local {
34 62
        db: Db,
36 64
    Remote {
37 65
        base_url: String,
38 66
        api_key: Option<String>,
39 -
        client: reqwest::blocking::Client,
67 +
        client: Client,
40 68
    },
41 69
}
42 70
49 77
        Backend::Remote {
50 78
            base_url,
51 79
            api_key,
52 -
            client: reqwest::blocking::Client::new(),
80 +
            client: Client::new(),
53 81
        }
54 82
    }
55 83
61 89
                api_key,
62 90
                client,
63 91
            } => {
64 -
                let mut req = client.get(format!("{}/api/notes", base_url));
65 -
                if let Some(key) = api_key {
66 -
                    req = req.header("x-api-key", key);
67 -
                }
68 -
                let resp = req
69 -
                    .send()
70 -
                    .map_err(|e| BackendError::Network(e.to_string()))?;
92 +
                let req = with_key(client.get(format!("{base_url}/api/notes")), api_key);
93 +
                let resp = send_request(req)?;
71 94
                match resp.status().as_u16() {
72 -
                    200 => resp
73 -
                        .json::<Vec<Note>>()
74 -
                        .map_err(|e| BackendError::Network(e.to_string())),
75 -
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
76 -
                    403 => Err(BackendError::Unauthorized(
77 -
                        "No API key configured on server".into(),
78 -
                    )),
79 -
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
95 +
                    200 => resp.json::<Vec<Note>>().map_err(net),
96 +
                    _ => Err(unexpected(resp.status())),
80 97
                }
81 98
            }
82 99
        }
90 107
                api_key,
91 108
                client,
92 109
            } => {
93 -
                let mut req = client
94 -
                    .post(format!("{}/api/notes", base_url))
95 -
                    .json(&serde_json::json!({"title": title, "content": content}));
96 -
                if let Some(key) = api_key {
97 -
                    req = req.header("x-api-key", key);
98 -
                }
99 -
                let resp = req
100 -
                    .send()
101 -
                    .map_err(|e| BackendError::Network(e.to_string()))?;
110 +
                let body = NoteInput {
111 +
                    title: title.to_string(),
112 +
                    content: content.to_string(),
113 +
                };
114 +
                let req = with_key(
115 +
                    client.post(format!("{base_url}/api/notes")).json(&body),
116 +
                    api_key,
117 +
                );
118 +
                let resp = send_request(req)?;
102 119
                match resp.status().as_u16() {
103 -
                    201 => resp
104 -
                        .json::<Note>()
105 -
                        .map_err(|e| BackendError::Network(e.to_string())),
106 -
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
107 -
                    403 => Err(BackendError::Unauthorized(
108 -
                        "No API key configured on server".into(),
109 -
                    )),
110 -
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
120 +
                    201 => resp.json::<Note>().map_err(net),
121 +
                    _ => Err(unexpected(resp.status())),
111 122
                }
112 123
            }
113 124
        }
126 137
                api_key,
127 138
                client,
128 139
            } => {
129 -
                let mut req = client
130 -
                    .put(format!("{}/api/notes/{}", base_url, short_id))
131 -
                    .json(&serde_json::json!({"title": title, "content": content}));
132 -
                if let Some(key) = api_key {
133 -
                    req = req.header("x-api-key", key);
134 -
                }
135 -
                let resp = req
136 -
                    .send()
137 -
                    .map_err(|e| BackendError::Network(e.to_string()))?;
140 +
                let body = NoteInput {
141 +
                    title: title.to_string(),
142 +
                    content: content.to_string(),
143 +
                };
144 +
                let req = with_key(
145 +
                    client
146 +
                        .put(format!("{base_url}/api/notes/{short_id}"))
147 +
                        .json(&body),
148 +
                    api_key,
149 +
                );
150 +
                let resp = send_request(req)?;
138 151
                match resp.status().as_u16() {
139 -
                    200 => resp
140 -
                        .json::<Note>()
141 -
                        .map(Some)
142 -
                        .map_err(|e| BackendError::Network(e.to_string())),
143 -
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
144 -
                    403 => Err(BackendError::Unauthorized(
145 -
                        "No API key configured on server".into(),
146 -
                    )),
152 +
                    200 => resp.json::<Note>().map(Some).map_err(net),
147 153
                    404 => Ok(None),
148 -
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
154 +
                    _ => Err(unexpected(resp.status())),
149 155
                }
150 156
            }
151 157
        }
159 165
                api_key,
160 166
                client,
161 167
            } => {
162 -
                let mut req = client.delete(format!("{}/api/notes/{}", base_url, short_id));
163 -
                if let Some(key) = api_key {
164 -
                    req = req.header("x-api-key", key);
165 -
                }
166 -
                let resp = req
167 -
                    .send()
168 -
                    .map_err(|e| BackendError::Network(e.to_string()))?;
168 +
                let req = with_key(
169 +
                    client.delete(format!("{base_url}/api/notes/{short_id}")),
170 +
                    api_key,
171 +
                );
172 +
                let resp = send_request(req)?;
169 173
                match resp.status().as_u16() {
170 174
                    200 | 204 => Ok(true),
171 -
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
172 -
                    403 => Err(BackendError::Unauthorized(
173 -
                        "No API key configured on server".into(),
174 -
                    )),
175 175
                    404 => Ok(false),
176 -
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
176 +
                    _ => Err(unexpected(resp.status())),
177 177
                }
178 178
            }
179 179
        }
180 180
    }
181 181
}
182 -
apps/jotts/src/db.rs +63 −96
1 1
use nanoid::nanoid;
2 -
use rusqlite::{Connection, params};
2 +
use rusqlite::{Connection, OptionalExtension, Row, params};
3 3
use serde::{Deserialize, Serialize};
4 4
use std::fmt;
5 5
use std::sync::{Arc, Mutex};
6 6
7 7
pub type Db = Arc<Mutex<Connection>>;
8 8
9 +
const NOTE_COLUMNS: &str = "id, short_id, title, content, created_at, updated_at";
10 +
11 +
const SCHEMA: &str = "
12 +
    CREATE TABLE IF NOT EXISTS notes (
13 +
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
14 +
        short_id   TEXT NOT NULL UNIQUE,
15 +
        title      TEXT NOT NULL,
16 +
        content    TEXT NOT NULL,
17 +
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
18 +
        updated_at TEXT NOT NULL DEFAULT (datetime('now'))
19 +
    );
20 +
21 +
    CREATE TABLE IF NOT EXISTS sessions (
22 +
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
23 +
        token      TEXT NOT NULL UNIQUE,
24 +
        expires_at TEXT NOT NULL
25 +
    );
26 +
";
27 +
9 28
#[derive(Debug)]
10 29
pub enum DbError {
11 30
    Sqlite(rusqlite::Error),
39 58
    pub updated_at: String,
40 59
}
41 60
61 +
impl Note {
62 +
    fn from_row(row: &Row) -> rusqlite::Result<Self> {
63 +
        Ok(Note {
64 +
            id: row.get(0)?,
65 +
            short_id: row.get(1)?,
66 +
            title: row.get(2)?,
67 +
            content: row.get(3)?,
68 +
            created_at: row.get(4)?,
69 +
            updated_at: row.get(5)?,
70 +
        })
71 +
    }
72 +
}
73 +
74 +
/// Incoming note payload from JSON/form requests. Shared by server and backend.
75 +
#[derive(Debug, Serialize, Deserialize)]
76 +
pub struct NoteInput {
77 +
    pub title: String,
78 +
    pub content: String,
79 +
}
80 +
42 81
pub fn init_db() -> Db {
43 82
    let path = std::env::var("JOTTS_DB_PATH").unwrap_or_else(|_| "jotts.sqlite".to_string());
44 83
    let conn = Connection::open(&path).expect("Failed to open database");
45 -
46 -
    conn.execute_batch(
47 -
        "CREATE TABLE IF NOT EXISTS notes (
48 -
            id         INTEGER PRIMARY KEY AUTOINCREMENT,
49 -
            short_id   TEXT NOT NULL UNIQUE,
50 -
            title      TEXT NOT NULL,
51 -
            content    TEXT NOT NULL,
52 -
            created_at TEXT NOT NULL DEFAULT (datetime('now')),
53 -
            updated_at TEXT NOT NULL DEFAULT (datetime('now'))
54 -
        );
55 -
56 -
        CREATE TABLE IF NOT EXISTS sessions (
57 -
            id         INTEGER PRIMARY KEY AUTOINCREMENT,
58 -
            token      TEXT NOT NULL UNIQUE,
59 -
            expires_at TEXT NOT NULL
60 -
        );"
61 -
    )
62 -
    .expect("Failed to create tables");
63 -
84 +
    conn.execute_batch(SCHEMA).expect("Failed to create tables");
64 85
    Arc::new(Mutex::new(conn))
65 86
}
66 87
73 94
    )?;
74 95
    let id = conn.last_insert_rowid();
75 96
    let note = conn.query_row(
76 -
        "SELECT id, short_id, title, content, created_at, updated_at FROM notes WHERE id = ?1",
97 +
        &format!("SELECT {NOTE_COLUMNS} FROM notes WHERE id = ?1"),
77 98
        params![id],
78 -
        |row| {
79 -
            Ok(Note {
80 -
                id: row.get(0)?,
81 -
                short_id: row.get(1)?,
82 -
                title: row.get(2)?,
83 -
                content: row.get(3)?,
84 -
                created_at: row.get(4)?,
85 -
                updated_at: row.get(5)?,
86 -
            })
87 -
        },
99 +
        Note::from_row,
88 100
    )?;
89 101
    Ok(note)
90 102
}
91 103
92 104
pub fn get_note_by_short_id(db: &Db, short_id: &str) -> Result<Option<Note>, DbError> {
93 105
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
94 -
    match conn.query_row(
95 -
        "SELECT id, short_id, title, content, created_at, updated_at FROM notes WHERE short_id = ?1",
96 -
        params![short_id],
97 -
        |row| {
98 -
            Ok(Note {
99 -
                id: row.get(0)?,
100 -
                short_id: row.get(1)?,
101 -
                title: row.get(2)?,
102 -
                content: row.get(3)?,
103 -
                created_at: row.get(4)?,
104 -
                updated_at: row.get(5)?,
105 -
            })
106 -
        },
107 -
    ) {
108 -
        Ok(note) => Ok(Some(note)),
109 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
110 -
        Err(e) => Err(DbError::Sqlite(e)),
111 -
    }
106 +
    let note = conn
107 +
        .query_row(
108 +
            &format!("SELECT {NOTE_COLUMNS} FROM notes WHERE short_id = ?1"),
109 +
            params![short_id],
110 +
            Note::from_row,
111 +
        )
112 +
        .optional()?;
113 +
    Ok(note)
112 114
}
113 115
114 116
pub fn get_all_notes(db: &Db) -> Result<Vec<Note>, DbError> {
115 117
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
116 -
    let mut stmt = conn.prepare(
117 -
        "SELECT id, short_id, title, content, created_at, updated_at FROM notes ORDER BY id DESC",
118 -
    )?;
118 +
    let mut stmt =
119 +
        conn.prepare(&format!("SELECT {NOTE_COLUMNS} FROM notes ORDER BY id DESC"))?;
119 120
    let notes = stmt
120 -
        .query_map([], |row| {
121 -
            Ok(Note {
122 -
                id: row.get(0)?,
123 -
                short_id: row.get(1)?,
124 -
                title: row.get(2)?,
125 -
                content: row.get(3)?,
126 -
                created_at: row.get(4)?,
127 -
                updated_at: row.get(5)?,
128 -
            })
129 -
        })?
121 +
        .query_map([], Note::from_row)?
130 122
        .collect::<Result<Vec<_>, _>>()?;
131 123
    Ok(notes)
132 124
}
145 137
    if rows == 0 {
146 138
        return Ok(None);
147 139
    }
148 -
    match conn.query_row(
149 -
        "SELECT id, short_id, title, content, created_at, updated_at FROM notes WHERE short_id = ?1",
150 -
        params![short_id],
151 -
        |row| {
152 -
            Ok(Note {
153 -
                id: row.get(0)?,
154 -
                short_id: row.get(1)?,
155 -
                title: row.get(2)?,
156 -
                content: row.get(3)?,
157 -
                created_at: row.get(4)?,
158 -
                updated_at: row.get(5)?,
159 -
            })
160 -
        },
161 -
    ) {
162 -
        Ok(note) => Ok(Some(note)),
163 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
164 -
        Err(e) => Err(DbError::Sqlite(e)),
165 -
    }
140 +
    let note = conn
141 +
        .query_row(
142 +
            &format!("SELECT {NOTE_COLUMNS} FROM notes WHERE short_id = ?1"),
143 +
            params![short_id],
144 +
            Note::from_row,
145 +
        )
146 +
        .optional()?;
147 +
    Ok(note)
166 148
}
167 149
168 150
pub fn delete_note_by_short_id(db: &Db, short_id: &str) -> Result<bool, DbError> {
219 201
220 202
    fn test_db() -> Db {
221 203
        let conn = Connection::open_in_memory().unwrap();
222 -
        conn.execute_batch(
223 -
            "CREATE TABLE IF NOT EXISTS notes (
224 -
                id         INTEGER PRIMARY KEY AUTOINCREMENT,
225 -
                short_id   TEXT NOT NULL UNIQUE,
226 -
                title      TEXT NOT NULL,
227 -
                content    TEXT NOT NULL,
228 -
                created_at TEXT NOT NULL DEFAULT (datetime('now')),
229 -
                updated_at TEXT NOT NULL DEFAULT (datetime('now'))
230 -
            );
231 -
            CREATE TABLE IF NOT EXISTS sessions (
232 -
                id         INTEGER PRIMARY KEY AUTOINCREMENT,
233 -
                token      TEXT NOT NULL UNIQUE,
234 -
                expires_at TEXT NOT NULL
235 -
            );",
236 -
        )
237 -
        .unwrap();
204 +
        conn.execute_batch(SCHEMA).unwrap();
238 205
        Arc::new(Mutex::new(conn))
239 206
    }
240 207
apps/jotts/src/server.rs +60 −102
12 12
use std::sync::Arc;
13 13
14 14
use crate::auth;
15 -
use crate::db::{self, Db, Note};
15 +
use crate::db::{self, Db, DbError, Note, NoteInput};
16 +
17 +
impl IntoResponse for DbError {
18 +
    fn into_response(self) -> Response {
19 +
        tracing::error!("{}", self);
20 +
        (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
21 +
    }
22 +
}
23 +
24 +
fn redirect_with_cookie(target: &str, cookie: String) -> Response {
25 +
    let mut resp = Redirect::to(target).into_response();
26 +
    resp.headers_mut().insert(
27 +
        axum::http::header::SET_COOKIE,
28 +
        HeaderValue::from_str(&cookie).unwrap(),
29 +
    );
30 +
    resp
31 +
}
16 32
17 33
#[derive(Clone)]
18 34
pub struct AppState {
76 92
    password: String,
77 93
}
78 94
79 -
#[derive(serde::Deserialize)]
80 -
struct NoteForm {
81 -
    title: String,
82 -
    content: String,
83 -
}
84 -
85 -
#[derive(serde::Deserialize)]
86 -
struct NoteJson {
87 -
    title: String,
88 -
    content: String,
89 -
}
90 -
91 95
// --- API key middleware ---
92 96
93 97
async fn api_key_guard(
117 121
118 122
// --- JSON API handlers ---
119 123
120 -
async fn api_list_notes(State(state): State<Arc<AppState>>) -> Response {
121 -
    match db::get_all_notes(&state.db) {
122 -
        Ok(notes) => Json(notes).into_response(),
123 -
        Err(e) => {
124 -
            tracing::error!("Failed to list notes: {}", e);
125 -
            (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
126 -
        }
127 -
    }
124 +
async fn api_list_notes(State(state): State<Arc<AppState>>) -> Result<Response, DbError> {
125 +
    Ok(Json(db::get_all_notes(&state.db)?).into_response())
128 126
}
129 127
130 128
async fn api_get_note(
131 129
    State(state): State<Arc<AppState>>,
132 130
    Path(short_id): Path<String>,
133 -
) -> Response {
134 -
    match db::get_note_by_short_id(&state.db, &short_id) {
135 -
        Ok(Some(note)) => Json(note).into_response(),
136 -
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
137 -
        Err(e) => {
138 -
            tracing::error!("Failed to get note: {}", e);
139 -
            (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
140 -
        }
141 -
    }
131 +
) -> Result<Response, DbError> {
132 +
    Ok(match db::get_note_by_short_id(&state.db, &short_id)? {
133 +
        Some(note) => Json(note).into_response(),
134 +
        None => StatusCode::NOT_FOUND.into_response(),
135 +
    })
142 136
}
143 137
144 138
async fn api_create_note(
145 139
    State(state): State<Arc<AppState>>,
146 -
    Json(body): Json<NoteJson>,
147 -
) -> Response {
140 +
    Json(body): Json<NoteInput>,
141 +
) -> Result<Response, DbError> {
148 142
    let title = body.title.trim();
149 143
    if title.is_empty() {
150 -
        return (StatusCode::BAD_REQUEST, "title required").into_response();
151 -
    }
152 -
    match db::create_note(&state.db, title, &body.content) {
153 -
        Ok(note) => (StatusCode::CREATED, Json(note)).into_response(),
154 -
        Err(e) => {
155 -
            tracing::error!("Failed to create note: {}", e);
156 -
            (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
157 -
        }
144 +
        return Ok((StatusCode::BAD_REQUEST, "title required").into_response());
158 145
    }
146 +
    let note = db::create_note(&state.db, title, &body.content)?;
147 +
    Ok((StatusCode::CREATED, Json(note)).into_response())
159 148
}
160 149
161 150
async fn api_update_note(
162 151
    State(state): State<Arc<AppState>>,
163 152
    Path(short_id): Path<String>,
164 -
    Json(body): Json<NoteJson>,
165 -
) -> Response {
153 +
    Json(body): Json<NoteInput>,
154 +
) -> Result<Response, DbError> {
166 155
    let title = body.title.trim();
167 156
    if title.is_empty() {
168 -
        return (StatusCode::BAD_REQUEST, "title required").into_response();
169 -
    }
170 -
    match db::update_note_by_short_id(&state.db, &short_id, title, &body.content) {
171 -
        Ok(Some(note)) => Json(note).into_response(),
172 -
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
173 -
        Err(e) => {
174 -
            tracing::error!("Failed to update note: {}", e);
175 -
            (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
176 -
        }
157 +
        return Ok((StatusCode::BAD_REQUEST, "title required").into_response());
177 158
    }
159 +
    Ok(
160 +
        match db::update_note_by_short_id(&state.db, &short_id, title, &body.content)? {
161 +
            Some(note) => Json(note).into_response(),
162 +
            None => StatusCode::NOT_FOUND.into_response(),
163 +
        },
164 +
    )
178 165
}
179 166
180 167
async fn api_delete_note(
181 168
    State(state): State<Arc<AppState>>,
182 169
    Path(short_id): Path<String>,
183 -
) -> Response {
184 -
    match db::delete_note_by_short_id(&state.db, &short_id) {
185 -
        Ok(true) => StatusCode::NO_CONTENT.into_response(),
186 -
        Ok(false) => StatusCode::NOT_FOUND.into_response(),
187 -
        Err(e) => {
188 -
            tracing::error!("Failed to delete note: {}", e);
189 -
            (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
190 -
        }
191 -
    }
170 +
) -> Result<Response, DbError> {
171 +
    Ok(match db::delete_note_by_short_id(&state.db, &short_id)? {
172 +
        true => StatusCode::NO_CONTENT.into_response(),
173 +
        false => StatusCode::NOT_FOUND.into_response(),
174 +
    })
192 175
}
193 176
194 177
// --- Static file handlers ---
249 232
        return Redirect::to("/login?error=Server+error").into_response();
250 233
    }
251 234
252 -
    let cookie = auth::build_session_cookie(&token, state.cookie_secure);
253 -
    let mut resp = Redirect::to("/").into_response();
254 -
    resp.headers_mut().insert(
255 -
        axum::http::header::SET_COOKIE,
256 -
        HeaderValue::from_str(&cookie).unwrap(),
257 -
    );
258 -
    resp
235 +
    redirect_with_cookie("/", auth::build_session_cookie(&token, state.cookie_secure))
259 236
}
260 237
261 238
async fn get_logout(State(state): State<Arc<AppState>>, headers: axum::http::HeaderMap) -> Response {
271 248
        }
272 249
    }
273 250
274 -
    let cookie = auth::clear_session_cookie();
275 -
    let mut resp = Redirect::to("/login").into_response();
276 -
    resp.headers_mut().insert(
277 -
        axum::http::header::SET_COOKIE,
278 -
        HeaderValue::from_str(&cookie).unwrap(),
279 -
    );
280 -
    resp
251 +
    redirect_with_cookie("/login", auth::clear_session_cookie())
281 252
}
282 253
283 254
// --- Note handlers ---
285 256
async fn get_index(
286 257
    _session: auth::AuthSession,
287 258
    State(state): State<Arc<AppState>>,
288 -
) -> Response {
289 -
    match db::get_all_notes(&state.db) {
290 -
        Ok(notes) => WebTemplate(IndexTemplate { notes }).into_response(),
291 -
        Err(e) => {
292 -
            tracing::error!("Failed to list notes: {}", e);
293 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
294 -
        }
295 -
    }
259 +
) -> Result<Response, DbError> {
260 +
    let notes = db::get_all_notes(&state.db)?;
261 +
    Ok(WebTemplate(IndexTemplate { notes }).into_response())
296 262
}
297 263
298 264
async fn get_new_note(
305 271
async fn post_create_note(
306 272
    _session: auth::AuthSession,
307 273
    State(state): State<Arc<AppState>>,
308 -
    Form(form): Form<NoteForm>,
274 +
    Form(form): Form<NoteInput>,
309 275
) -> Response {
310 276
    let title = form.title.trim();
311 277
    if title.is_empty() {
336 302
    _session: auth::AuthSession,
337 303
    State(state): State<Arc<AppState>>,
338 304
    Path(short_id): Path<String>,
339 -
) -> Response {
340 -
    match db::get_note_by_short_id(&state.db, &short_id) {
341 -
        Ok(Some(note)) => {
305 +
) -> Result<Response, DbError> {
306 +
    Ok(match db::get_note_by_short_id(&state.db, &short_id)? {
307 +
        Some(note) => {
342 308
            let rendered_content = render_markdown(&note.content);
343 309
            WebTemplate(ViewTemplate {
344 310
                note,
346 312
            })
347 313
            .into_response()
348 314
        }
349 -
        Ok(None) => (StatusCode::NOT_FOUND, Html("Note not found".to_string())).into_response(),
350 -
        Err(e) => {
351 -
            tracing::error!("Failed to get note: {}", e);
352 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
353 -
        }
354 -
    }
315 +
        None => (StatusCode::NOT_FOUND, Html("Note not found".to_string())).into_response(),
316 +
    })
355 317
}
356 318
357 319
async fn get_edit_note(
359 321
    State(state): State<Arc<AppState>>,
360 322
    Path(short_id): Path<String>,
361 323
    Query(q): Query<FlashQuery>,
362 -
) -> Response {
363 -
    match db::get_note_by_short_id(&state.db, &short_id) {
364 -
        Ok(Some(note)) => WebTemplate(EditTemplate {
324 +
) -> Result<Response, DbError> {
325 +
    Ok(match db::get_note_by_short_id(&state.db, &short_id)? {
326 +
        Some(note) => WebTemplate(EditTemplate {
365 327
            note,
366 328
            error: q.error,
367 329
        })
368 330
        .into_response(),
369 -
        Ok(None) => (StatusCode::NOT_FOUND, Html("Note not found".to_string())).into_response(),
370 -
        Err(e) => {
371 -
            tracing::error!("Failed to get note: {}", e);
372 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
373 -
        }
374 -
    }
331 +
        None => (StatusCode::NOT_FOUND, Html("Note not found".to_string())).into_response(),
332 +
    })
375 333
}
376 334
377 335
async fn post_update_note(
378 336
    _session: auth::AuthSession,
379 337
    State(state): State<Arc<AppState>>,
380 338
    Path(short_id): Path<String>,
381 -
    Form(form): Form<NoteForm>,
339 +
    Form(form): Form<NoteInput>,
382 340
) -> Response {
383 341
    let title = form.title.trim();
384 342
    if title.is_empty() {
apps/jotts/src/tui.rs +14 −1041
1 +
mod app;
2 +
mod editor;
3 +
mod events;
4 +
mod render;
5 +
1 6
use crate::backend::Backend;
2 7
use crate::config;
3 -
use crate::db::Note;
4 -
use crate::highlight::Highlighter;
8 +
use app::App;
5 9
use arboard::Clipboard;
6 -
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
7 -
use ratatui::{
8 -
    DefaultTerminal,
9 -
    layout::{Alignment, Constraint, Layout},
10 -
    style::{Color, Modifier, Style},
11 -
    text::{Line, Span, Text},
12 -
    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Widget, Wrap},
13 -
};
10 +
use crossterm::event::{self, Event};
11 +
use ratatui::DefaultTerminal;
14 12
use std::path::PathBuf;
15 -
use std::time::{Duration, Instant};
16 -
17 -
fn edit_in_external_editor(
18 -
    terminal: &mut DefaultTerminal,
19 -
    app: &mut App,
20 -
    backend: &Backend,
21 -
) -> Result<(), Box<dyn std::error::Error>> {
22 -
    let (short_id, title, content) = match app.selected_note() {
23 -
        Some(n) => (n.short_id.clone(), n.title.clone(), n.content.clone()),
24 -
        None => return Ok(()),
25 -
    };
26 -
27 -
    let editor = match std::env::var("EDITOR") {
28 -
        Ok(e) if !e.trim().is_empty() => e,
29 -
        _ => {
30 -
            app.status_message = Some(("EDITOR env not set".to_string(), Instant::now()));
31 -
            return Ok(());
32 -
        }
33 -
    };
34 -
35 -
    let mut path = std::env::temp_dir();
36 -
    path.push(format!("jotts-{}.md", short_id));
37 -
    std::fs::write(&path, &content)?;
38 -
39 -
    ratatui::restore();
40 -
41 -
    let status = std::process::Command::new(&editor).arg(&path).status();
42 -
43 -
    *terminal = ratatui::init();
44 -
    terminal.clear()?;
45 -
46 -
    match status {
47 -
        Ok(s) if s.success() => {
48 -
            let new_content = std::fs::read_to_string(&path)?;
49 -
            let _ = std::fs::remove_file(&path);
50 -
            if new_content == content {
51 -
                app.status_message = Some(("No changes".to_string(), Instant::now()));
52 -
                return Ok(());
53 -
            }
54 -
            match backend.update_note(&short_id, &title, &new_content) {
55 -
                Ok(Some(updated)) => {
56 -
                    if let Some(pos) = app.notes.iter().position(|n| n.short_id == short_id) {
57 -
                        app.notes[pos] = updated;
58 -
                    }
59 -
                    app.status_message = Some(("Updated!".to_string(), Instant::now()));
60 -
                }
61 -
                Ok(None) => {
62 -
                    app.status_message = Some(("Note not found".to_string(), Instant::now()));
63 -
                }
64 -
                Err(e) => {
65 -
                    app.status_message = Some((e.to_string(), Instant::now()));
66 -
                }
67 -
            }
68 -
        }
69 -
        Ok(_) => {
70 -
            let _ = std::fs::remove_file(&path);
71 -
            app.status_message = Some(("Editor exited non-zero".to_string(), Instant::now()));
72 -
        }
73 -
        Err(e) => {
74 -
            let _ = std::fs::remove_file(&path);
75 -
            app.status_message =
76 -
                Some((format!("Failed to launch editor: {}", e), Instant::now()));
77 -
        }
78 -
    }
79 -
    Ok(())
80 -
}
81 -
82 -
enum Focus {
83 -
    List,
84 -
    Content,
85 -
    CreateTitle,
86 -
    CreateContent,
87 -
    EditTitle,
88 -
    EditContent,
89 -
    Search,
90 -
}
91 -
92 -
struct App {
93 -
    notes: Vec<Note>,
94 -
    list_state: ListState,
95 -
    should_quit: bool,
96 -
    status_message: Option<(String, Instant)>,
97 -
    focus: Focus,
98 -
    content_scroll: u16,
99 -
    show_help: bool,
100 -
    confirm_delete: bool,
101 -
    highlighter: Highlighter,
102 -
    edit_title: String,
103 -
    edit_content: String,
104 -
    edit_short_id: Option<String>,
105 -
    search_query: String,
106 -
    filtered_indices: Option<Vec<usize>>,
107 -
    is_remote: bool,
108 -
    remote_url: Option<String>,
109 -
    wrap_content: bool,
110 -
    edit_scroll: u16,
111 -
}
112 -
113 -
impl App {
114 -
    fn new(notes: Vec<Note>, is_remote: bool, remote_url: Option<String>) -> Self {
115 -
        let mut list_state = ListState::default();
116 -
        if !notes.is_empty() {
117 -
            list_state.select(Some(0));
118 -
        }
119 -
        Self {
120 -
            notes,
121 -
            list_state,
122 -
            should_quit: false,
123 -
            status_message: None,
124 -
            focus: Focus::List,
125 -
            content_scroll: 0,
126 -
            show_help: false,
127 -
            confirm_delete: false,
128 -
            highlighter: Highlighter::new(),
129 -
            edit_title: String::new(),
130 -
            edit_content: String::new(),
131 -
            edit_short_id: None,
132 -
            search_query: String::new(),
133 -
            filtered_indices: None,
134 -
            is_remote,
135 -
            remote_url,
136 -
            wrap_content: true,
137 -
            edit_scroll: 0,
138 -
        }
139 -
    }
140 -
141 -
    fn selected_note(&self) -> Option<&Note> {
142 -
        self.list_state.selected().and_then(|i| {
143 -
            if let Some(indices) = &self.filtered_indices {
144 -
                indices.get(i).and_then(|&real| self.notes.get(real))
145 -
            } else {
146 -
                self.notes.get(i)
147 -
            }
148 -
        })
149 -
    }
150 -
151 -
    fn visible_count(&self) -> usize {
152 -
        match &self.filtered_indices {
153 -
            Some(indices) => indices.len(),
154 -
            None => self.notes.len(),
155 -
        }
156 -
    }
157 -
158 -
    fn move_up(&mut self) {
159 -
        let count = self.visible_count();
160 -
        if count == 0 {
161 -
            return;
162 -
        }
163 -
        let i = match self.list_state.selected() {
164 -
            Some(i) if i > 0 => i - 1,
165 -
            Some(_) => count - 1,
166 -
            None => 0,
167 -
        };
168 -
        self.list_state.select(Some(i));
169 -
        self.content_scroll = 0;
170 -
    }
171 -
172 -
    fn move_down(&mut self) {
173 -
        let count = self.visible_count();
174 -
        if count == 0 {
175 -
            return;
176 -
        }
177 -
        let i = match self.list_state.selected() {
178 -
            Some(i) if i < count - 1 => i + 1,
179 -
            Some(_) => 0,
180 -
            None => 0,
181 -
        };
182 -
        self.list_state.select(Some(i));
183 -
        self.content_scroll = 0;
184 -
    }
185 -
186 -
    fn scroll_up(&mut self) {
187 -
        self.content_scroll = self.content_scroll.saturating_sub(1);
188 -
    }
189 -
190 -
    fn scroll_down(&mut self, max_lines: u16) {
191 -
        if self.content_scroll < max_lines {
192 -
            self.content_scroll += 1;
193 -
        }
194 -
    }
195 -
196 -
    fn copy_selected(&mut self) {
197 -
        if let Some(note) = self.selected_note() {
198 -
            if let Ok(mut clipboard) = Clipboard::new() {
199 -
                let _ = clipboard.set_text(&note.content);
200 -
                self.status_message = Some(("Copied!".to_string(), Instant::now()));
201 -
            }
202 -
        }
203 -
    }
204 -
205 -
    fn copy_link(&mut self) {
206 -
        match &self.remote_url {
207 -
            Some(url) => {
208 -
                if let Some(note) = self.selected_note() {
209 -
                    let link = format!("{}/notes/{}", url.trim_end_matches('/'), note.short_id);
210 -
                    if let Ok(mut clipboard) = Clipboard::new() {
211 -
                        let _ = clipboard.set_text(&link);
212 -
                        self.status_message =
213 -
                            Some(("Link copied!".to_string(), Instant::now()));
214 -
                    }
215 -
                }
216 -
            }
217 -
            None => {
218 -
                self.status_message =
219 -
                    Some(("No remote URL configured".to_string(), Instant::now()));
220 -
            }
221 -
        }
222 -
    }
223 -
224 -
    fn open_in_browser(&mut self) {
225 -
        match &self.remote_url {
226 -
            Some(url) => {
227 -
                if let Some(note) = self.selected_note() {
228 -
                    let link = format!("{}/notes/{}", url.trim_end_matches('/'), note.short_id);
229 -
                    if let Err(e) = open::that(&link) {
230 -
                        self.status_message =
231 -
                            Some((format!("Failed to open browser: {}", e), Instant::now()));
232 -
                    } else {
233 -
                        self.status_message =
234 -
                            Some(("Opened in browser!".to_string(), Instant::now()));
235 -
                    }
236 -
                }
237 -
            }
238 -
            None => {
239 -
                self.status_message =
240 -
                    Some(("No remote URL configured".to_string(), Instant::now()));
241 -
            }
242 -
        }
243 -
    }
244 -
245 -
    fn delete_selected(&mut self, backend: &Backend) {
246 -
        if let Some(selected_index) = self.list_state.selected() {
247 -
            let real_index = if let Some(indices) = &self.filtered_indices {
248 -
                match indices.get(selected_index) {
249 -
                    Some(&ri) => ri,
250 -
                    None => return,
251 -
                }
252 -
            } else {
253 -
                selected_index
254 -
            };
255 -
            if let Some(note) = self.notes.get(real_index) {
256 -
                let short_id = note.short_id.clone();
257 -
                match backend.delete_note(&short_id) {
258 -
                    Ok(true) => {
259 -
                        self.notes.remove(real_index);
260 -
                        if self.filtered_indices.is_some() {
261 -
                            self.update_search_filter();
262 -
                        }
263 -
                        let count = self.visible_count();
264 -
                        if count == 0 {
265 -
                            self.list_state.select(None);
266 -
                        } else if selected_index >= count {
267 -
                            self.list_state.select(Some(count - 1));
268 -
                        } else {
269 -
                            self.list_state.select(Some(selected_index));
270 -
                        }
271 -
                        self.status_message = Some(("Deleted!".to_string(), Instant::now()));
272 -
                    }
273 -
                    Ok(false) => {
274 -
                        self.status_message =
275 -
                            Some(("Note not found".to_string(), Instant::now()));
276 -
                    }
277 -
                    Err(e) => {
278 -
                        self.status_message = Some((e.to_string(), Instant::now()));
279 -
                    }
280 -
                }
281 -
            }
282 -
        }
283 -
    }
284 -
285 -
    fn refresh(&mut self, backend: &Backend) {
286 -
        match backend.list_notes() {
287 -
            Ok(notes) => {
288 -
                self.notes = notes;
289 -
                self.filtered_indices = None;
290 -
                self.search_query.clear();
291 -
                if self.notes.is_empty() {
292 -
                    self.list_state.select(None);
293 -
                } else {
294 -
                    let idx = self.list_state.selected().unwrap_or(0);
295 -
                    if idx >= self.notes.len() {
296 -
                        self.list_state.select(Some(self.notes.len() - 1));
297 -
                    }
298 -
                }
299 -
                self.status_message = Some(("Refreshed!".to_string(), Instant::now()));
300 -
            }
301 -
            Err(e) => {
302 -
                self.status_message = Some((e.to_string(), Instant::now()));
303 -
            }
304 -
        }
305 -
    }
306 -
307 -
    fn cursor_position_wrapped(&self, width: u16) -> (u16, u16) {
308 -
        let w = width as usize;
309 -
        if w == 0 {
310 -
            return (0, 0);
311 -
        }
312 -
        let text = &self.edit_content;
313 -
        let mut visual_row: usize = 0;
314 -
        let lines: Vec<&str> = if text.is_empty() {
315 -
            vec![""]
316 -
        } else {
317 -
            text.split('\n').collect()
318 -
        };
319 -
        let last_idx = lines.len() - 1;
320 -
        for (i, line) in lines.iter().enumerate() {
321 -
            let line_len = line.len();
322 -
            let wrapped_lines = if line_len == 0 {
323 -
                1
324 -
            } else {
325 -
                (line_len + w - 1) / w
326 -
            };
327 -
            if i < last_idx {
328 -
                visual_row += wrapped_lines;
329 -
            } else {
330 -
                let cursor_col = if text.ends_with('\n') { 0 } else { line_len };
331 -
                let extra_rows = cursor_col / w;
332 -
                let col = cursor_col % w;
333 -
                visual_row += extra_rows;
334 -
                return (col as u16, visual_row as u16);
335 -
            }
336 -
        }
337 -
        (0, visual_row as u16)
338 -
    }
339 -
340 -
    fn auto_scroll_edit(&mut self, cursor_visual_row: u16, visible_height: u16) {
341 -
        if visible_height == 0 {
342 -
            return;
343 -
        }
344 -
        if cursor_visual_row < self.edit_scroll {
345 -
            self.edit_scroll = cursor_visual_row;
346 -
        } else if cursor_visual_row >= self.edit_scroll + visible_height {
347 -
            self.edit_scroll = cursor_visual_row - visible_height + 1;
348 -
        }
349 -
    }
350 -
351 -
    fn start_create(&mut self) {
352 -
        self.edit_title.clear();
353 -
        self.edit_content.clear();
354 -
        self.edit_scroll = 0;
355 -
        self.focus = Focus::CreateTitle;
356 -
    }
357 -
358 -
    fn save_create(&mut self, backend: &Backend) {
359 -
        if self.edit_title.trim().is_empty() {
360 -
            self.status_message = Some(("Title cannot be empty".to_string(), Instant::now()));
361 -
            return;
362 -
        }
363 -
        match backend.create_note(&self.edit_title, &self.edit_content) {
364 -
            Ok(note) => {
365 -
                self.notes.insert(0, note);
366 -
                self.list_state.select(Some(0));
367 -
                self.filtered_indices = None;
368 -
                self.search_query.clear();
369 -
                self.status_message = Some(("Created!".to_string(), Instant::now()));
370 -
                self.focus = Focus::List;
371 -
                self.edit_title.clear();
372 -
                self.edit_content.clear();
373 -
            }
374 -
            Err(e) => {
375 -
                self.status_message = Some((e.to_string(), Instant::now()));
376 -
            }
377 -
        }
378 -
    }
379 -
380 -
    fn cancel_create(&mut self) {
381 -
        self.edit_title.clear();
382 -
        self.edit_content.clear();
383 -
        self.focus = Focus::List;
384 -
    }
385 -
386 -
    fn start_edit(&mut self) {
387 -
        let data = self
388 -
            .selected_note()
389 -
            .map(|n| (n.title.clone(), n.content.clone(), n.short_id.clone()));
390 -
        if let Some((title, content, short_id)) = data {
391 -
            self.edit_title = title;
392 -
            self.edit_content = content;
393 -
            self.edit_short_id = Some(short_id);
394 -
            self.edit_scroll = 0;
395 -
            self.focus = Focus::EditTitle;
396 -
        }
397 -
    }
398 -
399 -
    fn save_edit(&mut self, backend: &Backend) {
400 -
        if self.edit_title.trim().is_empty() {
401 -
            self.status_message = Some(("Title cannot be empty".to_string(), Instant::now()));
402 -
            return;
403 -
        }
404 -
        let short_id = match &self.edit_short_id {
405 -
            Some(id) => id.clone(),
406 -
            None => return,
407 -
        };
408 -
        match backend.update_note(&short_id, &self.edit_title, &self.edit_content) {
409 -
            Ok(Some(updated)) => {
410 -
                if let Some(pos) = self.notes.iter().position(|n| n.short_id == short_id) {
411 -
                    self.notes[pos] = updated;
412 -
                }
413 -
                self.status_message = Some(("Updated!".to_string(), Instant::now()));
414 -
                self.focus = Focus::List;
415 -
                self.edit_title.clear();
416 -
                self.edit_content.clear();
417 -
                self.edit_short_id = None;
418 -
            }
419 -
            Ok(None) => {
420 -
                self.status_message = Some(("Note not found".to_string(), Instant::now()));
421 -
            }
422 -
            Err(e) => {
423 -
                self.status_message = Some((e.to_string(), Instant::now()));
424 -
            }
425 -
        }
426 -
    }
427 -
428 -
    fn cancel_edit(&mut self) {
429 -
        self.edit_title.clear();
430 -
        self.edit_content.clear();
431 -
        self.edit_short_id = None;
432 -
        self.focus = Focus::List;
433 -
    }
434 -
435 -
    fn start_search(&mut self) {
436 -
        self.search_query.clear();
437 -
        self.filtered_indices = Some((0..self.notes.len()).collect());
438 -
        self.focus = Focus::Search;
439 -
        self.list_state
440 -
            .select(if self.notes.is_empty() { None } else { Some(0) });
441 -
    }
442 -
443 -
    fn update_search_filter(&mut self) {
444 -
        let query = self.search_query.to_lowercase();
445 -
        let indices: Vec<usize> = self
446 -
            .notes
447 -
            .iter()
448 -
            .enumerate()
449 -
            .filter(|(_, n)| n.title.to_lowercase().contains(&query))
450 -
            .map(|(i, _)| i)
451 -
            .collect();
452 -
        self.filtered_indices = Some(indices);
453 -
        if self.visible_count() == 0 {
454 -
            self.list_state.select(None);
455 -
        } else {
456 -
            self.list_state.select(Some(0));
457 -
        }
458 -
    }
459 -
460 -
    fn cancel_search(&mut self) {
461 -
        self.filtered_indices = None;
462 -
        self.search_query.clear();
463 -
        self.focus = Focus::List;
464 -
    }
465 -
466 -
    fn confirm_search(&mut self) {
467 -
        let real_index = self.list_state.selected().and_then(|i| {
468 -
            self.filtered_indices
469 -
                .as_ref()
470 -
                .and_then(|indices| indices.get(i).copied())
471 -
        });
472 -
        self.filtered_indices = None;
473 -
        self.search_query.clear();
474 -
        self.focus = Focus::List;
475 -
        if let Some(ri) = real_index {
476 -
            self.list_state.select(Some(ri));
477 -
        }
478 -
    }
479 -
480 -
    fn clear_expired_status(&mut self) {
481 -
        if let Some((_, time)) = &self.status_message {
482 -
            if time.elapsed() > Duration::from_secs(2) {
483 -
                self.status_message = None;
484 -
            }
485 -
        }
486 -
    }
487 -
}
13 +
use std::time::Duration;
488 14
489 15
fn db_path() -> String {
490 16
    std::env::var("JOTTS_DB_PATH").unwrap_or_else(|_| "jotts.sqlite".to_string())
605 131
            .map(|n| n.content.lines().count() as u16)
606 132
            .unwrap_or(0);
607 133
608 -
        terminal.draw(|frame| {
609 -
            let outer = Layout::vertical([Constraint::Min(1), Constraint::Length(1)])
610 -
                .split(frame.area());
611 -
612 -
            let chunks = Layout::horizontal([
613 -
                Constraint::Percentage(30),
614 -
                Constraint::Percentage(70),
615 -
            ])
616 -
            .split(outer[0]);
617 -
618 -
            let items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices {
619 -
                indices
620 -
                    .iter()
621 -
                    .filter_map(|&i| app.notes.get(i))
622 -
                    .map(|n| ListItem::new(n.title.as_str()))
623 -
                    .collect()
624 -
            } else {
625 -
                app.notes
626 -
                    .iter()
627 -
                    .map(|n| ListItem::new(n.title.as_str()))
628 -
                    .collect()
629 -
            };
630 -
631 -
            let list_border_style = match app.focus {
632 -
                Focus::List | Focus::Search => Style::default().fg(Color::Yellow),
633 -
                _ => Style::default().fg(Color::DarkGray),
634 -
            };
635 -
            let content_border_style = match app.focus {
636 -
                Focus::Content => Style::default().fg(Color::Yellow),
637 -
                _ => Style::default().fg(Color::DarkGray),
638 -
            };
134 +
        terminal.draw(|frame| render::draw(frame, &mut app))?;
639 135
640 -
            let list = List::new(items)
641 -
                .block(
642 -
                    Block::default()
643 -
                        .title(" Notes ")
644 -
                        .borders(Borders::ALL)
645 -
                        .border_style(list_border_style),
646 -
                )
647 -
                .highlight_style(
648 -
                    Style::default()
649 -
                        .fg(Color::Yellow)
650 -
                        .add_modifier(Modifier::BOLD),
651 -
                )
652 -
                .highlight_symbol("▶ ");
653 -
654 -
            if matches!(app.focus, Focus::Search) {
655 -
                let search_split =
656 -
                    Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(chunks[0]);
657 -
658 -
                let search_items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices {
659 -
                    indices
660 -
                        .iter()
661 -
                        .filter_map(|&i| app.notes.get(i))
662 -
                        .map(|n| ListItem::new(n.title.as_str()))
663 -
                        .collect()
664 -
                } else {
665 -
                    app.notes
666 -
                        .iter()
667 -
                        .map(|n| ListItem::new(n.title.as_str()))
668 -
                        .collect()
669 -
                };
670 -
                let search_list = List::new(search_items)
671 -
                    .block(
672 -
                        Block::default()
673 -
                            .title(" Notes ")
674 -
                            .borders(Borders::ALL)
675 -
                            .border_style(list_border_style),
676 -
                    )
677 -
                    .highlight_style(
678 -
                        Style::default()
679 -
                            .fg(Color::Yellow)
680 -
                            .add_modifier(Modifier::BOLD),
681 -
                    )
682 -
                    .highlight_symbol("▶ ");
683 -
                frame.render_stateful_widget(search_list, search_split[0], &mut app.list_state);
684 -
685 -
                let search_input = Paragraph::new(app.search_query.as_str()).block(
686 -
                    Block::default()
687 -
                        .title(" Search ")
688 -
                        .borders(Borders::ALL)
689 -
                        .border_style(Style::default().fg(Color::Yellow)),
690 -
                );
691 -
                frame.render_widget(search_input, search_split[1]);
692 -
693 -
                let x = search_split[1].x + 1 + app.search_query.len() as u16;
694 -
                let y = search_split[1].y + 1;
695 -
                frame.set_cursor_position((x, y));
696 -
            } else {
697 -
                frame.render_stateful_widget(list, chunks[0], &mut app.list_state);
698 -
            }
699 -
700 -
            match app.focus {
701 -
                Focus::CreateTitle
702 -
                | Focus::CreateContent
703 -
                | Focus::EditTitle
704 -
                | Focus::EditContent => {
705 -
                    let form_title = match app.focus {
706 -
                        Focus::EditTitle | Focus::EditContent => " Edit Note ",
707 -
                        _ => " New Note ",
708 -
                    };
709 -
                    let create_block = Block::default()
710 -
                        .title(form_title)
711 -
                        .borders(Borders::ALL)
712 -
                        .border_style(Style::default().fg(Color::Yellow));
713 -
714 -
                    let inner = create_block.inner(chunks[1]);
715 -
                    frame.render_widget(create_block, chunks[1]);
716 -
717 -
                    let form_layout =
718 -
                        Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(inner);
719 -
720 -
                    let title_style = match app.focus {
721 -
                        Focus::CreateTitle | Focus::EditTitle => Style::default().fg(Color::Yellow),
722 -
                        _ => Style::default().fg(Color::DarkGray),
723 -
                    };
724 -
                    let title_input = Paragraph::new(app.edit_title.as_str()).block(
725 -
                        Block::default()
726 -
                            .title(" Title ")
727 -
                            .borders(Borders::ALL)
728 -
                            .border_style(title_style),
729 -
                    );
730 -
                    frame.render_widget(title_input, form_layout[0]);
731 -
732 -
                    let content_style = match app.focus {
733 -
                        Focus::CreateContent | Focus::EditContent => {
734 -
                            Style::default().fg(Color::Yellow)
735 -
                        }
736 -
                        _ => Style::default().fg(Color::DarkGray),
737 -
                    };
738 -
                    let mut content_input = Paragraph::new(app.edit_content.as_str()).block(
739 -
                        Block::default()
740 -
                            .title(" Content ")
741 -
                            .borders(Borders::ALL)
742 -
                            .border_style(content_style),
743 -
                    );
744 -
                    if app.wrap_content {
745 -
                        content_input = content_input.wrap(Wrap { trim: false });
746 -
                    }
747 -
                    content_input = content_input.scroll((app.edit_scroll, 0));
748 -
                    frame.render_widget(content_input, form_layout[1]);
749 -
750 -
                    let content_inner =
751 -
                        Block::default().borders(Borders::ALL).inner(form_layout[1]);
752 -
                    let inner_width = content_inner.width;
753 -
                    let inner_height = content_inner.height;
754 -
755 -
                    match app.focus {
756 -
                        Focus::CreateTitle | Focus::EditTitle => {
757 -
                            let x = form_layout[0].x + 1 + app.edit_title.len() as u16;
758 -
                            let y = form_layout[0].y + 1;
759 -
                            frame.set_cursor_position((x, y));
760 -
                        }
761 -
                        Focus::CreateContent | Focus::EditContent => {
762 -
                            let (cx, cy) = if app.wrap_content {
763 -
                                app.cursor_position_wrapped(inner_width)
764 -
                            } else {
765 -
                                let last_line = app.edit_content.lines().last().unwrap_or("");
766 -
                                let line_count = app.edit_content.lines().count()
767 -
                                    + if app.edit_content.ends_with('\n') { 1 } else { 0 };
768 -
                                let y_offset = if line_count == 0 { 0 } else { line_count - 1 };
769 -
                                let col = if app.edit_content.ends_with('\n') {
770 -
                                    0
771 -
                                } else {
772 -
                                    last_line.len() as u16
773 -
                                };
774 -
                                (col, y_offset as u16)
775 -
                            };
776 -
                            app.auto_scroll_edit(cy, inner_height);
777 -
                            let screen_y = cy.saturating_sub(app.edit_scroll);
778 -
                            let x = content_inner.x + cx;
779 -
                            let y = content_inner.y + screen_y;
780 -
                            frame.set_cursor_position((x, y));
781 -
                        }
782 -
                        _ => {}
783 -
                    }
784 -
                }
785 -
                _ => {
786 -
                    let highlighted = match app.selected_note() {
787 -
                        Some(n) => app.highlighter.highlight_markdown(&n.content),
788 -
                        None => Text::raw(""),
789 -
                    };
790 -
791 -
                    let paragraph = Paragraph::new(highlighted)
792 -
                        .block(
793 -
                            Block::default()
794 -
                                .title(" Content ")
795 -
                                .borders(Borders::ALL)
796 -
                                .border_style(content_border_style),
797 -
                        )
798 -
                        .scroll((app.content_scroll, 0));
799 -
800 -
                    frame.render_widget(paragraph, chunks[1]);
801 -
                }
802 -
            }
803 -
804 -
            let hints = match app.focus {
805 -
                Focus::List => Line::from(vec![
806 -
                    Span::styled("j/k", Style::default().fg(Color::Yellow)),
807 -
                    Span::raw(": Navigate  "),
808 -
                    Span::styled("Enter", Style::default().fg(Color::Yellow)),
809 -
                    Span::raw(": View  "),
810 -
                    Span::styled("y", Style::default().fg(Color::Yellow)),
811 -
                    Span::raw(": Copy  "),
812 -
                    Span::styled("e", Style::default().fg(Color::Yellow)),
813 -
                    Span::raw(": Edit  "),
814 -
                    Span::styled("d", Style::default().fg(Color::Yellow)),
815 -
                    Span::raw(": Delete  "),
816 -
                    Span::styled("c", Style::default().fg(Color::Yellow)),
817 -
                    Span::raw(": Create  "),
818 -
                    Span::styled("/", Style::default().fg(Color::Yellow)),
819 -
                    Span::raw(": Search  "),
820 -
                    Span::styled("?", Style::default().fg(Color::Yellow)),
821 -
                    Span::raw(": Help  "),
822 -
                    Span::styled("q", Style::default().fg(Color::Yellow)),
823 -
                    Span::raw(": Quit"),
824 -
                ]),
825 -
                Focus::Content => Line::from(vec![
826 -
                    Span::styled("j/k", Style::default().fg(Color::Yellow)),
827 -
                    Span::raw(": Scroll  "),
828 -
                    Span::styled("y", Style::default().fg(Color::Yellow)),
829 -
                    Span::raw(": Copy  "),
830 -
                    Span::styled("e", Style::default().fg(Color::Yellow)),
831 -
                    Span::raw(": Edit  "),
832 -
                    Span::styled("Esc", Style::default().fg(Color::Yellow)),
833 -
                    Span::raw(": Back  "),
834 -
                    Span::styled("?", Style::default().fg(Color::Yellow)),
835 -
                    Span::raw(": Help"),
836 -
                ]),
837 -
                Focus::CreateTitle
838 -
                | Focus::CreateContent
839 -
                | Focus::EditTitle
840 -
                | Focus::EditContent => Line::from(vec![
841 -
                    Span::styled("Tab", Style::default().fg(Color::Yellow)),
842 -
                    Span::raw(": Switch field  "),
843 -
                    Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
844 -
                    Span::raw(": Save  "),
845 -
                    Span::styled("Ctrl+W", Style::default().fg(Color::Yellow)),
846 -
                    Span::raw(": Wrap  "),
847 -
                    Span::styled("Esc", Style::default().fg(Color::Yellow)),
848 -
                    Span::raw(": Cancel"),
849 -
                ]),
850 -
                Focus::Search => Line::from(vec![
851 -
                    Span::styled("Type", Style::default().fg(Color::Yellow)),
852 -
                    Span::raw(": Filter  "),
853 -
                    Span::styled("Enter", Style::default().fg(Color::Yellow)),
854 -
                    Span::raw(": Select  "),
855 -
                    Span::styled("Esc", Style::default().fg(Color::Yellow)),
856 -
                    Span::raw(": Cancel"),
857 -
                ]),
858 -
            };
859 -
            frame.render_widget(Paragraph::new(hints), outer[1]);
860 -
861 -
            if let Some((msg, _)) = &app.status_message {
862 -
                let area = frame.area();
863 -
                let msg_width = (msg.len() as u16 + 4).max(20).min(area.width.saturating_sub(4));
864 -
                let popup_area = ratatui::layout::Rect {
865 -
                    x: (area.width.saturating_sub(msg_width)) / 2,
866 -
                    y: (area.height.saturating_sub(3)) / 2,
867 -
                    width: msg_width,
868 -
                    height: 3,
869 -
                };
870 -
                Clear.render(popup_area, frame.buffer_mut());
871 -
                let status_popup = Paragraph::new(Line::from(msg.as_str()))
872 -
                    .style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))
873 -
                    .alignment(Alignment::Center)
874 -
                    .block(
875 -
                        Block::default()
876 -
                            .borders(Borders::ALL)
877 -
                            .border_style(Style::default().fg(Color::Green)),
878 -
                    );
879 -
                frame.render_widget(status_popup, popup_area);
880 -
            }
881 -
882 -
            if app.confirm_delete {
883 -
                let delete_msg = match app.selected_note() {
884 -
                    Some(n) => format!("Delete {}? (y/n)", n.title),
885 -
                    None => "Delete note? (y/n)".to_string(),
886 -
                };
887 -
                let area = frame.area();
888 -
                let msg_width = (delete_msg.len() as u16 + 4).max(24).min(area.width.saturating_sub(4));
889 -
                let popup_area = ratatui::layout::Rect {
890 -
                    x: (area.width.saturating_sub(msg_width)) / 2,
891 -
                    y: (area.height.saturating_sub(3)) / 2,
892 -
                    width: msg_width,
893 -
                    height: 3,
894 -
                };
895 -
                Clear.render(popup_area, frame.buffer_mut());
896 -
                let confirm_popup = Paragraph::new(Line::from(delete_msg))
897 -
                    .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
898 -
                    .alignment(Alignment::Center)
899 -
                    .block(
900 -
                        Block::default()
901 -
                            .borders(Borders::ALL)
902 -
                            .border_style(Style::default().fg(Color::Red)),
903 -
                    );
904 -
                frame.render_widget(confirm_popup, popup_area);
905 -
            }
906 -
907 -
            if app.show_help {
908 -
                let area = frame.area();
909 -
                let popup_width = 34u16.min(area.width.saturating_sub(4));
910 -
                let popup_height = 21u16.min(area.height.saturating_sub(4));
911 -
                let popup_area = ratatui::layout::Rect {
912 -
                    x: (area.width.saturating_sub(popup_width)) / 2,
913 -
                    y: (area.height.saturating_sub(popup_height)) / 2,
914 -
                    width: popup_width,
915 -
                    height: popup_height,
916 -
                };
917 -
918 -
                let mut help_lines = vec![
919 -
                    Line::from(""),
920 -
                    Line::from(vec![
921 -
                        Span::styled("  j/↓  ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
922 -
                        Span::raw("Move down / Scroll down"),
923 -
                    ]),
924 -
                    Line::from(vec![
925 -
                        Span::styled("  k/↑  ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
926 -
                        Span::raw("Move up / Scroll up"),
927 -
                    ]),
928 -
                    Line::from(vec![
929 -
                        Span::styled("  Enter", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
930 -
                        Span::raw("  Focus content pane"),
931 -
                    ]),
932 -
                    Line::from(vec![
933 -
                        Span::styled("  Esc  ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
934 -
                        Span::raw("Back / Quit"),
935 -
                    ]),
936 -
                    Line::from(vec![
937 -
                        Span::styled("  y    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
938 -
                        Span::raw("Copy note"),
939 -
                    ]),
940 -
                    Line::from(vec![
941 -
                        Span::styled("  Y    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
942 -
                        Span::raw("Copy link"),
943 -
                    ]),
944 -
                    Line::from(vec![
945 -
                        Span::styled("  o    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
946 -
                        Span::raw("Open in browser"),
947 -
                    ]),
948 -
                    Line::from(vec![
949 -
                        Span::styled("  d    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
950 -
                        Span::raw("Delete note"),
951 -
                    ]),
952 -
                    Line::from(vec![
953 -
                        Span::styled("  c    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
954 -
                        Span::raw("Create note"),
955 -
                    ]),
956 -
                    Line::from(vec![
957 -
                        Span::styled("  e    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
958 -
                        Span::raw("Edit note"),
959 -
                    ]),
960 -
                    Line::from(vec![
961 -
                        Span::styled("  E    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
962 -
                        Span::raw("Edit in $EDITOR"),
963 -
                    ]),
964 -
                    Line::from(vec![
965 -
                        Span::styled("  /    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
966 -
                        Span::raw("Search notes"),
967 -
                    ]),
968 -
                    Line::from(vec![
969 -
                        Span::styled("  ^W   ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
970 -
                        Span::raw("Toggle word wrap (edit)"),
971 -
                    ]),
972 -
                ];
973 -
974 -
                if app.is_remote {
975 -
                    help_lines.push(Line::from(vec![
976 -
                        Span::styled("  r    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
977 -
                        Span::raw("Refresh notes"),
978 -
                    ]));
979 -
                }
980 -
981 -
                help_lines.extend([
982 -
                    Line::from(vec![
983 -
                        Span::styled("  q    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
984 -
                        Span::raw("Quit"),
985 -
                    ]),
986 -
                    Line::from(vec![
987 -
                        Span::styled("  ?    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
988 -
                        Span::raw("Toggle this help"),
989 -
                    ]),
990 -
                    Line::from(""),
991 -
                    Line::from(Span::styled(
992 -
                        "  Press any key to close",
993 -
                        Style::default().fg(Color::DarkGray),
994 -
                    )),
995 -
                ]);
996 -
997 -
                let help_text = Text::from(help_lines);
998 -
999 -
                Clear.render(popup_area, frame.buffer_mut());
1000 -
                let help = Paragraph::new(help_text).block(
1001 -
                    Block::default()
1002 -
                        .title(" Keybindings ")
1003 -
                        .borders(Borders::ALL)
1004 -
                        .border_style(Style::default().fg(Color::Yellow)),
1005 -
                );
1006 -
                frame.render_widget(help, popup_area);
1007 -
            }
1008 -
        })?;
1009 -
1010 -
        if event::poll(Duration::from_millis(100))? {
1011 -
            if let Event::Key(key) = event::read()? {
1012 -
                if app.show_help {
1013 -
                    app.show_help = false;
1014 -
                } else if app.status_message.is_some() {
1015 -
                    app.status_message = None;
1016 -
                } else if app.confirm_delete {
1017 -
                    if key.code == KeyCode::Char('y') {
1018 -
                        app.delete_selected(backend);
1019 -
                    }
1020 -
                    app.confirm_delete = false;
1021 -
                } else {
1022 -
                    match app.focus {
1023 -
                        Focus::List => match key.code {
1024 -
                            KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
1025 -
                            KeyCode::Char('j') | KeyCode::Down => app.move_down(),
1026 -
                            KeyCode::Char('k') | KeyCode::Up => app.move_up(),
1027 -
                            KeyCode::Char('y') => app.copy_selected(),
1028 -
                            KeyCode::Char('Y') => app.copy_link(),
1029 -
                            KeyCode::Char('d') => app.confirm_delete = true,
1030 -
                            KeyCode::Char('c') => app.start_create(),
1031 -
                            KeyCode::Char('e') => app.start_edit(),
1032 -
                            KeyCode::Char('E') => {
1033 -
                                edit_in_external_editor(terminal, &mut app, backend)?
1034 -
                            }
1035 -
                            KeyCode::Char('/') => app.start_search(),
1036 -
                            KeyCode::Char('o') => app.open_in_browser(),
1037 -
                            KeyCode::Char('r') if app.is_remote => app.refresh(backend),
1038 -
                            KeyCode::Char('?') => app.show_help = true,
1039 -
                            KeyCode::Enter | KeyCode::Char('l') => {
1040 -
                                if app.selected_note().is_some() {
1041 -
                                    app.focus = Focus::Content;
1042 -
                                }
1043 -
                            }
1044 -
                            _ => {}
1045 -
                        },
1046 -
                        Focus::Content => match key.code {
1047 -
                            KeyCode::Char(' ')
1048 -
                            | KeyCode::Esc
1049 -
                            | KeyCode::Char('q')
1050 -
                            | KeyCode::Char('h') => {
1051 -
                                app.focus = Focus::List;
1052 -
                            }
1053 -
                            KeyCode::Char('j') | KeyCode::Down => {
1054 -
                                app.scroll_down(content_line_count);
1055 -
                            }
1056 -
                            KeyCode::Char('k') | KeyCode::Up => app.scroll_up(),
1057 -
                            KeyCode::Char('y') => app.copy_selected(),
1058 -
                            KeyCode::Char('Y') => app.copy_link(),
1059 -
                            KeyCode::Char('e') => app.start_edit(),
1060 -
                            KeyCode::Char('E') => {
1061 -
                                edit_in_external_editor(terminal, &mut app, backend)?
1062 -
                            }
1063 -
                            KeyCode::Char('o') => app.open_in_browser(),
1064 -
                            KeyCode::Char('?') => app.show_help = true,
1065 -
                            _ => {}
1066 -
                        },
1067 -
                        Focus::CreateTitle => {
1068 -
                            if key.modifiers.contains(KeyModifiers::CONTROL)
1069 -
                                && key.code == KeyCode::Char('s')
1070 -
                            {
1071 -
                                app.save_create(backend);
1072 -
                            } else {
1073 -
                                match key.code {
1074 -
                                    KeyCode::Esc => app.cancel_create(),
1075 -
                                    KeyCode::Enter | KeyCode::Tab => {
1076 -
                                        app.focus = Focus::CreateContent
1077 -
                                    }
1078 -
                                    KeyCode::Backspace => {
1079 -
                                        app.edit_title.pop();
1080 -
                                    }
1081 -
                                    KeyCode::Char(c) => app.edit_title.push(c),
1082 -
                                    _ => {}
1083 -
                                }
1084 -
                            }
1085 -
                        }
1086 -
                        Focus::CreateContent => {
1087 -
                            if key.modifiers.contains(KeyModifiers::CONTROL) {
1088 -
                                match key.code {
1089 -
                                    KeyCode::Char('s') => app.save_create(backend),
1090 -
                                    KeyCode::Char('w') => {
1091 -
                                        app.wrap_content = !app.wrap_content;
1092 -
                                        app.edit_scroll = 0;
1093 -
                                    }
1094 -
                                    _ => {}
1095 -
                                }
1096 -
                            } else {
1097 -
                                match key.code {
1098 -
                                    KeyCode::Esc => app.cancel_create(),
1099 -
                                    KeyCode::Tab => app.focus = Focus::CreateTitle,
1100 -
                                    KeyCode::Enter => app.edit_content.push('\n'),
1101 -
                                    KeyCode::Backspace => {
1102 -
                                        app.edit_content.pop();
1103 -
                                    }
1104 -
                                    KeyCode::Char(c) => app.edit_content.push(c),
1105 -
                                    _ => {}
1106 -
                                }
1107 -
                            }
1108 -
                        }
1109 -
                        Focus::EditTitle => {
1110 -
                            if key.modifiers.contains(KeyModifiers::CONTROL)
1111 -
                                && key.code == KeyCode::Char('s')
1112 -
                            {
1113 -
                                app.save_edit(backend);
1114 -
                            } else {
1115 -
                                match key.code {
1116 -
                                    KeyCode::Esc => app.cancel_edit(),
1117 -
                                    KeyCode::Enter | KeyCode::Tab => {
1118 -
                                        app.focus = Focus::EditContent
1119 -
                                    }
1120 -
                                    KeyCode::Backspace => {
1121 -
                                        app.edit_title.pop();
1122 -
                                    }
1123 -
                                    KeyCode::Char(c) => app.edit_title.push(c),
1124 -
                                    _ => {}
1125 -
                                }
1126 -
                            }
1127 -
                        }
1128 -
                        Focus::EditContent => {
1129 -
                            if key.modifiers.contains(KeyModifiers::CONTROL) {
1130 -
                                match key.code {
1131 -
                                    KeyCode::Char('s') => app.save_edit(backend),
1132 -
                                    KeyCode::Char('w') => {
1133 -
                                        app.wrap_content = !app.wrap_content;
1134 -
                                        app.edit_scroll = 0;
1135 -
                                    }
1136 -
                                    _ => {}
1137 -
                                }
1138 -
                            } else {
1139 -
                                match key.code {
1140 -
                                    KeyCode::Esc => app.cancel_edit(),
1141 -
                                    KeyCode::Tab => app.focus = Focus::EditTitle,
1142 -
                                    KeyCode::Enter => app.edit_content.push('\n'),
1143 -
                                    KeyCode::Backspace => {
1144 -
                                        app.edit_content.pop();
1145 -
                                    }
1146 -
                                    KeyCode::Char(c) => app.edit_content.push(c),
1147 -
                                    _ => {}
1148 -
                                }
1149 -
                            }
1150 -
                        }
1151 -
                        Focus::Search => match key.code {
1152 -
                            KeyCode::Esc => app.cancel_search(),
1153 -
                            KeyCode::Enter => app.confirm_search(),
1154 -
                            KeyCode::Backspace => {
1155 -
                                app.search_query.pop();
1156 -
                                app.update_search_filter();
1157 -
                            }
1158 -
                            KeyCode::Char(c) => {
1159 -
                                app.search_query.push(c);
1160 -
                                app.update_search_filter();
1161 -
                            }
1162 -
                            _ => {}
1163 -
                        },
1164 -
                    }
1165 -
                }
1166 -
            }
136 +
        if event::poll(Duration::from_millis(100))?
137 +
            && let Event::Key(key) = event::read()?
138 +
        {
139 +
            events::handle_key(terminal, &mut app, backend, key, content_line_count)?;
1167 140
        }
1168 141
    }
1169 142
apps/jotts/src/tui/app.rs (added) +413 −0
1 +
use crate::backend::Backend;
2 +
use crate::db::Note;
3 +
use crate::highlight::Highlighter;
4 +
use arboard::Clipboard;
5 +
use ratatui::widgets::ListState;
6 +
use std::time::{Duration, Instant};
7 +
8 +
pub(super) enum Focus {
9 +
    List,
10 +
    Content,
11 +
    CreateTitle,
12 +
    CreateContent,
13 +
    EditTitle,
14 +
    EditContent,
15 +
    Search,
16 +
}
17 +
18 +
pub(super) struct App {
19 +
    pub(super) notes: Vec<Note>,
20 +
    pub(super) list_state: ListState,
21 +
    pub(super) should_quit: bool,
22 +
    pub(super) status_message: Option<(String, Instant)>,
23 +
    pub(super) focus: Focus,
24 +
    pub(super) content_scroll: u16,
25 +
    pub(super) show_help: bool,
26 +
    pub(super) confirm_delete: bool,
27 +
    pub(super) highlighter: Highlighter,
28 +
    pub(super) edit_title: String,
29 +
    pub(super) edit_content: String,
30 +
    pub(super) edit_short_id: Option<String>,
31 +
    pub(super) search_query: String,
32 +
    pub(super) filtered_indices: Option<Vec<usize>>,
33 +
    pub(super) is_remote: bool,
34 +
    pub(super) remote_url: Option<String>,
35 +
    pub(super) wrap_content: bool,
36 +
    pub(super) edit_scroll: u16,
37 +
}
38 +
39 +
impl App {
40 +
    pub(super) fn new(notes: Vec<Note>, is_remote: bool, remote_url: Option<String>) -> Self {
41 +
        let mut list_state = ListState::default();
42 +
        if !notes.is_empty() {
43 +
            list_state.select(Some(0));
44 +
        }
45 +
        Self {
46 +
            notes,
47 +
            list_state,
48 +
            should_quit: false,
49 +
            status_message: None,
50 +
            focus: Focus::List,
51 +
            content_scroll: 0,
52 +
            show_help: false,
53 +
            confirm_delete: false,
54 +
            highlighter: Highlighter::new(),
55 +
            edit_title: String::new(),
56 +
            edit_content: String::new(),
57 +
            edit_short_id: None,
58 +
            search_query: String::new(),
59 +
            filtered_indices: None,
60 +
            is_remote,
61 +
            remote_url,
62 +
            wrap_content: true,
63 +
            edit_scroll: 0,
64 +
        }
65 +
    }
66 +
67 +
    pub(super) fn selected_note(&self) -> Option<&Note> {
68 +
        self.list_state.selected().and_then(|i| {
69 +
            if let Some(indices) = &self.filtered_indices {
70 +
                indices.get(i).and_then(|&real| self.notes.get(real))
71 +
            } else {
72 +
                self.notes.get(i)
73 +
            }
74 +
        })
75 +
    }
76 +
77 +
    pub(super) fn visible_count(&self) -> usize {
78 +
        match &self.filtered_indices {
79 +
            Some(indices) => indices.len(),
80 +
            None => self.notes.len(),
81 +
        }
82 +
    }
83 +
84 +
    pub(super) fn move_up(&mut self) {
85 +
        let count = self.visible_count();
86 +
        if count == 0 {
87 +
            return;
88 +
        }
89 +
        let i = match self.list_state.selected() {
90 +
            Some(i) if i > 0 => i - 1,
91 +
            Some(_) => count - 1,
92 +
            None => 0,
93 +
        };
94 +
        self.list_state.select(Some(i));
95 +
        self.content_scroll = 0;
96 +
    }
97 +
98 +
    pub(super) fn move_down(&mut self) {
99 +
        let count = self.visible_count();
100 +
        if count == 0 {
101 +
            return;
102 +
        }
103 +
        let i = match self.list_state.selected() {
104 +
            Some(i) if i < count - 1 => i + 1,
105 +
            Some(_) => 0,
106 +
            None => 0,
107 +
        };
108 +
        self.list_state.select(Some(i));
109 +
        self.content_scroll = 0;
110 +
    }
111 +
112 +
    pub(super) fn scroll_up(&mut self) {
113 +
        self.content_scroll = self.content_scroll.saturating_sub(1);
114 +
    }
115 +
116 +
    pub(super) fn scroll_down(&mut self, max_lines: u16) {
117 +
        if self.content_scroll < max_lines {
118 +
            self.content_scroll += 1;
119 +
        }
120 +
    }
121 +
122 +
    pub(super) fn copy_selected(&mut self) {
123 +
        if let Some(note) = self.selected_note() {
124 +
            if let Ok(mut clipboard) = Clipboard::new() {
125 +
                let _ = clipboard.set_text(&note.content);
126 +
                self.status_message = Some(("Copied!".to_string(), Instant::now()));
127 +
            }
128 +
        }
129 +
    }
130 +
131 +
    pub(super) fn copy_link(&mut self) {
132 +
        match &self.remote_url {
133 +
            Some(url) => {
134 +
                if let Some(note) = self.selected_note() {
135 +
                    let link = format!("{}/notes/{}", url.trim_end_matches('/'), note.short_id);
136 +
                    if let Ok(mut clipboard) = Clipboard::new() {
137 +
                        let _ = clipboard.set_text(&link);
138 +
                        self.status_message =
139 +
                            Some(("Link copied!".to_string(), Instant::now()));
140 +
                    }
141 +
                }
142 +
            }
143 +
            None => {
144 +
                self.status_message =
145 +
                    Some(("No remote URL configured".to_string(), Instant::now()));
146 +
            }
147 +
        }
148 +
    }
149 +
150 +
    pub(super) fn open_in_browser(&mut self) {
151 +
        match &self.remote_url {
152 +
            Some(url) => {
153 +
                if let Some(note) = self.selected_note() {
154 +
                    let link = format!("{}/notes/{}", url.trim_end_matches('/'), note.short_id);
155 +
                    if let Err(e) = open::that(&link) {
156 +
                        self.status_message =
157 +
                            Some((format!("Failed to open browser: {}", e), Instant::now()));
158 +
                    } else {
159 +
                        self.status_message =
160 +
                            Some(("Opened in browser!".to_string(), Instant::now()));
161 +
                    }
162 +
                }
163 +
            }
164 +
            None => {
165 +
                self.status_message =
166 +
                    Some(("No remote URL configured".to_string(), Instant::now()));
167 +
            }
168 +
        }
169 +
    }
170 +
171 +
    pub(super) fn delete_selected(&mut self, backend: &Backend) {
172 +
        if let Some(selected_index) = self.list_state.selected() {
173 +
            let real_index = if let Some(indices) = &self.filtered_indices {
174 +
                match indices.get(selected_index) {
175 +
                    Some(&ri) => ri,
176 +
                    None => return,
177 +
                }
178 +
            } else {
179 +
                selected_index
180 +
            };
181 +
            if let Some(note) = self.notes.get(real_index) {
182 +
                let short_id = note.short_id.clone();
183 +
                match backend.delete_note(&short_id) {
184 +
                    Ok(true) => {
185 +
                        self.notes.remove(real_index);
186 +
                        if self.filtered_indices.is_some() {
187 +
                            self.update_search_filter();
188 +
                        }
189 +
                        let count = self.visible_count();
190 +
                        if count == 0 {
191 +
                            self.list_state.select(None);
192 +
                        } else if selected_index >= count {
193 +
                            self.list_state.select(Some(count - 1));
194 +
                        } else {
195 +
                            self.list_state.select(Some(selected_index));
196 +
                        }
197 +
                        self.status_message = Some(("Deleted!".to_string(), Instant::now()));
198 +
                    }
199 +
                    Ok(false) => {
200 +
                        self.status_message =
201 +
                            Some(("Note not found".to_string(), Instant::now()));
202 +
                    }
203 +
                    Err(e) => {
204 +
                        self.status_message = Some((e.to_string(), Instant::now()));
205 +
                    }
206 +
                }
207 +
            }
208 +
        }
209 +
    }
210 +
211 +
    pub(super) fn refresh(&mut self, backend: &Backend) {
212 +
        match backend.list_notes() {
213 +
            Ok(notes) => {
214 +
                self.notes = notes;
215 +
                self.filtered_indices = None;
216 +
                self.search_query.clear();
217 +
                if self.notes.is_empty() {
218 +
                    self.list_state.select(None);
219 +
                } else {
220 +
                    let idx = self.list_state.selected().unwrap_or(0);
221 +
                    if idx >= self.notes.len() {
222 +
                        self.list_state.select(Some(self.notes.len() - 1));
223 +
                    }
224 +
                }
225 +
                self.status_message = Some(("Refreshed!".to_string(), Instant::now()));
226 +
            }
227 +
            Err(e) => {
228 +
                self.status_message = Some((e.to_string(), Instant::now()));
229 +
            }
230 +
        }
231 +
    }
232 +
233 +
    pub(super) fn cursor_position_wrapped(&self, width: u16) -> (u16, u16) {
234 +
        let w = width as usize;
235 +
        if w == 0 {
236 +
            return (0, 0);
237 +
        }
238 +
        let text = &self.edit_content;
239 +
        let mut visual_row: usize = 0;
240 +
        let lines: Vec<&str> = if text.is_empty() {
241 +
            vec![""]
242 +
        } else {
243 +
            text.split('\n').collect()
244 +
        };
245 +
        let last_idx = lines.len() - 1;
246 +
        for (i, line) in lines.iter().enumerate() {
247 +
            let line_len = line.len();
248 +
            let wrapped_lines = if line_len == 0 {
249 +
                1
250 +
            } else {
251 +
                (line_len + w - 1) / w
252 +
            };
253 +
            if i < last_idx {
254 +
                visual_row += wrapped_lines;
255 +
            } else {
256 +
                let cursor_col = if text.ends_with('\n') { 0 } else { line_len };
257 +
                let extra_rows = cursor_col / w;
258 +
                let col = cursor_col % w;
259 +
                visual_row += extra_rows;
260 +
                return (col as u16, visual_row as u16);
261 +
            }
262 +
        }
263 +
        (0, visual_row as u16)
264 +
    }
265 +
266 +
    pub(super) fn auto_scroll_edit(&mut self, cursor_visual_row: u16, visible_height: u16) {
267 +
        if visible_height == 0 {
268 +
            return;
269 +
        }
270 +
        if cursor_visual_row < self.edit_scroll {
271 +
            self.edit_scroll = cursor_visual_row;
272 +
        } else if cursor_visual_row >= self.edit_scroll + visible_height {
273 +
            self.edit_scroll = cursor_visual_row - visible_height + 1;
274 +
        }
275 +
    }
276 +
277 +
    pub(super) fn start_create(&mut self) {
278 +
        self.edit_title.clear();
279 +
        self.edit_content.clear();
280 +
        self.edit_scroll = 0;
281 +
        self.focus = Focus::CreateTitle;
282 +
    }
283 +
284 +
    pub(super) fn save_create(&mut self, backend: &Backend) {
285 +
        if self.edit_title.trim().is_empty() {
286 +
            self.status_message = Some(("Title cannot be empty".to_string(), Instant::now()));
287 +
            return;
288 +
        }
289 +
        match backend.create_note(&self.edit_title, &self.edit_content) {
290 +
            Ok(note) => {
291 +
                self.notes.insert(0, note);
292 +
                self.list_state.select(Some(0));
293 +
                self.filtered_indices = None;
294 +
                self.search_query.clear();
295 +
                self.status_message = Some(("Created!".to_string(), Instant::now()));
296 +
                self.focus = Focus::List;
297 +
                self.edit_title.clear();
298 +
                self.edit_content.clear();
299 +
            }
300 +
            Err(e) => {
301 +
                self.status_message = Some((e.to_string(), Instant::now()));
302 +
            }
303 +
        }
304 +
    }
305 +
306 +
    pub(super) fn cancel_create(&mut self) {
307 +
        self.edit_title.clear();
308 +
        self.edit_content.clear();
309 +
        self.focus = Focus::List;
310 +
    }
311 +
312 +
    pub(super) fn start_edit(&mut self) {
313 +
        let data = self
314 +
            .selected_note()
315 +
            .map(|n| (n.title.clone(), n.content.clone(), n.short_id.clone()));
316 +
        if let Some((title, content, short_id)) = data {
317 +
            self.edit_title = title;
318 +
            self.edit_content = content;
319 +
            self.edit_short_id = Some(short_id);
320 +
            self.edit_scroll = 0;
321 +
            self.focus = Focus::EditTitle;
322 +
        }
323 +
    }
324 +
325 +
    pub(super) fn save_edit(&mut self, backend: &Backend) {
326 +
        if self.edit_title.trim().is_empty() {
327 +
            self.status_message = Some(("Title cannot be empty".to_string(), Instant::now()));
328 +
            return;
329 +
        }
330 +
        let short_id = match &self.edit_short_id {
331 +
            Some(id) => id.clone(),
332 +
            None => return,
333 +
        };
334 +
        match backend.update_note(&short_id, &self.edit_title, &self.edit_content) {
335 +
            Ok(Some(updated)) => {
336 +
                if let Some(pos) = self.notes.iter().position(|n| n.short_id == short_id) {
337 +
                    self.notes[pos] = updated;
338 +
                }
339 +
                self.status_message = Some(("Updated!".to_string(), Instant::now()));
340 +
                self.focus = Focus::List;
341 +
                self.edit_title.clear();
342 +
                self.edit_content.clear();
343 +
                self.edit_short_id = None;
344 +
            }
345 +
            Ok(None) => {
346 +
                self.status_message = Some(("Note not found".to_string(), Instant::now()));
347 +
            }
348 +
            Err(e) => {
349 +
                self.status_message = Some((e.to_string(), Instant::now()));
350 +
            }
351 +
        }
352 +
    }
353 +
354 +
    pub(super) fn cancel_edit(&mut self) {
355 +
        self.edit_title.clear();
356 +
        self.edit_content.clear();
357 +
        self.edit_short_id = None;
358 +
        self.focus = Focus::List;
359 +
    }
360 +
361 +
    pub(super) fn start_search(&mut self) {
362 +
        self.search_query.clear();
363 +
        self.filtered_indices = Some((0..self.notes.len()).collect());
364 +
        self.focus = Focus::Search;
365 +
        self.list_state
366 +
            .select(if self.notes.is_empty() { None } else { Some(0) });
367 +
    }
368 +
369 +
    pub(super) fn update_search_filter(&mut self) {
370 +
        let query = self.search_query.to_lowercase();
371 +
        let indices: Vec<usize> = self
372 +
            .notes
373 +
            .iter()
374 +
            .enumerate()
375 +
            .filter(|(_, n)| n.title.to_lowercase().contains(&query))
376 +
            .map(|(i, _)| i)
377 +
            .collect();
378 +
        self.filtered_indices = Some(indices);
379 +
        if self.visible_count() == 0 {
380 +
            self.list_state.select(None);
381 +
        } else {
382 +
            self.list_state.select(Some(0));
383 +
        }
384 +
    }
385 +
386 +
    pub(super) fn cancel_search(&mut self) {
387 +
        self.filtered_indices = None;
388 +
        self.search_query.clear();
389 +
        self.focus = Focus::List;
390 +
    }
391 +
392 +
    pub(super) fn confirm_search(&mut self) {
393 +
        let real_index = self.list_state.selected().and_then(|i| {
394 +
            self.filtered_indices
395 +
                .as_ref()
396 +
                .and_then(|indices| indices.get(i).copied())
397 +
        });
398 +
        self.filtered_indices = None;
399 +
        self.search_query.clear();
400 +
        self.focus = Focus::List;
401 +
        if let Some(ri) = real_index {
402 +
            self.list_state.select(Some(ri));
403 +
        }
404 +
    }
405 +
406 +
    pub(super) fn clear_expired_status(&mut self) {
407 +
        if let Some((_, time)) = &self.status_message {
408 +
            if time.elapsed() > Duration::from_secs(2) {
409 +
                self.status_message = None;
410 +
            }
411 +
        }
412 +
    }
413 +
}
apps/jotts/src/tui/editor.rs (added) +69 −0
1 +
use super::app::App;
2 +
use crate::backend::Backend;
3 +
use ratatui::DefaultTerminal;
4 +
use std::time::Instant;
5 +
6 +
pub(super) fn edit_in_external_editor(
7 +
    terminal: &mut DefaultTerminal,
8 +
    app: &mut App,
9 +
    backend: &Backend,
10 +
) -> Result<(), Box<dyn std::error::Error>> {
11 +
    let (short_id, title, content) = match app.selected_note() {
12 +
        Some(n) => (n.short_id.clone(), n.title.clone(), n.content.clone()),
13 +
        None => return Ok(()),
14 +
    };
15 +
16 +
    let editor = match std::env::var("EDITOR") {
17 +
        Ok(e) if !e.trim().is_empty() => e,
18 +
        _ => {
19 +
            app.status_message = Some(("EDITOR env not set".to_string(), Instant::now()));
20 +
            return Ok(());
21 +
        }
22 +
    };
23 +
24 +
    let mut path = std::env::temp_dir();
25 +
    path.push(format!("jotts-{}.md", short_id));
26 +
    std::fs::write(&path, &content)?;
27 +
28 +
    ratatui::restore();
29 +
30 +
    let status = std::process::Command::new(&editor).arg(&path).status();
31 +
32 +
    *terminal = ratatui::init();
33 +
    terminal.clear()?;
34 +
35 +
    match status {
36 +
        Ok(s) if s.success() => {
37 +
            let new_content = std::fs::read_to_string(&path)?;
38 +
            let _ = std::fs::remove_file(&path);
39 +
            if new_content == content {
40 +
                app.status_message = Some(("No changes".to_string(), Instant::now()));
41 +
                return Ok(());
42 +
            }
43 +
            match backend.update_note(&short_id, &title, &new_content) {
44 +
                Ok(Some(updated)) => {
45 +
                    if let Some(pos) = app.notes.iter().position(|n| n.short_id == short_id) {
46 +
                        app.notes[pos] = updated;
47 +
                    }
48 +
                    app.status_message = Some(("Updated!".to_string(), Instant::now()));
49 +
                }
50 +
                Ok(None) => {
51 +
                    app.status_message = Some(("Note not found".to_string(), Instant::now()));
52 +
                }
53 +
                Err(e) => {
54 +
                    app.status_message = Some((e.to_string(), Instant::now()));
55 +
                }
56 +
            }
57 +
        }
58 +
        Ok(_) => {
59 +
            let _ = std::fs::remove_file(&path);
60 +
            app.status_message = Some(("Editor exited non-zero".to_string(), Instant::now()));
61 +
        }
62 +
        Err(e) => {
63 +
            let _ = std::fs::remove_file(&path);
64 +
            app.status_message =
65 +
                Some((format!("Failed to launch editor: {}", e), Instant::now()));
66 +
        }
67 +
    }
68 +
    Ok(())
69 +
}
apps/jotts/src/tui/events.rs (added) +160 −0
1 +
use super::app::{App, Focus};
2 +
use super::editor::edit_in_external_editor;
3 +
use crate::backend::Backend;
4 +
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5 +
use ratatui::DefaultTerminal;
6 +
7 +
pub(super) fn handle_key(
8 +
    terminal: &mut DefaultTerminal,
9 +
    app: &mut App,
10 +
    backend: &Backend,
11 +
    key: KeyEvent,
12 +
    content_line_count: u16,
13 +
) -> Result<(), Box<dyn std::error::Error>> {
14 +
    if app.show_help {
15 +
        app.show_help = false;
16 +
        return Ok(());
17 +
    }
18 +
    if app.status_message.is_some() {
19 +
        app.status_message = None;
20 +
        return Ok(());
21 +
    }
22 +
    if app.confirm_delete {
23 +
        if key.code == KeyCode::Char('y') {
24 +
            app.delete_selected(backend);
25 +
        }
26 +
        app.confirm_delete = false;
27 +
        return Ok(());
28 +
    }
29 +
30 +
    match app.focus {
31 +
        Focus::List => match key.code {
32 +
            KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
33 +
            KeyCode::Char('j') | KeyCode::Down => app.move_down(),
34 +
            KeyCode::Char('k') | KeyCode::Up => app.move_up(),
35 +
            KeyCode::Char('y') => app.copy_selected(),
36 +
            KeyCode::Char('Y') => app.copy_link(),
37 +
            KeyCode::Char('d') => app.confirm_delete = true,
38 +
            KeyCode::Char('c') => app.start_create(),
39 +
            KeyCode::Char('e') => app.start_edit(),
40 +
            KeyCode::Char('E') => edit_in_external_editor(terminal, app, backend)?,
41 +
            KeyCode::Char('/') => app.start_search(),
42 +
            KeyCode::Char('o') => app.open_in_browser(),
43 +
            KeyCode::Char('r') if app.is_remote => app.refresh(backend),
44 +
            KeyCode::Char('?') => app.show_help = true,
45 +
            KeyCode::Enter | KeyCode::Char('l') => {
46 +
                if app.selected_note().is_some() {
47 +
                    app.focus = Focus::Content;
48 +
                }
49 +
            }
50 +
            _ => {}
51 +
        },
52 +
        Focus::Content => match key.code {
53 +
            KeyCode::Char(' ')
54 +
            | KeyCode::Esc
55 +
            | KeyCode::Char('q')
56 +
            | KeyCode::Char('h') => {
57 +
                app.focus = Focus::List;
58 +
            }
59 +
            KeyCode::Char('j') | KeyCode::Down => app.scroll_down(content_line_count),
60 +
            KeyCode::Char('k') | KeyCode::Up => app.scroll_up(),
61 +
            KeyCode::Char('y') => app.copy_selected(),
62 +
            KeyCode::Char('Y') => app.copy_link(),
63 +
            KeyCode::Char('e') => app.start_edit(),
64 +
            KeyCode::Char('E') => edit_in_external_editor(terminal, app, backend)?,
65 +
            KeyCode::Char('o') => app.open_in_browser(),
66 +
            KeyCode::Char('?') => app.show_help = true,
67 +
            _ => {}
68 +
        },
69 +
        Focus::CreateTitle => {
70 +
            if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
71 +
                app.save_create(backend);
72 +
            } else {
73 +
                match key.code {
74 +
                    KeyCode::Esc => app.cancel_create(),
75 +
                    KeyCode::Enter | KeyCode::Tab => app.focus = Focus::CreateContent,
76 +
                    KeyCode::Backspace => {
77 +
                        app.edit_title.pop();
78 +
                    }
79 +
                    KeyCode::Char(c) => app.edit_title.push(c),
80 +
                    _ => {}
81 +
                }
82 +
            }
83 +
        }
84 +
        Focus::CreateContent => {
85 +
            if key.modifiers.contains(KeyModifiers::CONTROL) {
86 +
                match key.code {
87 +
                    KeyCode::Char('s') => app.save_create(backend),
88 +
                    KeyCode::Char('w') => {
89 +
                        app.wrap_content = !app.wrap_content;
90 +
                        app.edit_scroll = 0;
91 +
                    }
92 +
                    _ => {}
93 +
                }
94 +
            } else {
95 +
                match key.code {
96 +
                    KeyCode::Esc => app.cancel_create(),
97 +
                    KeyCode::Tab => app.focus = Focus::CreateTitle,
98 +
                    KeyCode::Enter => app.edit_content.push('\n'),
99 +
                    KeyCode::Backspace => {
100 +
                        app.edit_content.pop();
101 +
                    }
102 +
                    KeyCode::Char(c) => app.edit_content.push(c),
103 +
                    _ => {}
104 +
                }
105 +
            }
106 +
        }
107 +
        Focus::EditTitle => {
108 +
            if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
109 +
                app.save_edit(backend);
110 +
            } else {
111 +
                match key.code {
112 +
                    KeyCode::Esc => app.cancel_edit(),
113 +
                    KeyCode::Enter | KeyCode::Tab => app.focus = Focus::EditContent,
114 +
                    KeyCode::Backspace => {
115 +
                        app.edit_title.pop();
116 +
                    }
117 +
                    KeyCode::Char(c) => app.edit_title.push(c),
118 +
                    _ => {}
119 +
                }
120 +
            }
121 +
        }
122 +
        Focus::EditContent => {
123 +
            if key.modifiers.contains(KeyModifiers::CONTROL) {
124 +
                match key.code {
125 +
                    KeyCode::Char('s') => app.save_edit(backend),
126 +
                    KeyCode::Char('w') => {
127 +
                        app.wrap_content = !app.wrap_content;
128 +
                        app.edit_scroll = 0;
129 +
                    }
130 +
                    _ => {}
131 +
                }
132 +
            } else {
133 +
                match key.code {
134 +
                    KeyCode::Esc => app.cancel_edit(),
135 +
                    KeyCode::Tab => app.focus = Focus::EditTitle,
136 +
                    KeyCode::Enter => app.edit_content.push('\n'),
137 +
                    KeyCode::Backspace => {
138 +
                        app.edit_content.pop();
139 +
                    }
140 +
                    KeyCode::Char(c) => app.edit_content.push(c),
141 +
                    _ => {}
142 +
                }
143 +
            }
144 +
        }
145 +
        Focus::Search => match key.code {
146 +
            KeyCode::Esc => app.cancel_search(),
147 +
            KeyCode::Enter => app.confirm_search(),
148 +
            KeyCode::Backspace => {
149 +
                app.search_query.pop();
150 +
                app.update_search_filter();
151 +
            }
152 +
            KeyCode::Char(c) => {
153 +
                app.search_query.push(c);
154 +
                app.update_search_filter();
155 +
            }
156 +
            _ => {}
157 +
        },
158 +
    }
159 +
    Ok(())
160 +
}
apps/jotts/src/tui/render.rs (added) +399 −0
1 +
use super::app::{App, Focus};
2 +
use ratatui::{
3 +
    Frame,
4 +
    layout::{Alignment, Constraint, Layout},
5 +
    style::{Color, Modifier, Style},
6 +
    text::{Line, Span, Text},
7 +
    widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Widget, Wrap},
8 +
};
9 +
10 +
pub(super) fn draw(frame: &mut Frame, app: &mut App) {
11 +
    let outer = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(frame.area());
12 +
13 +
    let chunks =
14 +
        Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)]).split(outer[0]);
15 +
16 +
    let items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices {
17 +
        indices
18 +
            .iter()
19 +
            .filter_map(|&i| app.notes.get(i))
20 +
            .map(|n| ListItem::new(n.title.as_str()))
21 +
            .collect()
22 +
    } else {
23 +
        app.notes
24 +
            .iter()
25 +
            .map(|n| ListItem::new(n.title.as_str()))
26 +
            .collect()
27 +
    };
28 +
29 +
    let list_border_style = match app.focus {
30 +
        Focus::List | Focus::Search => Style::default().fg(Color::Yellow),
31 +
        _ => Style::default().fg(Color::DarkGray),
32 +
    };
33 +
    let content_border_style = match app.focus {
34 +
        Focus::Content => Style::default().fg(Color::Yellow),
35 +
        _ => Style::default().fg(Color::DarkGray),
36 +
    };
37 +
38 +
    let list = List::new(items)
39 +
        .block(
40 +
            Block::default()
41 +
                .title(" Notes ")
42 +
                .borders(Borders::ALL)
43 +
                .border_style(list_border_style),
44 +
        )
45 +
        .highlight_style(
46 +
            Style::default()
47 +
                .fg(Color::Yellow)
48 +
                .add_modifier(Modifier::BOLD),
49 +
        )
50 +
        .highlight_symbol("▶ ");
51 +
52 +
    if matches!(app.focus, Focus::Search) {
53 +
        let search_split =
54 +
            Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(chunks[0]);
55 +
56 +
        let search_items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices {
57 +
            indices
58 +
                .iter()
59 +
                .filter_map(|&i| app.notes.get(i))
60 +
                .map(|n| ListItem::new(n.title.as_str()))
61 +
                .collect()
62 +
        } else {
63 +
            app.notes
64 +
                .iter()
65 +
                .map(|n| ListItem::new(n.title.as_str()))
66 +
                .collect()
67 +
        };
68 +
        let search_list = List::new(search_items)
69 +
            .block(
70 +
                Block::default()
71 +
                    .title(" Notes ")
72 +
                    .borders(Borders::ALL)
73 +
                    .border_style(list_border_style),
74 +
            )
75 +
            .highlight_style(
76 +
                Style::default()
77 +
                    .fg(Color::Yellow)
78 +
                    .add_modifier(Modifier::BOLD),
79 +
            )
80 +
            .highlight_symbol("▶ ");
81 +
        frame.render_stateful_widget(search_list, search_split[0], &mut app.list_state);
82 +
83 +
        let search_input = Paragraph::new(app.search_query.as_str()).block(
84 +
            Block::default()
85 +
                .title(" Search ")
86 +
                .borders(Borders::ALL)
87 +
                .border_style(Style::default().fg(Color::Yellow)),
88 +
        );
89 +
        frame.render_widget(search_input, search_split[1]);
90 +
91 +
        let x = search_split[1].x + 1 + app.search_query.len() as u16;
92 +
        let y = search_split[1].y + 1;
93 +
        frame.set_cursor_position((x, y));
94 +
    } else {
95 +
        frame.render_stateful_widget(list, chunks[0], &mut app.list_state);
96 +
    }
97 +
98 +
    match app.focus {
99 +
        Focus::CreateTitle | Focus::CreateContent | Focus::EditTitle | Focus::EditContent => {
100 +
            let form_title = match app.focus {
101 +
                Focus::EditTitle | Focus::EditContent => " Edit Note ",
102 +
                _ => " New Note ",
103 +
            };
104 +
            let create_block = Block::default()
105 +
                .title(form_title)
106 +
                .borders(Borders::ALL)
107 +
                .border_style(Style::default().fg(Color::Yellow));
108 +
109 +
            let inner = create_block.inner(chunks[1]);
110 +
            frame.render_widget(create_block, chunks[1]);
111 +
112 +
            let form_layout =
113 +
                Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(inner);
114 +
115 +
            let title_style = match app.focus {
116 +
                Focus::CreateTitle | Focus::EditTitle => Style::default().fg(Color::Yellow),
117 +
                _ => Style::default().fg(Color::DarkGray),
118 +
            };
119 +
            let title_input = Paragraph::new(app.edit_title.as_str()).block(
120 +
                Block::default()
121 +
                    .title(" Title ")
122 +
                    .borders(Borders::ALL)
123 +
                    .border_style(title_style),
124 +
            );
125 +
            frame.render_widget(title_input, form_layout[0]);
126 +
127 +
            let content_style = match app.focus {
128 +
                Focus::CreateContent | Focus::EditContent => Style::default().fg(Color::Yellow),
129 +
                _ => Style::default().fg(Color::DarkGray),
130 +
            };
131 +
            let mut content_input = Paragraph::new(app.edit_content.as_str()).block(
132 +
                Block::default()
133 +
                    .title(" Content ")
134 +
                    .borders(Borders::ALL)
135 +
                    .border_style(content_style),
136 +
            );
137 +
            if app.wrap_content {
138 +
                content_input = content_input.wrap(Wrap { trim: false });
139 +
            }
140 +
            content_input = content_input.scroll((app.edit_scroll, 0));
141 +
            frame.render_widget(content_input, form_layout[1]);
142 +
143 +
            let content_inner = Block::default().borders(Borders::ALL).inner(form_layout[1]);
144 +
            let inner_width = content_inner.width;
145 +
            let inner_height = content_inner.height;
146 +
147 +
            match app.focus {
148 +
                Focus::CreateTitle | Focus::EditTitle => {
149 +
                    let x = form_layout[0].x + 1 + app.edit_title.len() as u16;
150 +
                    let y = form_layout[0].y + 1;
151 +
                    frame.set_cursor_position((x, y));
152 +
                }
153 +
                Focus::CreateContent | Focus::EditContent => {
154 +
                    let (cx, cy) = if app.wrap_content {
155 +
                        app.cursor_position_wrapped(inner_width)
156 +
                    } else {
157 +
                        let last_line = app.edit_content.lines().last().unwrap_or("");
158 +
                        let line_count = app.edit_content.lines().count()
159 +
                            + if app.edit_content.ends_with('\n') { 1 } else { 0 };
160 +
                        let y_offset = if line_count == 0 { 0 } else { line_count - 1 };
161 +
                        let col = if app.edit_content.ends_with('\n') {
162 +
                            0
163 +
                        } else {
164 +
                            last_line.len() as u16
165 +
                        };
166 +
                        (col, y_offset as u16)
167 +
                    };
168 +
                    app.auto_scroll_edit(cy, inner_height);
169 +
                    let screen_y = cy.saturating_sub(app.edit_scroll);
170 +
                    let x = content_inner.x + cx;
171 +
                    let y = content_inner.y + screen_y;
172 +
                    frame.set_cursor_position((x, y));
173 +
                }
174 +
                _ => {}
175 +
            }
176 +
        }
177 +
        _ => {
178 +
            let highlighted = match app.selected_note() {
179 +
                Some(n) => app.highlighter.highlight_markdown(&n.content),
180 +
                None => Text::raw(""),
181 +
            };
182 +
183 +
            let paragraph = Paragraph::new(highlighted)
184 +
                .block(
185 +
                    Block::default()
186 +
                        .title(" Content ")
187 +
                        .borders(Borders::ALL)
188 +
                        .border_style(content_border_style),
189 +
                )
190 +
                .scroll((app.content_scroll, 0));
191 +
192 +
            frame.render_widget(paragraph, chunks[1]);
193 +
        }
194 +
    }
195 +
196 +
    let hints = match app.focus {
197 +
        Focus::List => Line::from(vec![
198 +
            Span::styled("j/k", Style::default().fg(Color::Yellow)),
199 +
            Span::raw(": Navigate  "),
200 +
            Span::styled("Enter", Style::default().fg(Color::Yellow)),
201 +
            Span::raw(": View  "),
202 +
            Span::styled("y", Style::default().fg(Color::Yellow)),
203 +
            Span::raw(": Copy  "),
204 +
            Span::styled("e", Style::default().fg(Color::Yellow)),
205 +
            Span::raw(": Edit  "),
206 +
            Span::styled("d", Style::default().fg(Color::Yellow)),
207 +
            Span::raw(": Delete  "),
208 +
            Span::styled("c", Style::default().fg(Color::Yellow)),
209 +
            Span::raw(": Create  "),
210 +
            Span::styled("/", Style::default().fg(Color::Yellow)),
211 +
            Span::raw(": Search  "),
212 +
            Span::styled("?", Style::default().fg(Color::Yellow)),
213 +
            Span::raw(": Help  "),
214 +
            Span::styled("q", Style::default().fg(Color::Yellow)),
215 +
            Span::raw(": Quit"),
216 +
        ]),
217 +
        Focus::Content => Line::from(vec![
218 +
            Span::styled("j/k", Style::default().fg(Color::Yellow)),
219 +
            Span::raw(": Scroll  "),
220 +
            Span::styled("y", Style::default().fg(Color::Yellow)),
221 +
            Span::raw(": Copy  "),
222 +
            Span::styled("e", Style::default().fg(Color::Yellow)),
223 +
            Span::raw(": Edit  "),
224 +
            Span::styled("Esc", Style::default().fg(Color::Yellow)),
225 +
            Span::raw(": Back  "),
226 +
            Span::styled("?", Style::default().fg(Color::Yellow)),
227 +
            Span::raw(": Help"),
228 +
        ]),
229 +
        Focus::CreateTitle | Focus::CreateContent | Focus::EditTitle | Focus::EditContent => {
230 +
            Line::from(vec![
231 +
                Span::styled("Tab", Style::default().fg(Color::Yellow)),
232 +
                Span::raw(": Switch field  "),
233 +
                Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
234 +
                Span::raw(": Save  "),
235 +
                Span::styled("Ctrl+W", Style::default().fg(Color::Yellow)),
236 +
                Span::raw(": Wrap  "),
237 +
                Span::styled("Esc", Style::default().fg(Color::Yellow)),
238 +
                Span::raw(": Cancel"),
239 +
            ])
240 +
        }
241 +
        Focus::Search => Line::from(vec![
242 +
            Span::styled("Type", Style::default().fg(Color::Yellow)),
243 +
            Span::raw(": Filter  "),
244 +
            Span::styled("Enter", Style::default().fg(Color::Yellow)),
245 +
            Span::raw(": Select  "),
246 +
            Span::styled("Esc", Style::default().fg(Color::Yellow)),
247 +
            Span::raw(": Cancel"),
248 +
        ]),
249 +
    };
250 +
    frame.render_widget(Paragraph::new(hints), outer[1]);
251 +
252 +
    if let Some((msg, _)) = &app.status_message {
253 +
        let area = frame.area();
254 +
        let msg_width = (msg.len() as u16 + 4).max(20).min(area.width.saturating_sub(4));
255 +
        let popup_area = ratatui::layout::Rect {
256 +
            x: (area.width.saturating_sub(msg_width)) / 2,
257 +
            y: (area.height.saturating_sub(3)) / 2,
258 +
            width: msg_width,
259 +
            height: 3,
260 +
        };
261 +
        Clear.render(popup_area, frame.buffer_mut());
262 +
        let status_popup = Paragraph::new(Line::from(msg.as_str()))
263 +
            .style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))
264 +
            .alignment(Alignment::Center)
265 +
            .block(
266 +
                Block::default()
267 +
                    .borders(Borders::ALL)
268 +
                    .border_style(Style::default().fg(Color::Green)),
269 +
            );
270 +
        frame.render_widget(status_popup, popup_area);
271 +
    }
272 +
273 +
    if app.confirm_delete {
274 +
        let delete_msg = match app.selected_note() {
275 +
            Some(n) => format!("Delete {}? (y/n)", n.title),
276 +
            None => "Delete note? (y/n)".to_string(),
277 +
        };
278 +
        let area = frame.area();
279 +
        let msg_width = (delete_msg.len() as u16 + 4).max(24).min(area.width.saturating_sub(4));
280 +
        let popup_area = ratatui::layout::Rect {
281 +
            x: (area.width.saturating_sub(msg_width)) / 2,
282 +
            y: (area.height.saturating_sub(3)) / 2,
283 +
            width: msg_width,
284 +
            height: 3,
285 +
        };
286 +
        Clear.render(popup_area, frame.buffer_mut());
287 +
        let confirm_popup = Paragraph::new(Line::from(delete_msg))
288 +
            .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
289 +
            .alignment(Alignment::Center)
290 +
            .block(
291 +
                Block::default()
292 +
                    .borders(Borders::ALL)
293 +
                    .border_style(Style::default().fg(Color::Red)),
294 +
            );
295 +
        frame.render_widget(confirm_popup, popup_area);
296 +
    }
297 +
298 +
    if app.show_help {
299 +
        let area = frame.area();
300 +
        let popup_width = 34u16.min(area.width.saturating_sub(4));
301 +
        let popup_height = 21u16.min(area.height.saturating_sub(4));
302 +
        let popup_area = ratatui::layout::Rect {
303 +
            x: (area.width.saturating_sub(popup_width)) / 2,
304 +
            y: (area.height.saturating_sub(popup_height)) / 2,
305 +
            width: popup_width,
306 +
            height: popup_height,
307 +
        };
308 +
309 +
        let mut help_lines = vec![
310 +
            Line::from(""),
311 +
            Line::from(vec![
312 +
                Span::styled("  j/↓  ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
313 +
                Span::raw("Move down / Scroll down"),
314 +
            ]),
315 +
            Line::from(vec![
316 +
                Span::styled("  k/↑  ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
317 +
                Span::raw("Move up / Scroll up"),
318 +
            ]),
319 +
            Line::from(vec![
320 +
                Span::styled("  Enter", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
321 +
                Span::raw("  Focus content pane"),
322 +
            ]),
323 +
            Line::from(vec![
324 +
                Span::styled("  Esc  ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
325 +
                Span::raw("Back / Quit"),
326 +
            ]),
327 +
            Line::from(vec![
328 +
                Span::styled("  y    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
329 +
                Span::raw("Copy note"),
330 +
            ]),
331 +
            Line::from(vec![
332 +
                Span::styled("  Y    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
333 +
                Span::raw("Copy link"),
334 +
            ]),
335 +
            Line::from(vec![
336 +
                Span::styled("  o    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
337 +
                Span::raw("Open in browser"),
338 +
            ]),
339 +
            Line::from(vec![
340 +
                Span::styled("  d    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
341 +
                Span::raw("Delete note"),
342 +
            ]),
343 +
            Line::from(vec![
344 +
                Span::styled("  c    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
345 +
                Span::raw("Create note"),
346 +
            ]),
347 +
            Line::from(vec![
348 +
                Span::styled("  e    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
349 +
                Span::raw("Edit note"),
350 +
            ]),
351 +
            Line::from(vec![
352 +
                Span::styled("  E    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
353 +
                Span::raw("Edit in $EDITOR"),
354 +
            ]),
355 +
            Line::from(vec![
356 +
                Span::styled("  /    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
357 +
                Span::raw("Search notes"),
358 +
            ]),
359 +
            Line::from(vec![
360 +
                Span::styled("  ^W   ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
361 +
                Span::raw("Toggle word wrap (edit)"),
362 +
            ]),
363 +
        ];
364 +
365 +
        if app.is_remote {
366 +
            help_lines.push(Line::from(vec![
367 +
                Span::styled("  r    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
368 +
                Span::raw("Refresh notes"),
369 +
            ]));
370 +
        }
371 +
372 +
        help_lines.extend([
373 +
            Line::from(vec![
374 +
                Span::styled("  q    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
375 +
                Span::raw("Quit"),
376 +
            ]),
377 +
            Line::from(vec![
378 +
                Span::styled("  ?    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
379 +
                Span::raw("Toggle this help"),
380 +
            ]),
381 +
            Line::from(""),
382 +
            Line::from(Span::styled(
383 +
                "  Press any key to close",
384 +
                Style::default().fg(Color::DarkGray),
385 +
            )),
386 +
        ]);
387 +
388 +
        let help_text = Text::from(help_lines);
389 +
390 +
        Clear.render(popup_area, frame.buffer_mut());
391 +
        let help = Paragraph::new(help_text).block(
392 +
            Block::default()
393 +
                .title(" Keybindings ")
394 +
                .borders(Borders::ALL)
395 +
                .border_style(Style::default().fg(Color::Yellow)),
396 +
        );
397 +
        frame.render_widget(help, popup_area);
398 +
    }
399 +
}