diff --git a/README.md b/README.md index d961748..e54f146 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ Everything is also in the [wiki](https://github.com/narrrl/narl-workspace/wiki). on a phone. Math by KaTeX, `$x^2$` and `$$…$$`, and ` ```typst ` blocks typeset by Typst on the server. `[[Title]]` links that are rewritten when what they point at is renamed or moved, and backlinks. -- **Plan.** A kanban board per space, due dates said the way you say them +- **Plan.** A kanban board per space, cards with a checklist, labels and a + markdown description, due dates said the way you say them (`fri`, `+3d`), cards that come back once done, and an agenda of what is late, due or in hand on every board. - **Remember.** A diary with a calendar, templates, the entries from this day @@ -266,7 +267,7 @@ you were. On the board, `o` or `c` puts a card on the column under the cursor. | `j` / `k` | down / up the cards of that list | | `J` / `K` | move the card down / up its list | | `H` / `L` | move the card to the list on the left / right | -| `Enter` | the card's notes: its document, made on first use; `q` comes back | +| `Enter` | turn the card over: labels, a markdown description, a checklist | | `Space` | into the done column, or back out to the inbox | | `o`, `c` | a new card on this list | | `D` | due date — `fri`, `tm`, `+3d`, `+2w`, `10-31`, `2026-10-31` | @@ -276,8 +277,14 @@ you were. On the board, `o` or `c` puts a card on the column under the cursor. | `x`, `dd` | delete the card | | `q`, `Esc` | back to the tree | +On the back of a card, `j`/`k` walk the checklist, `Space` ticks a step, `o` +adds one and `dd` drops it; `e` writes the description in the vim editor, `:w` +saving it to the card; `a` puts a label on. Code in the description is +coloured as nvim colours it. See the +[wiki](docs/wiki/Boards-and-Agenda.md#the-back-of-a-card) for every key. + A card can point at a document: `:card!` while reading one makes a card for it, -and `Enter` on that card opens it again. `Enter` on a card without one makes a +and `gf` on its back opens it again. `gf` on a card without one makes a document named after the card in the space's root and opens it to write in, and closing that document returns to the board on the same card. The link is deliberately loose — deleting the document leaves the card standing, because the work is still open diff --git a/docs/wiki/Boards-and-Agenda.md b/docs/wiki/Boards-and-Agenda.md index b038e28..fda2bd6 100644 --- a/docs/wiki/Boards-and-Agenda.md +++ b/docs/wiki/Boards-and-Agenda.md @@ -57,7 +57,7 @@ a week fold away until `z` shows them. | `H` / `L` | move the card to the list on the left / right | | `Space` | into the done list, or back out to the inbox | | `o`, `c` | a new card on this list | -| `Enter` | the card's notes | +| `Enter` | turn the card over: labels, description, checklist | | `D` | due date | | `R` | rename the card | | `z` | show done cards older than a week | @@ -65,6 +65,39 @@ a week fold away until `z` shows them. | `x`, `dd` | delete the card | | `q`, `Esc` | leave the special workspace | +### The back of a card + +`Enter` on a card turns it over: a sheet floats over the board with its +labels, its description and its checklist. The card stays the one under the +board's cursor, so `D`, `R`, `H` and `L` act on it as they do on the board. + +| key | what it does | +| --- | --- | +| `j` / `k`, `gg` / `G` | down / up the checklist, its first / last step | +| `Space`, `x` | tick the step, or untick it | +| `o` | a new step below the cursor (`:task `) | +| `r` | reword the step (`:task! `) | +| `J` / `K` | move the step down / up | +| `dd` | drop the step | +| `e`, `i`, `Enter` | write the description | +| `a` | put a label on, or take it off (`:label `) | +| `gf` | the card's notes, below | +| `q`, `Esc` | back to the board | + +The description is markdown, written in the same editor as a document, vim +and all: `:w` writes it to the card, `:q` or `Esc` in normal mode leaves, and +`:q!` throws away what was not written. A fenced block in a language is +coloured the way nvim colours it, written and read. + +Labels are lower case, and each name keeps its colour on every card and every +board, without anything to set: the colour comes from the name. `Tab` on +`:label` offers the ones the board already uses. The board shows a card's +labels over its title, and `☑ 2/5` and `≡` under it when it has a checklist and +a description. + +A repeating card comes back with the same labels and the same checklist, every +step open again. + ### Capturing a card `c` anywhere outside the special workspace opens `:card ` on the command line. The card goes @@ -73,7 +106,7 @@ you stay where you were. From outside the browser, see [Capture](Capture.md). ### Notes -`Enter` on a card opens its notes: a document named after the card, made in the +`gf` on the back of a card opens its notes: a document named after the card, made in the root of the space the first time. Closing it goes back to the board, on the same card. `:card!` while reading a document makes a card that points at it. diff --git a/docs/wiki/Commands.md b/docs/wiki/Commands.md index 9a9a4ad..339b046 100644 --- a/docs/wiki/Commands.md +++ b/docs/wiki/Commands.md @@ -76,7 +76,7 @@ The first list is the inbox, the last list is done. | `J / K` | move the card down / up its list | | `H / L` | move the card to the list left / right | | `Space` | into the done list, or back out to the inbox | -| `Enter` | the card's notes — a document, made on first use; q comes back | +| `Enter` | turn the card over: its labels, description and checklist | | `o · c` | new card on this list | | `D` | due date: fri, tm, +3d, 10-31 … | | `R` | rename the card, or the list when it is empty | @@ -131,6 +131,9 @@ do: `:w!`, `:space! `, `:import!`. | `:due ` | due date of the card — on the board, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears | | `:repeat ` | once the card is done, a copy comes back in the inbox: daily, weekly, monthly, yearly, +3d, +2w, +6m — - stops it | | `:done` | move the selected card into the done list, or back out to the inbox | +| `:task, :step ` | add a step to the open card's checklist, below the cursor | +| `:task!` | reword the step under the cursor | +| `:label ` | put a label on the card, or take it off when it has it — each name keeps its colour everywhere | | `:list ` | add a column to the board | | `:list!` | delete the column under the cursor and every card on it | | `:agenda, :ag` | what is late, due this week, or in hand — across every board | @@ -172,7 +175,7 @@ do: `:w!`, `:space! `, `:import!`. | command | what it does | | --- | --- | -| `:write, :w` | save the open document | +| `:write, :w` | save the open document — or the card's description being written | | `:write!` | save even over a newer version saved somewhere else since this one was opened | | `:wq, :x` | save and leave insert/editor mode | | `:wq!` | save over a newer version, as :w! does, and leave the editor | diff --git a/migrations/0025_card_checklist.sql b/migrations/0025_card_checklist.sql new file mode 100644 index 0000000..fc44ae3 --- /dev/null +++ b/migrations/0025_card_checklist.sql @@ -0,0 +1,8 @@ +-- What a card holds besides its title, the way a card on a paper board does: +-- a checklist of the steps it takes, and labels that say what kind of work +-- it is. Both are small, read with the card and written with it, so they are +-- JSON on the card rather than tables of their own: `checklist` is an array +-- of `{"text": …, "done": …}`, `labels` an array of names. The description is +-- the card's `body`, which it has had from the start. +ALTER TABLE cards ADD COLUMN checklist TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE cards ADD COLUMN labels TEXT NOT NULL DEFAULT '[]'; diff --git a/src/routes/boards.rs b/src/routes/boards.rs index 4c9da81..8989ae1 100644 --- a/src/routes/boards.rs +++ b/src/routes/boards.rs @@ -70,11 +70,83 @@ pub struct Card { pub done_at: Option, /// The rule it comes back by once finished, as `Repeat::name` writes it. pub repeat: Option, + pub checklist: Vec, + pub labels: Vec, pub position: i64, pub created_at: i64, pub updated_at: i64, } +/// A step on a card's checklist. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct Task { + pub text: String, + #[serde(default)] + pub done: bool, +} + +/// The most a card holds of each, so a runaway client cannot grow one row +/// without end. +const MAX_TASKS: usize = 200; +const MAX_LABELS: usize = 12; +const MAX_LABEL_LEN: usize = 32; + +/// A checklist as it is kept: each step trimmed, the empty ones dropped. +fn tidy_checklist(tasks: Vec) -> AppResult> { + let tasks: Vec = tasks + .into_iter() + .filter_map(|task| { + let text = task.text.trim(); + (!text.is_empty()).then(|| Task { + text: text.to_string(), + done: task.done, + }) + }) + .collect(); + if tasks.len() > MAX_TASKS { + return Err(AppError::BadRequest(format!( + "a checklist holds at most {MAX_TASKS} steps" + ))); + } + Ok(tasks) +} + +/// Labels as they are kept: lower case, trimmed, each once, in the order given. +fn tidy_labels(labels: Vec) -> AppResult> { + let mut kept: Vec = Vec::new(); + for label in labels { + let label = label.trim().to_lowercase(); + if label.is_empty() || kept.contains(&label) { + continue; + } + if label.chars().count() > MAX_LABEL_LEN { + return Err(AppError::BadRequest(format!( + "a label is at most {MAX_LABEL_LEN} characters" + ))); + } + kept.push(label); + } + if kept.len() > MAX_LABELS { + return Err(AppError::BadRequest(format!( + "a card carries at most {MAX_LABELS} labels" + ))); + } + Ok(kept) +} + +/// A JSON column read back, or empty when it does not parse: a card with a +/// mangled checklist still shows, which beats the whole board failing. +fn json_column( + row: &sqlx::sqlite::SqliteRow, + name: &str, +) -> T { + serde_json::from_str(row.get::<&str, _>(name)).unwrap_or_default() +} + +fn to_json(value: &T) -> String { + serde_json::to_string(value).expect("a checklist or label list serialises") +} + #[derive(Deserialize)] pub struct ListInput { pub name: String, @@ -107,6 +179,11 @@ pub struct CardPatch { pub due_at: Option>, #[serde(default, deserialize_with = "double_option")] pub repeat: Option>, + /// The whole checklist, as it should now read. + #[serde(default)] + pub checklist: Option>, + #[serde(default)] + pub labels: Option>, pub done: Option, } @@ -229,7 +306,7 @@ async fn agenda_cards(db: &SqlitePool, horizon: i64) -> AppResult Card { due_at: row.get("due_at"), done_at: row.get("done_at"), repeat: row.get("repeat"), + checklist: json_column(row, "checklist"), + labels: json_column(row, "labels"), position: row.get("position"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), @@ -470,8 +549,8 @@ pub(crate) async fn restore( for card in &list.cards { sqlx::query( "INSERT INTO cards (list_id, node_id, title, body, due_at, done_at, repeat, - position, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + checklist, labels, position, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", ) .bind(id) .bind(card.document.as_ref().and_then(|path| documents.get(path))) @@ -487,6 +566,8 @@ pub(crate) async fn restore( .and_then(Repeat::parse) .map(Repeat::name), ) + .bind(to_json(&tidy_checklist(card.checklist.clone())?)) + .bind(to_json(&tidy_labels(card.labels.clone())?)) .bind(position) .bind(card.created_at) .bind(card.updated_at) @@ -541,7 +622,7 @@ async fn load(state: &AppState, space_id: i64, space_name: String) -> AppResult< // is small, and the round trips are what would be felt. let card_rows = sqlx::query( "SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at, - c.repeat, c.position, c.created_at, c.updated_at, + c.repeat, c.checklist, c.labels, c.position, c.created_at, c.updated_at, (SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name FROM cards c JOIN board_lists l ON l.id = c.list_id @@ -814,18 +895,28 @@ pub async fn update_card( Some(None) => None, None => current.repeat.clone(), }; + let checklist = match patch.checklist { + Some(tasks) => tidy_checklist(tasks)?, + None => current.checklist.clone(), + }; + let labels = match patch.labels { + Some(labels) => tidy_labels(labels)?, + None => current.labels.clone(), + }; let mut tx = crate::db::begin_write(&state.db).await?; sqlx::query( "UPDATE cards SET title = ?1, body = ?2, node_id = ?3, due_at = ?4, repeat = ?5, - updated_at = ?6 - WHERE id = ?7", + checklist = ?6, labels = ?7, updated_at = ?8 + WHERE id = ?9", ) .bind(&title) .bind(patch.body.as_ref().unwrap_or(¤t.body)) .bind(patch.node_id.unwrap_or(current.node_id)) .bind(patch.due_at.unwrap_or(current.due_at)) .bind(&repeat) + .bind(to_json(&checklist)) + .bind(to_json(&labels)) .bind(now) .bind(card_id) .execute(&mut *tx) @@ -838,6 +929,8 @@ pub async fn update_card( node_id: patch.node_id.unwrap_or(current.node_id), due_at: patch.due_at.unwrap_or(current.due_at), repeat, + checklist, + labels, ..current }; @@ -1011,8 +1104,9 @@ async fn relocate( } /// The next one of a repeating card that has just been finished, at the -/// bottom of its board's inbox: the same title, notes, document and rule, -/// due on the rule's next date from today on. A card without a date counts +/// bottom of its board's inbox: the same title, notes, document, labels and +/// rule, and the same checklist with every step open again, due on the +/// rule's next date from today on. A card without a date counts /// from the moment it was finished. The finished card gives its rule up, so /// taking it out of done and finishing it again leaves no second copy. async fn come_back( @@ -1038,11 +1132,11 @@ async fn come_back( .fetch_one(&mut *db) .await?; let id: i64 = sqlx::query_scalar( - "INSERT INTO cards (list_id, node_id, title, body, due_at, repeat, position, - created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, + "INSERT INTO cards (list_id, node_id, title, body, due_at, repeat, checklist, labels, + position, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, (SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE list_id = ?1), - ?7, ?7) + ?9, ?9) RETURNING id", ) .bind(inbox) @@ -1051,6 +1145,8 @@ async fn come_back( .bind(&card.body) .bind(due_at) .bind(rule.name()) + .bind(to_json(&reopened(&card.checklist))) + .bind(to_json(&card.labels)) .bind(now) .fetch_one(&mut *db) .await?; @@ -1062,10 +1158,21 @@ async fn come_back( Ok(Some(id)) } +/// A checklist to go through again: the same steps, none of them ticked. +fn reopened(tasks: &[Task]) -> Vec { + tasks + .iter() + .map(|task| Task { + done: false, + ..task.clone() + }) + .collect() +} + async fn card(db: &SqlitePool, id: i64) -> AppResult { let row = sqlx::query( "SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at, - c.repeat, c.position, c.created_at, c.updated_at, + c.repeat, c.checklist, c.labels, c.position, c.created_at, c.updated_at, (SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name FROM cards c WHERE c.id = ?1", ) @@ -1079,7 +1186,10 @@ async fn card(db: &SqlitePool, id: i64) -> AppResult { #[cfg(test)] mod tests { - use super::{agenda_cards, card, done_after_move, relocate, reordered, restore, settle}; + use super::{ + agenda_cards, card, done_after_move, relocate, reordered, restore, settle, tidy_checklist, + tidy_labels, Task, + }; use crate::routes::manifest::{CardMeta, ListMeta}; use std::collections::HashMap; @@ -1132,8 +1242,11 @@ mod tests { let (inbox, doing, done) = (lists[0], lists[1], lists[2]); let day = 86400; let id: i64 = sqlx::query_scalar( - "INSERT INTO cards (list_id, title, body, due_at, repeat, position, created_at, updated_at) - VALUES (?1, 'water the plants', 'the big one too', ?2, 'weekly', 0, 0, 0) RETURNING id", + r#"INSERT INTO cards (list_id, title, body, due_at, repeat, checklist, labels, + position, created_at, updated_at) + VALUES (?1, 'water the plants', 'the big one too', ?2, 'weekly', + '[{"text":"ferns","done":true},{"text":"cactus","done":false}]', + '["home"]', 0, 0, 0) RETURNING id"#, ) .bind(doing) .bind(10 * day + 12 * 3600) @@ -1150,6 +1263,14 @@ mod tests { assert_eq!(copy.due_at, Some(17 * day + 12 * 3600)); assert_eq!(copy.repeat.as_deref(), Some("weekly")); assert_eq!(copy.done_at, None); + // The same steps to go through again, none of them ticked yet. + let steps: Vec<(&str, bool)> = copy + .checklist + .iter() + .map(|t| (t.text.as_str(), t.done)) + .collect(); + assert_eq!(steps, [("ferns", false), ("cactus", false)]); + assert_eq!(copy.labels, ["home"]); assert_eq!(card(&db, id).await.unwrap().repeat, None); assert_eq!(finish(&db, id, inbox, done, 12 * day).await, None); @@ -1210,6 +1331,8 @@ mod tests { due_at: Some(0), done_at: None, repeat: repeat.map(String::from), + checklist: Vec::new(), + labels: Vec::new(), created_at: 0, updated_at: 0, document: None, @@ -1394,6 +1517,34 @@ mod tests { assert!(done_at(db.clone()).await.is_some()); } + #[test] + fn a_checklist_keeps_its_steps_trimmed_and_drops_the_empty_ones() { + let task = |text: &str, done| Task { + text: text.into(), + done, + }; + let kept = tidy_checklist(vec![ + task(" buy paint ", true), + task(" ", false), + task("paint", false), + ]) + .unwrap(); + assert_eq!(kept, [task("buy paint", true), task("paint", false)]); + assert!(tidy_checklist(vec![task("x", false); 201]).is_err()); + } + + #[test] + fn labels_are_lower_case_and_each_once() { + let labels = |names: &[&str]| tidy_labels(names.iter().map(|n| n.to_string()).collect()); + assert_eq!( + labels(&["Bug", " bug ", "", "uni"]).unwrap(), + ["bug", "uni"] + ); + assert!(labels(&[&"x".repeat(33)]).is_err()); + let many: Vec = (0..13).map(|n| n.to_string()).collect(); + assert!(tidy_labels(many).is_err()); + } + #[test] fn arriving_in_the_last_list_finishes_a_card_and_leaving_reopens_it() { let (inbox, doing, done) = (1, 2, 3); diff --git a/src/routes/export.rs b/src/routes/export.rs index 6e8a3b6..36c8a3c 100644 --- a/src/routes/export.rs +++ b/src/routes/export.rs @@ -227,7 +227,8 @@ pub async fn export(_: Session, State(state): State) -> AppResult)> = sqlx::query( - "SELECT list_id, title, body, due_at, done_at, repeat, created_at, updated_at, node_id + "SELECT list_id, title, body, due_at, done_at, repeat, checklist, labels, + created_at, updated_at, node_id FROM cards ORDER BY position, id", ) .fetch_all(&state.db) @@ -240,6 +241,8 @@ pub async fn export(_: Session, State(state): State) -> AppResult, + /// The checklist and the labels, added the same way as `repeat`: an older + /// build imports the card without them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub checklist: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub labels: Vec, pub created_at: i64, pub updated_at: i64, /// The archive path of the document that holds the card's notes. diff --git a/web/bun.lock b/web/bun.lock index ee3b340..e29e971 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -9,6 +9,7 @@ "@codemirror/commands": "^6.7.1", "@codemirror/lang-markdown": "^6.3.1", "@codemirror/language": "^6.10.8", + "@codemirror/language-data": "^6.5.2", "@codemirror/state": "^6.5.1", "@codemirror/view": "^6.36.2", "@fontsource-variable/jetbrains-mono": "^5.3.0", @@ -34,22 +35,62 @@ "@codemirror/commands": ["@codemirror/commands@6.11.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA=="], + "@codemirror/lang-angular": ["@codemirror/lang-angular@0.1.4", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.3" } }, "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g=="], + + "@codemirror/lang-cpp": ["@codemirror/lang-cpp@6.0.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/cpp": "^1.0.0" } }, "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA=="], + "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], + "@codemirror/lang-go": ["@codemirror/lang-go@6.0.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/go": "^1.0.0" } }, "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg=="], + "@codemirror/lang-html": ["@codemirror/lang-html@6.4.12", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w=="], + "@codemirror/lang-java": ["@codemirror/lang-java@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/java": "^1.0.0" } }, "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ=="], + "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], + "@codemirror/lang-jinja": ["@codemirror/lang-jinja@6.0.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.4.0" } }, "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw=="], + + "@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="], + + "@codemirror/lang-less": ["@codemirror/lang-less@6.0.2", "", { "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ=="], + + "@codemirror/lang-liquid": ["@codemirror/lang-liquid@6.3.3", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-U9EnyzWuAx1TX3MZSKDXydKH734n1Aogk/ijKdW+jdIwmbf9zSPQYOIKggC/b2PTRjQb7ddvMktmxnfTLuv/SQ=="], + "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw=="], + "@codemirror/lang-php": ["@codemirror/lang-php@6.0.2", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/php": "^1.0.0" } }, "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA=="], + + "@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="], + + "@codemirror/lang-rust": ["@codemirror/lang-rust@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/rust": "^1.0.0" } }, "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA=="], + + "@codemirror/lang-sass": ["@codemirror/lang-sass@6.0.2", "", { "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/sass": "^1.0.0" } }, "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q=="], + + "@codemirror/lang-sql": ["@codemirror/lang-sql@6.10.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w=="], + + "@codemirror/lang-vue": ["@codemirror/lang-vue@0.1.3", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug=="], + + "@codemirror/lang-wast": ["@codemirror/lang-wast@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q=="], + + "@codemirror/lang-xml": ["@codemirror/lang-xml@6.1.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/xml": "^1.0.0" } }, "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg=="], + + "@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.3", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ=="], + "@codemirror/language": ["@codemirror/language@6.12.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="], + "@codemirror/language-data": ["@codemirror/language-data@6.5.2", "", { "dependencies": { "@codemirror/lang-angular": "^0.1.0", "@codemirror/lang-cpp": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-go": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-java": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/lang-jinja": "^6.0.0", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-less": "^6.0.0", "@codemirror/lang-liquid": "^6.0.0", "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lang-php": "^6.0.0", "@codemirror/lang-python": "^6.0.0", "@codemirror/lang-rust": "^6.0.0", "@codemirror/lang-sass": "^6.0.0", "@codemirror/lang-sql": "^6.0.0", "@codemirror/lang-vue": "^0.1.1", "@codemirror/lang-wast": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.4.0" } }, "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg=="], + + "@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.5", "", { "dependencies": { "@codemirror/streamparser": "^6.0.0" } }, "sha512-9ltT214oMb3Y1w5eAZMSigzrjzZnMI2Wa3Feq73rU/i4C56MkwrpfswfQYOr28C5pXr6dWwspNTMyeHXlzWMxg=="], + "@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], "@codemirror/search": ["@codemirror/search@6.7.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA=="], "@codemirror/state": ["@codemirror/state@6.7.4", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-QhQIVRY+xHZDxwOSFrJ1eUMapJBUID3IdeAjf7dHO7zBUzSkyooHiodnalz5MG3iHzwixKMlAAyn7244y537EA=="], + "@codemirror/streamparser": ["@codemirror/streamparser@6.0.0", "", {}, "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w=="], + "@codemirror/view": ["@codemirror/view@6.43.11", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-2+esucbQX6wB2JYi1eDvdCPFTA31BN8oSy6xCmk3G6CloV11yOvEjYk+gH7kLrP0MuHG94E8WDhjs5oMiu3+Wg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], @@ -120,18 +161,38 @@ "@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], + "@lezer/cpp": ["@lezer/cpp@1.1.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA=="], + "@lezer/css": ["@lezer/css@1.3.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g=="], + "@lezer/go": ["@lezer/go@1.0.1", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ=="], + "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], "@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="], + "@lezer/java": ["@lezer/java@1.1.5", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-JppTd1iKHpeNlLYw/Zl8JX7zSY5HqAzK4zZ05lpn8l8Sk7Fo5KAKiDiMiezKoT5lfeC2xZf8HeKp1tSctj4G6Q=="], + "@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], + "@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="], + "@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], "@lezer/markdown": ["@lezer/markdown@1.7.2", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ=="], + "@lezer/php": ["@lezer/php@1.0.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.1.0" } }, "sha512-QJ2xNXPmsm/Z3IpThq8fnJrEIVpxhsIoj6GZBW0JYEd/l9zxpJZW216X4fzqQY4vgpdGIumypvI4uQjd4tnYtw=="], + + "@lezer/python": ["@lezer/python@1.1.19", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ=="], + + "@lezer/rust": ["@lezer/rust@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ=="], + + "@lezer/sass": ["@lezer/sass@1.1.0", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ=="], + + "@lezer/xml": ["@lezer/xml@1.0.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww=="], + + "@lezer/yaml": ["@lezer/yaml@1.0.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw=="], + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.4", "", {}, "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ=="], "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], diff --git a/web/package.json b/web/package.json index d1d9799..f872a34 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "@codemirror/commands": "^6.7.1", "@codemirror/lang-markdown": "^6.3.1", "@codemirror/language": "^6.10.8", + "@codemirror/language-data": "^6.5.2", "@codemirror/state": "^6.5.1", "@codemirror/view": "^6.36.2", "@fontsource-variable/jetbrains-mono": "^5.3.0", diff --git a/web/src/App.svelte b/web/src/App.svelte index 58f8db3..105cb32 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -17,6 +17,7 @@ import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte' import Trash, { purgeTrashed, restoreTrashed } from './lib/Trash.svelte' import Backlinks, { openBacklink } from './lib/Backlinks.svelte' + import CardSheet from './lib/CardSheet.svelte' import History, { moveHistory, restoreShown } from './lib/History.svelte' import Actions from './lib/Actions.svelte' import Prompt from './lib/Prompt.svelte' @@ -669,7 +670,7 @@ workspace.cardCursor = Math.max((workspace.list?.cards.length ?? 0) - 1, 0) return true case 'Enter': - void workspace.guard(() => workspace.openCard()) + workspace.openCardSheet() return true case 'o': case 'c': @@ -708,6 +709,90 @@ } } + /* + * The card sheet reads the list keys over its checklist, and the board's + * card keys for the card itself — `D`, `H`/`L`, `R` act on it as they do + * on the board under it. While the description is being written the keys + * are the editor's. + */ + function cardKey(event: KeyboardEvent): boolean { + const key = event.key + const previous = pending + pending = '' + if (previous === 'g' && key === 'g') return void workspace.moveTask(-Infinity), true + if (previous === 'g' && key === 'f') { + workspace.overlay = 'none' + void workspace.guard(() => workspace.openCard()) + return true + } + if (previous === 'd' && key === 'd') return void workspace.guard(() => workspace.deleteTask()), true + switch (key) { + case 'g': + case 'd': + pending = key + return true + case 'j': + case 'ArrowDown': + void workspace.moveTask(1) + return true + case 'k': + case 'ArrowUp': + void workspace.moveTask(-1) + return true + case 'G': + void workspace.moveTask(Infinity) + return true + case 'J': + void workspace.guard(() => workspace.nudgeTask(1)) + return true + case 'K': + void workspace.guard(() => workspace.nudgeTask(-1)) + return true + case ' ': + case 'x': + void workspace.guard(() => workspace.toggleTask()) + return true + case 'o': + cmdline = ':task ' + return true + case 'r': { + const task = workspace.card?.checklist[workspace.taskCursor] + if (task) cmdline = `:task! ${task.text}` + return true + } + case 'e': + case 'i': + case 'Enter': + workspace.describe() + return true + case 'a': + cmdline = ':label ' + return true + case 'R': + cmdline = `:name ${workspace.card?.title ?? ''}` + return true + case 'D': + cmdline = ':due ' + return true + case 'H': + void workspace.guard(() => workspace.sendCard(-1)) + return true + case 'L': + void workspace.guard(() => workspace.sendCard(1)) + return true + case ':': + cmdGlobal = false + cmdline = ':' + return true + case 'q': + case 'Escape': + workspace.closeCardSheet() + return true + default: + return false + } + } + function isTypingTarget(target: EventTarget | null) { const node = target as HTMLElement | null return ( @@ -722,7 +807,8 @@ function onkeydown(event: KeyboardEvent) { if (event.key === 's' && (event.metaKey || event.ctrlKey)) { event.preventDefault() - if (workspace.open) void workspace.guard(() => workspace.save()) + if (workspace.describing) void workspace.guard(() => workspace.saveDescription()) + else if (workspace.open) void workspace.guard(() => workspace.save()) return } @@ -742,6 +828,12 @@ // The command line over an overlay — `D` in the agenda — keeps its keys. const listening = cmdline === null && !isTypingTarget(event.target) + if (workspace.overlay === 'card' && !workspace.describing && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { + if (cardKey(event)) { + event.preventDefault() + return + } + } if (workspace.overlay === 'help' && listening && !event.metaKey && !event.altKey) { if (helpKey(event)) { event.preventDefault() @@ -801,6 +893,8 @@ } if (event.key === 'Escape') { + // The description's editor has had its Esc; the sheet stays up under it. + if (workspace.describing) return if (workspace.overlay !== 'none') { workspace.overlay = 'none' return @@ -1285,9 +1379,9 @@ {#if workspace.overlay !== 'none'} -
e.target === e.currentTarget && (workspace.overlay = 'none')} +
e.target === e.currentTarget && (workspace.overlay === 'card' ? workspace.closeCardSheet() : (workspace.overlay = 'none'))} > - {#if workspace.overlay === 'help'}{:else if workspace.overlay === 'overview'}{:else if workspace.overlay === 'actions'}{:else if workspace.overlay === 'verify'}{:else if workspace.overlay === 'outline'}{:else if workspace.overlay === 'trash'}{:else if workspace.overlay === 'history'}{:else if workspace.overlay === 'backlinks'}{:else}{/if} + {#if workspace.overlay === 'help'}{:else if workspace.overlay === 'overview'}{:else if workspace.overlay === 'actions'}{:else if workspace.overlay === 'verify'}{:else if workspace.overlay === 'outline'}{:else if workspace.overlay === 'trash'}{:else if workspace.overlay === 'history'}{:else if workspace.overlay === 'backlinks'}{:else if workspace.overlay === 'card'}{:else}{/if}
{/if}
diff --git a/web/src/app.css b/web/src/app.css index 519c3c5..2d350e4 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,9 +1,11 @@ /* ── palette ─────────────────────────────────────────────────────────────── */ /* * YoRHa dark, the palette of the desktop this runs on (quickshell Theme.qml, - * hypr palette.lua) and of narl.io. A green-black ramp, sage-grey text, and - * only two colours that mean anything: `light` is focus, selection and - * whatever is live; `alarm` is something wrong. Nothing else carries hue. + * hypr palette.lua, nvim's yorha.lua) and of narl.io. A green-black ramp, + * sage-grey text, and only two colours that mean anything: `light` is focus, + * selection and whatever is live; `alarm` is something wrong. The earth hues + * below are the terminal's, pulled down so they sit in the void: they colour + * code, the way nvim colours it, and a card's labels — and nothing else. */ :root { --ink: #090a08; @@ -24,6 +26,17 @@ --light-soft: #bcc1bf; --alarm: #c8684f; + --sand: #c9b27c; + --sand-br: #d8c491; + --moss: #8fa37e; + --moss-br: #a5b893; + --slate: #7f9aa8; + --slate-br: #96afbc; + --heather: #a48ba0; + --heather-br: #b8a0b4; + --dusk: #86a8a0; + --dusk-br: #9cbdb4; + --font-sans: "Noto Sans Variable", "Noto Sans", ui-sans-serif, system-ui, sans-serif; --font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace; @@ -512,7 +525,77 @@ input::placeholder { color: var(--muted); } overflow-x: auto; margin: 0 0 1em; } -.md pre code { background: none; border: 0; padding: 0; font-size: inherit; } +.md pre code { background: none; border: 0; padding: 0; font-size: inherit; color: var(--fg); } +/* Inline code is moss on a lift and a block sits on the hull, as + render-markdown draws them in nvim. */ +.md :not(pre) > code { color: var(--moss); } +.md pre { background: var(--hull); } + +/* A card's label: its hue on a breath of the same, as nvim draws virtual text. */ +.chip { + padding: 0 7px; + border-radius: var(--radius-s); + font-family: var(--font-mono); + font-size: 10px; + line-height: 16px; + letter-spacing: 0.06em; + color: var(--hue); + background: color-mix(in oklab, var(--hue) 14%, transparent); +} +.chip.sand { --hue: var(--sand); } +.chip.moss { --hue: var(--moss); } +.chip.dusk { --hue: var(--dusk); } +.chip.slate { --hue: var(--slate-br); } +.chip.heather { --hue: var(--heather); } + +/* ── syntax ────────────────────────────────────────────────────────────── */ +/* + * nvim's yorha.lua, group for group: structure is sand, names are the text, + * data is moss and heather, shapes are dusk, punctuation recedes into the + * grey. The classes are `highlight.ts`'s, and the same in the editor and in + * a rendered code block, so code reads alike in both. + */ +.tok-comment { color: var(--muted); font-style: italic; } +.tok-keyword { color: var(--sand); } +.tok-string { color: var(--moss); } +.tok-escape { color: var(--dusk-br); } +.tok-regexp { color: var(--dusk); } +.tok-number { color: var(--heather-br); } +.tok-constant { color: var(--heather); } +.tok-builtin { color: var(--heather); font-style: italic; } +.tok-variable { color: var(--fg); } +.tok-parameter { color: var(--subtle); font-style: italic; } +.tok-property { color: var(--slate-br); } +.tok-function { color: var(--light); } +.tok-type { color: var(--dusk); } +.tok-type-builtin { color: var(--dusk); font-style: italic; } +.tok-namespace { color: var(--light-soft); } +.tok-macro { color: var(--heather); } +.tok-tag { color: var(--sand); } +.tok-attribute { color: var(--slate-br); font-style: italic; } +.tok-operator { color: var(--subtle); } +.tok-punctuation { color: var(--muted); } +.tok-invalid { color: var(--alarm); font-weight: 700; } +.tok-inserted { color: var(--moss); } +.tok-deleted { color: var(--alarm); } +.tok-changed { color: var(--sand); } +/* markdown, as @markup.* is drawn */ +.tok-heading1 { color: var(--light); font-weight: 700; } +.tok-heading2 { color: var(--light-soft); font-weight: 700; } +.tok-heading3 { color: var(--fg); font-weight: 700; } +.tok-heading4 { color: var(--subtle); font-weight: 700; } +.tok-heading5 { color: var(--subtle); } +.tok-heading6 { color: var(--muted); } +.tok-strong { color: var(--light); font-weight: 700; } +.tok-emphasis { font-style: italic; } +.tok-strikethrough { text-decoration: line-through; } +.tok-link { color: var(--slate-br); } +.tok-url { color: var(--slate); text-decoration: underline; text-decoration-color: color-mix(in oklab, var(--slate) 50%, transparent); } +.tok-raw { color: var(--moss); } +.tok-quote { color: var(--muted); font-style: italic; } +.tok-list { color: var(--light-dim); } +.tok-mark { color: var(--muted); } +.tok-rule { color: var(--rim); } /* A long equation scrolls on its own rather than widening the page. */ .md .katex-display { overflow-x: auto; overflow-y: hidden; padding: 2px 0; } diff --git a/web/src/lib/Board.svelte b/web/src/lib/Board.svelte index 7721849..9d4bb62 100644 --- a/web/src/lib/Board.svelte +++ b/web/src/lib/Board.svelte @@ -4,9 +4,8 @@ * idiom as the list. The keys live in App.svelte with every other key, so * this file only draws and — for a pointer — moves the two cursors. */ - import type { Card } from './api' import { workspace } from './store.svelte' - import { formatDay } from './util' + import { formatDay, labelHue, urgency } from './util' let columns = $state() @@ -16,19 +15,6 @@ columns?.querySelector(`[data-at="${at}"]`)?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }) - const midnight = () => { - const today = new Date() - today.setHours(0, 0, 0, 0) - return Math.floor(today.getTime() / 1000) - } - - /** `overdue`, `today` or nothing — a date only earns colour when it bites. */ - function urgency(card: Card): '' | 'today' | 'overdue' { - if (card.due_at === null || card.done_at !== null) return '' - const start = midnight() - if (card.due_at < start) return 'overdue' - return card.due_at < start + 86400 ? 'today' : '' - } const total = $derived(workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0) const lastIndex = $derived((workspace.board?.lists.length ?? 0) - 1) @@ -95,15 +81,27 @@ class:done={card.done_at !== null} data-at="{listIndex}:{cardIndex}" onclick={() => pick(listIndex, cardIndex)} - ondblclick={() => workspace.guard(() => workspace.openCard())} - title={card.node_id === null ? 'Enter starts a document for it' : 'Enter opens its document'} + ondblclick={() => workspace.openCardSheet()} + title="Enter turns the card over" > + {#if card.labels.length} + + {#each card.labels as label (label)}{label}{/each} + + {/if} {card.title} - {#if card.due_at !== null || card.node_id !== null || card.repeat !== null} + {#if card.due_at !== null || card.node_id !== null || card.repeat !== null || card.checklist.length || card.body.trim()} + {@const ticked = card.checklist.filter((task) => task.done).length} + {#if card.body.trim()} + ≡ + {/if} + {#if card.checklist.length} + ☑ {ticked}/{card.checklist.length} + {/if} {#if card.due_at !== null} due {formatDay(card.due_at)} {/if} @@ -147,7 +145,7 @@ hjkl move HL send ␣ done - ↵ notes + ↵ open o new D due R rename @@ -286,7 +284,7 @@ .card:hover { background: var(--surface-alt); color: var(--light); border-color: transparent; } .card:active { transform: none; } .card.active, .card:focus-visible { background: var(--light); color: var(--ink); } - .card.active :is(.meta, .today, .overdue, .doc) { color: var(--ink); } + .card.active :is(.meta, .today, .overdue, .doc, .complete) { color: var(--ink); } .card.active .tick { border-color: var(--ink); } .card.active .tick.ticked { background: var(--ink); } .card.done .text { text-decoration: line-through; color: var(--muted); } @@ -304,6 +302,9 @@ .tick.ticked { background: var(--light-dim); border-color: var(--light-dim); } .meta { display: flex; gap: 10px; font-size: 11px; padding-left: 18px; color: var(--muted); } .meta .today { color: var(--light); } + .meta .complete { color: var(--moss); } + .card.active .chip { color: var(--ink); background: color-mix(in oklab, var(--hue) 55%, transparent); } + .labels { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 18px; } .meta .overdue { color: var(--alarm); } .doc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 24ch; } diff --git a/web/src/lib/CardEditor.svelte b/web/src/lib/CardEditor.svelte new file mode 100644 index 0000000..ad0cbd9 --- /dev/null +++ b/web/src/lib/CardEditor.svelte @@ -0,0 +1,118 @@ + + + +
+
+
+ {mode} + {#if workspace.descriptionDirty}+{/if} + :w write · :q leave · :q! discard +
+
+ + diff --git a/web/src/lib/CardSheet.svelte b/web/src/lib/CardSheet.svelte new file mode 100644 index 0000000..5b3739c --- /dev/null +++ b/web/src/lib/CardSheet.svelte @@ -0,0 +1,204 @@ + + +{#if card} +
+
+ + card + {workspace.boardSpace?.slug ?? ''} / {list?.name ?? ''}{#if role}{role}{/if} + + +
+ +
+

{card.title}

+ + {#if card.labels.length || card.due_at !== null || card.repeat !== null || card.node_id !== null} +
+ {#each card.labels as label (label)}{label}{/each} + {#if card.due_at !== null}due {formatDay(card.due_at)}{/if} + {#if card.repeat !== null}↻ {card.repeat}{/if} + {#if card.node_id !== null} + + {/if} +
+ {/if} + +
+
description
+ {#if workspace.describing} + {#await import('./CardEditor.svelte')} +
loading editor
+ {:then module} + + {/await} + {:else if html} + +
workspace.describe()}> + + {@html html} +
+ {:else} + + {/if} +
+ +
+
+ checklist + {#if card.checklist.length} + {ticked}/{card.checklist.length} + {/if} +
+ {#if card.checklist.length} +
+ {/if} +
    + {#each card.checklist as task, index (index)} + +
  • pick(index)}> + + {task.text} +
  • + {:else} +
  • no steps — o adds one
  • + {/each} +
+
+
+ +
+ jk step + ␣ tick + o add + r reword + JK order + dd drop + e describe + a label + D due + HL send + gf notes + q close +
+
+{/if} + + diff --git a/web/src/lib/Editor.svelte b/web/src/lib/Editor.svelte index cd553a8..b8534b1 100644 --- a/web/src/lib/Editor.svelte +++ b/web/src/lib/Editor.svelte @@ -120,7 +120,7 @@ Vim.mapCommand('gT', 'action', 'workspacePrevTile', {}, { context: 'normal' }) } - function bindVim() { + export function bindVim() { if (vimBound) return vimBound = true bridgeExCommands() @@ -136,7 +136,7 @@ import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete' import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' - import { HighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language' + import { indentOnInput } from '@codemirror/language' import { EditorState } from '@codemirror/state' import { drawSelection, @@ -145,10 +145,10 @@ keymap, placeholder, } from '@codemirror/view' - import { tags } from '@lezer/highlight' import { onDestroy, onMount } from 'svelte' import { api } from './api' import { hooks } from './commands' + import { editorTheme, highlighting, languages } from './editorTheme' import { embedSnippet } from './markdown' import { workspace, type Tile } from './store.svelte' @@ -159,71 +159,6 @@ let view: EditorView | undefined const focus = () => view?.focus() - const markdownHighlight = HighlightStyle.define([ - { tag: tags.heading, color: 'var(--light)', fontWeight: '600' }, - { tag: tags.strong, color: 'var(--light)', fontWeight: '700' }, - { tag: tags.emphasis, color: 'var(--subtle)', fontStyle: 'italic' }, - { tag: tags.link, color: 'var(--light)', textDecoration: 'underline', textDecorationColor: 'var(--overlay)' }, - { tag: tags.url, color: 'var(--light-dim)' }, - { tag: tags.monospace, color: 'var(--light-soft)', backgroundColor: 'var(--surface)' }, - { tag: tags.quote, color: 'var(--subtle)', fontStyle: 'italic' }, - { tag: tags.list, color: 'var(--light-dim)' }, - { tag: tags.contentSeparator, color: 'var(--muted)' }, - { tag: tags.processingInstruction, color: 'var(--muted)' }, - ]) - - /* - * CodeMirror's base theme paints panels and the caret for a light editor - * (`&light .cm-panels { color: black }`), and those selectors outrank - * anything the app stylesheet can say. Theme rules do win, so the parts that - * would otherwise render black on black live here rather than in app.css. - * `dark: true` also stops the light defaults applying in the first place. - */ - const editorTheme = EditorView.theme( - { - '&': { color: 'var(--fg)', backgroundColor: 'transparent', height: '100%' }, - '.cm-content': { padding: '12px 28px', maxWidth: '90ch' }, - '.cm-line': { padding: '0' }, - '.cm-cursor, .cm-dropCursor': { borderLeft: '2px solid var(--light)' }, - '.cm-panels': { - backgroundColor: 'var(--hull)', - color: 'var(--fg)', - borderColor: 'var(--rim)', - }, - '.cm-panels-bottom': { borderTop: '1px solid var(--rim)' }, - /* - * The `:` prompt is a bare text node in the panel; the input follows it. - * The input is an inline-block whose baseline sits below the text node's, - * so the two are laid out as flex items instead of aligned by baseline. - */ - '.cm-vim-panel': { - padding: '4px 12px', - color: 'var(--light)', - display: 'flex', - alignItems: 'center', - }, - '.cm-tooltip': { - backgroundColor: 'var(--hull)', - color: 'var(--fg)', - border: '1px solid var(--rim)', - }, - '.cm-tooltip-autocomplete > ul > li[aria-selected]': { - backgroundColor: 'var(--surface-alt)', - color: 'var(--light)', - }, - '.cm-completionDetail': { color: 'var(--muted)', fontStyle: 'normal', marginLeft: '1ch' }, - '.cm-vim-panel input': { - fontFamily: 'inherit', - fontSize: 'inherit', - lineHeight: 'inherit', - color: 'var(--light)', - caretColor: 'var(--light)', - flex: '1', - }, - }, - { dark: true }, - ) - /* * `[[` offers the titles of documents as the search finds them, each with * where it sits; picking one closes the brackets. @@ -298,9 +233,9 @@ indentOnInput(), highlightActiveLine(), EditorState.allowMultipleSelections.of(true), - markdown({ base: markdownLanguage, codeLanguages: [] }), + markdown({ base: markdownLanguage, codeLanguages: languages }), autocompletion({ override: [titleCompletions], icons: false }), - syntaxHighlighting(markdownHighlight), + highlighting, EditorView.lineWrapping, placeholder('write…'), editorTheme, diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 69145d0..60447d2 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -106,7 +106,7 @@ ['J / K', 'move the card down / up its list'], ['H / L', 'move the card to the list left / right'], ['Space', 'into the done list, or back out to the inbox'], - ['Enter', "the card's notes — a document, made on first use; q comes back"], + ['Enter', 'turn the card over: its labels, description and checklist'], ['o · c', 'new card on this list'], ['D', 'due date: fri, tm, +3d, 10-31 …'], ['R', 'rename the card, or the list when it is empty'], @@ -117,6 +117,22 @@ ['q · Esc', 'leave the special workspace'], ] + const cardKeys: [string, string][] = [ + ['j / k', 'down / up the checklist'], + ['gg / G', 'first / last step'], + ['Space · x', 'tick the step, or untick it'], + ['o', 'a new step below the cursor — :task on the line'], + ['r', 'reword the step'], + ['J / K', 'move the step down / up'], + ['dd', 'drop the step'], + ['e · i · Enter', 'write the description, in the editor — :w writes it, :q or Esc in normal mode leaves'], + ['a', 'put on a label, or take it off — :label on the line, Tab offers the board\'s'], + ['R · D', 'rename the card, its due date'], + ['H / L', 'send the card to the list left / right'], + ['gf', "the card's notes — a document, made on first use; q comes back"], + ['q · Esc', 'back to the board'], + ] + const agendaKeys: [string, string][] = [ ['j / k', 'down / up the cards, then what was written on this day in earlier years, then into the calendar'], ['h', 'to the board'], @@ -159,6 +175,7 @@ ['vim', 'the full keymap — hjkl, w/b, dd, ciw, visual mode, macros; :w writes'], ['drop · paste', 'an image, video or audio file attaches it'], ['$x^2$ · $$…$$', 'set as math'], + ['```rust', 'a fenced block in a language is coloured as nvim colours it, written and read'], ['```typst', 'a block typst sets, shown as an image — math, tables, figures; no packages or files'], [':12', 'jumps straight to entry 12'], ['y · d', 'copied to the system clipboard — :set noclipboard keeps them in the registers'], @@ -168,6 +185,7 @@ const sections: [string, Row[]][] = [ ['keys', fromPairs(keys)], ['board keys', fromPairs(boardKeys)], + ['card keys', fromPairs(cardKeys)], ['agenda keys', fromPairs(agendaKeys)], ['in the editor', fromPairs(editorKeys)], ...groups.map((group: Group) => [titles[group], fromSpecs(commands.filter((spec) => spec.group === group))] as [string, Row[]]), diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 7cd9473..3f6c1e4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -170,11 +170,20 @@ export interface Card { done_at: number | null /** `daily`, `weekly`, `monthly`, `yearly`, `+3d`, `+2w`, `+6m` — or none. */ repeat: string | null + checklist: Task[] + /** Lower case, each once. */ + labels: string[] position: number created_at: number updated_at: number } +/** A step on a card's checklist. */ +export interface Task { + text: string + done: boolean +} + /** A card after a move or an edit, and the copy a repeating card it finished left in the inbox. */ export interface Moved extends Card { again: Card | null @@ -564,6 +573,9 @@ export const api = { node_id?: number | null due_at?: number | null repeat?: string | null + /** The whole checklist, as it should now read. */ + checklist?: Task[] + labels?: string[] done?: boolean }, ) => request(`/cards/${id}`, { method: 'PUT', body: body(patch) }), diff --git a/web/src/lib/code.svelte.ts b/web/src/lib/code.svelte.ts new file mode 100644 index 0000000..032415b --- /dev/null +++ b/web/src/lib/code.svelte.ts @@ -0,0 +1,68 @@ +/* + * A fenced code block, coloured the way the editor colours it: the same + * parser for the language named after the fence, the same classes. Each + * language is fetched the first time a block on the page is written in it, + * as KaTeX is for math; until it arrives the block shows plain. The languages + * are state, so markdown rendered without one renders again once it is here. + */ +import type { Language } from '@codemirror/language' + +/** What colours a block once there is one to colour, fetched with its first language. */ +type Engine = { highlightCode: typeof import('@lezer/highlight').highlightCode; highlighter: typeof import('./highlight').highlighter } +let engine: Engine | null = null + +/** By the name after the fence; null for a name no language answers to. */ +let loaded = $state.raw(new Map()) +const asked = new Set() + +/** The block as coloured HTML, or null while its language loads or when there is none. */ +export function highlighted(source: string, fence: string): string | null { + const name = fence.trim().split(/\s/)[0].toLowerCase() + if (!name) return null + const language = loaded.get(name) + if (language === undefined) { + load(name) + return null + } + if (language === null || !engine) return null + + const { highlightCode, highlighter } = engine + let html = '' + highlightCode( + source, + language.parser.parse(source), + highlighter, + (text, classes) => (html += classes ? `${escape(text)}` : escape(text)), + () => (html += '\n'), + ) + return html +} + +function load(name: string) { + if (asked.has(name)) return + asked.add(name) + const settle = (language: Language | null) => (loaded = new Map(loaded).set(name, language)) + // The list of languages and the parsers' machinery come with CodeMirror, + // which the reader does not otherwise need, so they are fetched with the + // first language. + Promise.all([ + import('@codemirror/language'), + import('@codemirror/language-data'), + import('@lezer/highlight'), + import('./highlight'), + ]) + .then(([{ LanguageDescription }, { languages }, { highlightCode }, { highlighter }]) => { + engine = { highlightCode, highlighter } + const description = LanguageDescription.matchLanguageName(languages, name, true) + return description ? description.load().then((support) => support.language) : null + }) + .then(settle) + .catch((e) => { + console.error(`code: could not load ${name}`, e) + settle(null) + }) +} + +function escape(text: string): string { + return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') +} diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 2f14acf..9069530 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -298,8 +298,8 @@ export const commands: CommandSpec[] = [ name: 'write', aliases: ['w'], group: 'editing', - help: 'save the open document', - run: () => workspace.guard(() => workspace.save()), + help: "save the open document — or the card's description being written", + run: () => workspace.guard(() => (workspace.describing ? workspace.saveDescription() : workspace.save())), bang: { help: 'save even over a newer version saved somewhere else since this one was opened', run: () => workspace.guard(() => workspace.save({ force: true })), @@ -311,6 +311,11 @@ export const commands: CommandSpec[] = [ group: 'editing', help: 'save and leave insert/editor mode', run: async () => { + if (workspace.describing) { + await workspace.guard(() => workspace.saveDescription()) + workspace.stopDescribing() + return + } await workspace.guard(() => workspace.save()) workspace.editing = false hooks.focusList() @@ -330,6 +335,7 @@ export const commands: CommandSpec[] = [ group: 'editing', help: 'close the editor, or the document when already reading', run: async () => { + if (workspace.describing) return void workspace.stopDescribing() // A document `o` made and nothing was written in is not kept. if (await workspace.abandonIfBlank()) { if (workspace.returnTo === 'special') await workspace.guard(() => workspace.returnToSpecial()) @@ -348,6 +354,7 @@ export const commands: CommandSpec[] = [ bang: { help: 'discard changes and close the editor', run: async () => { + if (workspace.describing) return void workspace.stopDescribing(true) const open = workspace.open workspace.editing = false workspace.dirty = false @@ -508,6 +515,38 @@ export const commands: CommandSpec[] = [ return workspace.guard(() => workspace.toggleDone()) }, }, + { + name: 'task', + aliases: ['step'], + args: '', + group: 'board', + help: "add a step to the open card's checklist, below the cursor", + run: async (arg) => { + if (workspace.overlay !== 'card') return workspace.say('no card open — Enter on the board opens one', 'error') + if (!arg.trim()) return workspace.say('usage: :task ', 'error') + await workspace.guard(() => workspace.addTask(arg.trim())) + }, + bang: { + help: 'reword the step under the cursor', + run: async (arg) => { + if (workspace.overlay !== 'card') return workspace.say('no card open — Enter on the board opens one', 'error') + if (!arg.trim()) return workspace.say('usage: :task! ', 'error') + await workspace.guard(() => workspace.renameTask(arg.trim())) + }, + }, + }, + { + name: 'label', + args: '', + group: 'board', + help: 'put a label on the card, or take it off when it has it — each name keeps its colour everywhere', + complete: () => workspace.boardLabels, + run: async (arg) => { + if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error') + if (!arg.trim()) return workspace.say(workspace.card?.labels.join(' · ') || 'no labels — :label adds one') + await workspace.guard(() => workspace.toggleLabel(arg)) + }, + }, { name: 'list', args: '', diff --git a/web/src/lib/editorTheme.ts b/web/src/lib/editorTheme.ts new file mode 100644 index 0000000..03c2f7a --- /dev/null +++ b/web/src/lib/editorTheme.ts @@ -0,0 +1,65 @@ +/* + * What every editor on the page looks like: the frame of it, and the colours + * of what is written in it, in any language a fence names. + */ +import { syntaxHighlighting } from '@codemirror/language' +import { languages } from '@codemirror/language-data' +import { EditorView } from '@codemirror/view' +import { highlighter } from './highlight' + +/** The languages a fence can be written in, each fetched the first time one is. */ +export { languages } + +export const highlighting = syntaxHighlighting(highlighter) + +/* + * CodeMirror's base theme paints panels and the caret for a light editor + * (`&light .cm-panels { color: black }`), and those selectors outrank + * anything the app stylesheet can say. Theme rules do win, so the parts that + * would otherwise render black on black live here rather than in app.css. + * `dark: true` also stops the light defaults applying in the first place. + */ +export const editorTheme = EditorView.theme( + { + '&': { color: 'var(--fg)', backgroundColor: 'transparent', height: '100%' }, + '.cm-content': { padding: '12px 28px', maxWidth: '90ch' }, + '.cm-line': { padding: '0' }, + '.cm-cursor, .cm-dropCursor': { borderLeft: '2px solid var(--light)' }, + '.cm-panels': { + backgroundColor: 'var(--hull)', + color: 'var(--fg)', + borderColor: 'var(--rim)', + }, + '.cm-panels-bottom': { borderTop: '1px solid var(--rim)' }, + /* + * The `:` prompt is a bare text node in the panel; the input follows it. + * The input is an inline-block whose baseline sits below the text node's, + * so the two are laid out as flex items instead of aligned by baseline. + */ + '.cm-vim-panel': { + padding: '4px 12px', + color: 'var(--light)', + display: 'flex', + alignItems: 'center', + }, + '.cm-tooltip': { + backgroundColor: 'var(--hull)', + color: 'var(--fg)', + border: '1px solid var(--rim)', + }, + '.cm-tooltip-autocomplete > ul > li[aria-selected]': { + backgroundColor: 'var(--surface-alt)', + color: 'var(--light)', + }, + '.cm-completionDetail': { color: 'var(--muted)', fontStyle: 'normal', marginLeft: '1ch' }, + '.cm-vim-panel input': { + fontFamily: 'inherit', + fontSize: 'inherit', + lineHeight: 'inherit', + color: 'var(--light)', + caretColor: 'var(--light)', + flex: '1', + }, + }, + { dark: true }, +) diff --git a/web/src/lib/highlight.ts b/web/src/lib/highlight.ts new file mode 100644 index 0000000..c8ac748 --- /dev/null +++ b/web/src/lib/highlight.ts @@ -0,0 +1,55 @@ +/* + * The colours of code, as classes — `tok-keyword`, `tok-string` — painted in + * app.css from nvim's yorha.lua. The editor (editorTheme.ts) and a rendered + * code block (code.svelte.ts) are both given these, so a fence reads the same + * written and read. Only lezer here: the reader loads it with the app, and + * CodeMirror comes only with an editor. + */ +import { tagHighlighter, tags as t } from '@lezer/highlight' + +/** Lezer's tags onto yorha.lua's groups. The most specific tag that matches wins. */ +export const highlighter = tagHighlighter([ + { tag: t.comment, class: 'tok-comment' }, + { tag: [t.keyword, t.labelName], class: 'tok-keyword' }, + { tag: [t.string, t.character, t.attributeValue], class: 'tok-string' }, + { tag: [t.escape, t.special(t.string)], class: 'tok-escape' }, + { tag: t.regexp, class: 'tok-regexp' }, + { tag: [t.number, t.bool, t.null, t.unit], class: 'tok-number' }, + { tag: [t.atom, t.constant(t.variableName), t.constant(t.propertyName)], class: 'tok-constant' }, + { tag: [t.self, t.standard(t.variableName)], class: 'tok-builtin' }, + { tag: t.variableName, class: 'tok-variable' }, + { tag: [t.propertyName, t.definition(t.propertyName)], class: 'tok-property' }, + { + tag: [t.function(t.variableName), t.function(t.propertyName), t.function(t.definition(t.variableName))], + class: 'tok-function', + }, + { tag: [t.typeName, t.className], class: 'tok-type' }, + { tag: t.standard(t.typeName), class: 'tok-type-builtin' }, + { tag: t.namespace, class: 'tok-namespace' }, + { tag: [t.macroName, t.meta, t.annotation], class: 'tok-macro' }, + { tag: t.tagName, class: 'tok-tag' }, + { tag: t.attributeName, class: 'tok-attribute' }, + { tag: t.operator, class: 'tok-operator' }, + { tag: [t.punctuation, t.angleBracket], class: 'tok-punctuation' }, + { tag: t.invalid, class: 'tok-invalid' }, + { tag: t.inserted, class: 'tok-inserted' }, + { tag: t.deleted, class: 'tok-deleted' }, + { tag: t.changed, class: 'tok-changed' }, + + { tag: [t.heading, t.heading1], class: 'tok-heading1' }, + { tag: t.heading2, class: 'tok-heading2' }, + { tag: t.heading3, class: 'tok-heading3' }, + { tag: t.heading4, class: 'tok-heading4' }, + { tag: t.heading5, class: 'tok-heading5' }, + { tag: t.heading6, class: 'tok-heading6' }, + { tag: t.strong, class: 'tok-strong' }, + { tag: t.emphasis, class: 'tok-emphasis' }, + { tag: t.strikethrough, class: 'tok-strikethrough' }, + { tag: t.link, class: 'tok-link' }, + { tag: t.url, class: 'tok-url' }, + { tag: t.monospace, class: 'tok-raw' }, + { tag: t.quote, class: 'tok-quote' }, + { tag: t.list, class: 'tok-list' }, + { tag: t.processingInstruction, class: 'tok-mark' }, + { tag: t.contentSeparator, class: 'tok-rule' }, +]) diff --git a/web/src/lib/markdown.ts b/web/src/lib/markdown.ts index d2fbc8d..689bcfe 100644 --- a/web/src/lib/markdown.ts +++ b/web/src/lib/markdown.ts @@ -1,5 +1,6 @@ import DOMPurify from 'dompurify' import { marked, type TokenizerAndRendererExtension } from 'marked' +import { highlighted } from './code.svelte' import { mathEngine } from './math.svelte' import { typeset } from './typst.svelte' import { hardWrapped, titleHref } from './util' @@ -94,7 +95,9 @@ marked.use({ code(token) { if (token.lang === 'math') return tex(token.text, true) if (token.lang === 'typst' || token.lang === 'typ') return typst(token.text) - return false + const html = token.lang ? highlighted(token.text, token.lang) : null + if (html === null) return false + return `
${html}
\n` }, }, }) diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 7ca4ac8..bb91dbf 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -17,6 +17,7 @@ import { type Revision, type RevisionSummary, type SpaceOverview, + type Task, type TrashItem, type VaultStatus, } from './api' @@ -282,6 +283,13 @@ class Workspace { }) listCursor = $state(0) cardCursor = $state(0) + /** The step of the open card's checklist under the cursor. */ + taskCursor = $state(0) + /** The open card's description is being written, and what it says so far. */ + describing = $state(false) + description = $state('') + descriptionDirty = $state(false) + /** `z` on the board: show done cards older than a week too. */ showOldDone = $state(false) /* @@ -296,7 +304,7 @@ class Workspace { pane = $state('list') flash = $state(null) - overlay = $state<'none' | 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'outline' | 'trash' | 'history' | 'backlinks'>('none') + overlay = $state<'none' | 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'outline' | 'trash' | 'history' | 'backlinks' | 'card'>('none') /** Which of the special workspace's tiles the keys go to. */ specialFocus = $state('board') /** @@ -1679,6 +1687,153 @@ class Workspace { this.say(`deleted "${card.title}"`) } + /* + * The card sheet: one card opened up over its board, the way a card on a + * paper board is turned over to read its back — the description, the + * checklist, the labels. It is the card under the board's cursor, so the + * board keys that act on a card (`D`, `H`/`L`, `:repeat`) act on it here + * too, and the cursor follows it wherever a move puts it. + */ + + openCardSheet() { + if (!this.card) return this.say('no card selected', 'error') + this.taskCursor = 0 + this.describing = false + this.overlay = 'card' + } + + /** `q`: the sheet away, unless that would lose a description being written. */ + closeCardSheet() { + if (this.describing && !this.stopDescribing()) return + this.overlay = 'none' + } + + /** `e`: write the description, from what it says now. */ + describe() { + const card = this.card + if (!card) return + this.description = card.body + this.descriptionDirty = false + this.describing = true + } + + async saveDescription() { + const card = this.card + if (!card || !this.describing) return + await this.patchCard({ body: this.description }) + this.descriptionDirty = false + this.say(`"${card.title}" described`) + } + + /** Stop writing; false, and a word why, while something is unsaved. */ + stopDescribing(discard = false): boolean { + if (this.descriptionDirty && !discard) { + this.say('unsaved description — :w to write, :q! to discard', 'error') + return false + } + this.describing = false + this.descriptionDirty = false + return true + } + + /* + * The checklist and the labels are written back whole, so two keys pressed + * faster than the board reloads would each start from the card as it was, + * and the second would undo the first. Each change waits for the one + * before it, and reads the card as that one left it. + */ + private cardWrites: Promise = Promise.resolve() + private inTurn(change: () => Promise): Promise { + const next = this.cardWrites.then(change) + this.cardWrites = next.catch(() => {}) + return next.then(() => {}) + } + + /** The checklist written back whole, with the cursor on step `at`. */ + async writeChecklist(checklist: Task[], at = this.taskCursor) { + await this.patchCard({ checklist }) + this.taskCursor = Math.min(Math.max(at, 0), Math.max(checklist.length - 1, 0)) + } + + /** `o` / `O`: a new step below or above the cursor. */ + addTask(text: string, above = false) { + return this.inTurn(async () => { + const card = this.card + if (!card) return this.say('no card selected', 'error') + const tasks = [...card.checklist] + const at = tasks.length === 0 ? 0 : above ? this.taskCursor : this.taskCursor + 1 + tasks.splice(at, 0, { text, done: false }) + await this.writeChecklist(tasks, at) + }) + } + + /** Space: tick the step under the cursor, or untick it. */ + toggleTask() { + return this.inTurn(async () => { + const tasks = this.card?.checklist ?? [] + const task = tasks[this.taskCursor] + if (!task) return this.say('no step here — o adds one', 'error') + const next = tasks.map((t, i) => (i === this.taskCursor ? { ...t, done: !t.done } : t)) + await this.writeChecklist(next) + this.say(`${next.filter((t) => t.done).length}/${next.length} done`) + }) + } + + renameTask(text: string) { + return this.inTurn(async () => { + const tasks = this.card?.checklist ?? [] + if (!tasks[this.taskCursor]) return this.say('no step here — o adds one', 'error') + await this.writeChecklist(tasks.map((t, i) => (i === this.taskCursor ? { ...t, text } : t))) + }) + } + + deleteTask() { + return this.inTurn(async () => { + const tasks = this.card?.checklist ?? [] + const task = tasks[this.taskCursor] + if (!task) return this.say('no step here', 'error') + await this.writeChecklist(tasks.filter((_, i) => i !== this.taskCursor)) + this.say(`dropped "${task.text}"`) + }) + } + + /** `J` / `K`: the step takes the cursor with it. */ + nudgeTask(delta: number) { + return this.inTurn(async () => { + const tasks = [...(this.card?.checklist ?? [])] + const to = this.taskCursor + delta + if (!tasks[this.taskCursor] || to < 0 || to >= tasks.length) return + ;[tasks[this.taskCursor], tasks[to]] = [tasks[to], tasks[this.taskCursor]] + await this.writeChecklist(tasks, to) + }) + } + + /** `j`/`k`, and `gg`/`G` as ±Infinity — in turn too, so a move lands on the steps as written. */ + moveTask(delta: number) { + return this.inTurn(async () => { + const count = this.card?.checklist.length ?? 0 + this.taskCursor = Math.min(Math.max(this.taskCursor + delta, 0), Math.max(count - 1, 0)) + }) + } + + /** `:label work`: on the card, or off it when it is on already. */ + toggleLabel(name: string) { + return this.inTurn(async () => { + const card = this.card + if (!card) return this.say('no card selected', 'error') + const label = name.trim().toLowerCase() + const on = card.labels.includes(label) + await this.patchCard({ labels: on ? card.labels.filter((l) => l !== label) : [...card.labels, label] }) + this.say(on ? `${label} off` : `${label} on`) + }) + } + + /** Every label on the board, for Tab on `:label`. */ + get boardLabels(): string[] { + const all = (this.rawBoard?.lists ?? []).flatMap((list) => list.cards.flatMap((card) => card.labels)) + return [...new Set(all)].sort() + } + async createList(name: string) { if (this.boardSpaceId === null) return this.rawBoard = await api.createList(this.boardSpaceId, name) diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index 25ecd0f..30f8189 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -362,3 +362,25 @@ export function hitParts(excerpt: string): { text: string; lit: boolean }[] { } return parts } + +/* + * A label's colour, from its name alone, so the same label is the same hue on + * every card and every board without anything to set. The hues are the + * terminal's earth tones; rust stays out, because rust means wrong. + */ +const LABEL_HUES = ['sand', 'moss', 'dusk', 'slate', 'heather'] as const +export function labelHue(name: string): (typeof LABEL_HUES)[number] { + let hash = 0 + for (const char of name) hash = (hash * 31 + char.codePointAt(0)!) >>> 0 + return LABEL_HUES[hash % LABEL_HUES.length] +} + +/** `overdue`, `today` or nothing — a card's date only earns colour when it bites. */ +export function urgency(card: { due_at: number | null; done_at: number | null }): '' | 'today' | 'overdue' { + if (card.due_at === null || card.done_at !== null) return '' + const today = new Date() + today.setHours(0, 0, 0, 0) + const start = Math.floor(today.getTime() / 1000) + if (card.due_at < start) return 'overdue' + return card.due_at < start + 86400 ? 'today' : '' +}