Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "writui"
version = "0.16.0"
version = "0.17.0"
edition = "2024"
description = "A terminal writing app. No nonsense. Markdown. Local. Encrypted."
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ the password each time it starts.
- **Posts** are listed with the most recently changed first. Open one with
Enter or a click, start a new one with `n` (or Enter: "New" is
highlighted when the list opens). The list's shortcuts are single keys,
shown next to "New" and "Import" and at the bottom. Esc asks before quitting. writui opens
shown next to "New" and "Import" and at the bottom. Esc quits. writui opens
where you left it: on the post you were writing, cursor and all.
- **Search:** press `/` (or click "Type to filter (/)") and type. Posts with
every word you type, anywhere in their text or front matter, are listed,
Expand Down
4 changes: 3 additions & 1 deletion docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ The creator's batch of list / editor ideas shipped in `0.12.0`, and the
`writui info` and Select All in `0.14.0`, Tab cycling the sort and
Ctrl/Cmd+arrow jumps in `0.15.0`, and in `0.16.0` the logo as a big banner
on the password screens, boxed password fields, Esc to clear or quit, and
the whole repo run through `cargo fmt` (checked in CI). Next:
the whole repo run through `cargo fmt` (checked in CI). `0.17.0` narrows
the logo, adds an Unlock button, trims the setup screen, and makes Esc on
the list quit without asking. Next:

1. **Try online** (writui.com/try), below: the creator wants it next, and
it must work on a phone. Probably two or three PRs, in the order listed.
Expand Down
32 changes: 27 additions & 5 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md).
column. Windows narrower than that, or too short to fit it, get the plain
"writui" title instead.

> can you try making "writui" less wide? also, i think some space is
> needed between the logo and the password box. think of the aesthetics

The letters are narrow (the name is 42 columns, not 54), and a blank line
under the logo leaves two rows before what follows.

> put a box around the password

> and esc to clear password, and if clear esc to quit
Expand All @@ -178,6 +184,12 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md).
vault" puts its two side by side, so the banner still fits 30 rows). Esc
clears what's typed; with nothing typed, Esc quits.

> and to the right of the password box add a clickable "Unlock" button

- The unlock screen has an "Unlock" button right of the password box, as
tall as it, dimmed until something's typed. A window too narrow for it
leaves it out (Enter still unlocks).

> and finally, on the password screen, let's add an "Overview" describing
> how the app works in no more than 5-6 points, each no longer than one
> line. this is for new users and returning ones, run the copy by me once
Expand All @@ -186,10 +198,15 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md).
"Overview" (creator-approved copy):
- All your writing lives in one file, encrypted with this password.
- Forget the password and the writing is gone: there's no recovery.
- Write in markdown. Every post starts with a # title.
- Write your posts in markdown. Every post starts with a # title.
- Posts save as you type; Ctrl+S also keeps a version to go back to.
- While writing, Ctrl+K shows every command and its shortcut.
- Ctrl+O locks writui; unlocking takes you back where you were.

> you know, with the overview in palce, we dont need the initla para

So "Create your vault" no longer has its own paragraph about the
password being unrecoverable: the Overview's first two points say it.
- Change password — planned.

## The screens
Expand Down Expand Up @@ -245,9 +262,14 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md).
> can you add j/k support for down and up on the list page (while i'm
> not typing in the filter box)?

> no need to mention them in the bottom menu Ctrl+N and Ctrl+D still work but aren't shown. Esc asks "Quit
writui?" (Quit is the selected button, `y`); Ctrl+Q and Ctrl+C still quit
straight away, unshown.
> no need to mention them in the bottom menu Ctrl+N and Ctrl+D still work but aren't shown. Esc quits; Ctrl+Q
and Ctrl+C also quit, unshown.

> also remove the quit confirmation on th elist screen on pressing esc, i
> think it's not necessary

(Esc used to ask "Quit writui?" first. Everything is saved as you type,
so there's nothing to lose.)

> add a line gap above type to filter. and below type to filter, i want
> "Sort by": "Updated", "Created", "Title". for filter use the maginifier
Expand Down Expand Up @@ -282,7 +304,7 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md).
matches, Delete deletes it. Esc empties the field, then leaves it (to
the first post). The empty field is just a row ↑/↓ pass through; a
click on a post leaves the field too. On the posts, Esc empties the
search before it asks to quit.
search before it quits.

> let's put "Sort by: Updated " on the right of the "type to filter"
> (within the main viewport), and make it a dropdown that i can click and
Expand Down
28 changes: 4 additions & 24 deletions src/tui/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,7 @@ enum Cmd {
UpdateImported,
ImportAsNew,
Delete,
/// Esc on the list: asks first.
Quit,
ConfirmQuit,
Back,
ConfirmDelete,
CancelDialog,
Expand Down Expand Up @@ -838,21 +836,7 @@ impl List {

fn run(&mut self, cmd: Cmd) -> Action {
match cmd {
Cmd::Quit => {
let dialog = Dialog::new(
"Quit",
vec![Line::from("Quit writui?"), Line::from("Your writing is saved.".dim())],
vec![
Button { label: "Quit", key: "y", cmd: Cmd::ConfirmQuit, danger: false },
Button { label: "Cancel", key: "n", cmd: Cmd::CancelDialog, danger: false },
],
0,
1,
);
self.dialog = Some((0, dialog));
Action::None
}
Cmd::ConfirmQuit => Action::Quit,
Cmd::Quit => Action::Quit,
Cmd::Lock => Action::Lock,
Cmd::New if !self.trash_view() => Action::NewPost,
Cmd::Settings if !self.trash_view() => Action::ShowSettings,
Expand Down Expand Up @@ -1189,7 +1173,7 @@ mod tests {
}

#[test]
fn single_keys_and_esc_asks_before_quitting() {
fn single_keys_and_esc_quits() {
let posts = vec![post(1, "One"), post(2, "Two")];
let mut list = List::new(Mode::Posts { deleted_count: 0 }, posts, None);
let text = screen(&mut list);
Expand All @@ -1215,13 +1199,9 @@ mod tests {
press(&mut list, KeyCode::Char('d'), KeyModifiers::NONE);
assert!(screen(&mut list).contains("Move “One” to the Trash?"));
press(&mut list, KeyCode::Esc, KeyModifiers::NONE);
// Esc: asks; n keeps going, y quits.
assert!(matches!(press(&mut list, KeyCode::Esc, KeyModifiers::NONE), Action::None));
assert!(screen(&mut list).contains("Quit writui?"));
assert!(matches!(press(&mut list, KeyCode::Char('n'), KeyModifiers::NONE), Action::None));
assert!(list.dialog.is_none());
press(&mut list, KeyCode::Esc, KeyModifiers::NONE);
assert!(matches!(press(&mut list, KeyCode::Char('y'), KeyModifiers::NONE), Action::Quit));
// Esc quits straight away: the writing is already saved.
assert!(matches!(press(&mut list, KeyCode::Esc, KeyModifiers::NONE), Action::Quit));
}

#[test]
Expand Down
6 changes: 1 addition & 5 deletions src/tui/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,6 @@ impl Setup {
Line::default(),
Line::from("Create your vault"),
Line::default(),
Line::from("Everything you write in writui is encrypted with this password."),
Line::from("There is no way to recover it: if you forget the password, your"),
Line::from("writing is gone for good."),
Line::default(),
// The two password boxes go here, side by side.
Line::default(),
Line::default(),
Expand All @@ -88,7 +84,7 @@ impl Setup {
rest.extend([Line::default(), Line::from(note.dim())]);
}
let mut lines = title(body, rest.len());
let first_field_line = lines.len() as u16 + 7;
let first_field_line = lines.len() as u16 + 3;
lines.extend(rest);

let area = centered(body, COLUMN_WIDTH, lines.len() as u16);
Expand Down
32 changes: 29 additions & 3 deletions src/tui/unlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Stylize;
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Style, Stylize};
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::{Block, Paragraph};

use super::Action;
use super::hints::{HintBar, hint};
Expand All @@ -27,6 +27,8 @@ pub struct Unlock {
locked: bool,
error: Option<&'static str>,
busy: bool,
/// Where the Unlock button was drawn, for clicks.
button: Rect,
hints: HintBar<Cmd>,
}

Expand Down Expand Up @@ -84,6 +86,27 @@ impl Unlock {
frame.set_cursor_position(cursor);
}

// The Unlock button, right of the password box and as tall as it,
// left out if the window is too narrow for all of it. Dimmed while
// there's no password for it to try.
let button = Rect::new(field_area.right() + 2, field_area.y, 12, height);
self.button = if area.contains(Position::new(button.right() - 1, button.bottom() - 1)) {
button
} else {
Rect::default()
};
if !self.button.is_empty() {
let style = if self.field.is_empty() || self.busy {
Style::new().dim()
} else {
Style::new().bold()
};
let block = Block::bordered().border_style(style);
let label = Paragraph::new(Line::from("Unlock").style(style)).centered();
frame.render_widget(label, block.inner(button));
frame.render_widget(block, button);
}

// Esc clears the password, or quits if there's nothing to clear.
let mut hints = vec![hint("Enter", "unlock", Cmd::Unlock)];
if self.field.is_empty() {
Expand Down Expand Up @@ -114,6 +137,9 @@ impl Unlock {
Action::None
}
Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => {
if self.button.contains(Position::new(mouse.column, mouse.row)) {
return self.run(Cmd::Unlock);
}
match self.hints.hit(mouse.column, mouse.row) {
Some(cmd) => self.run(cmd),
None => Action::None,
Expand Down
42 changes: 29 additions & 13 deletions src/tui/widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pub fn overview() -> Vec<Line<'static>> {
const POINTS: [&str; 6] = [
"All your writing lives in one file, encrypted with this password.",
"Forget the password and the writing is gone: there's no recovery.",
"Write in markdown. Every post starts with a # title.",
"Write your posts in markdown. Every post starts with a # title.",
"Posts save as you type; Ctrl+S also keeps a version to go back to.",
"While writing, Ctrl+K shows every command and its shortcut.",
"Ctrl+O locks writui; unlocking takes you back where you were.",
Expand All @@ -43,24 +43,27 @@ pub fn overview() -> Vec<Line<'static>> {
/// into those three parts.
#[rustfmt::skip]
const BANNER: [[&str; 3]; 6] = [
[" ", " ", " ██ ██"],
["▄▄▄ ", "▄▄", " ██"],
[" ▀██▄ ", "██", " ██ ██ ██ ▄████ █████ ███████ ██ ██ █████"],
[" ▀██▄ ", "██", " ██ ▄▄ ██ ███▀ ██ ██ ██ ██ ██"],
[" ▄██▀ ", "██", " ██▄██▄██ ██ ██ ██ ██ ██ ██"],
["▄██▀ ", "██", " ▀█▀ ▀█▀ ██ ████████ ▀█████ ▀██▄▄▄██ ████████"],
[" ", " ", " ██ ██"],
["▄▄▄ ", "▄▄", " ██"],
[" ▀██▄ ", "██", " ██ ██ ██ ▄██ ▀██ █████ ██ ██ ▀██"],
[" ▀██▄ ", "██", " ██ ▄ ██ ███▀ ██ ██ ██ ██ ██"],
[" ▄██▀ ", "██", " ██▄█▄██ ██ ██ ██ ██ ██ ██"],
["▄██▀ ", "██", " ▀█▀ ▀█▀ ██ ▄██▄ ▀███ ▀████▀ ▄██▄"],
];

/// The password screens' title: the banner, or just "writui" when `area`
/// is too small to fit the banner above `other_lines` more lines.
/// The password screens' title: the banner and a blank line under it, or
/// just "writui" when `area` is too small to fit those above `other_lines`
/// more lines.
pub fn title(area: Rect, other_lines: usize) -> Vec<Line<'static>> {
if area.width < COLUMN_WIDTH || (area.height as usize) < BANNER.len() + other_lines {
if area.width < COLUMN_WIDTH || (area.height as usize) < BANNER.len() + 1 + other_lines {
return vec![Line::from("writui".bold())];
}
BANNER
let mut lines: Vec<Line> = BANNER
.iter()
.map(|[mark, bar, name]| Line::from(vec![Span::raw(*mark), bar.green(), Span::raw(*name)]))
.collect()
.collect();
lines.push(Line::default());
lines
}

/// A column of at most `width`, horizontally centred in `area`.
Expand Down Expand Up @@ -180,10 +183,23 @@ mod tests {
#[test]
fn the_banner_fits_the_column() {
let lines = title(Rect::new(0, 0, 100, 30), 10);
assert_eq!(lines.len(), BANNER.len());
assert_eq!(lines.len(), BANNER.len() + 1);
assert!(lines.iter().all(|line| line.width() <= COLUMN_WIDTH as usize));
}

#[test]
fn the_dots_of_the_is_sit_over_their_stems() {
let columns = |line: &str, piece: &str| -> Vec<usize> {
let chars: Vec<char> = line.chars().collect();
let piece: Vec<char> = piece.chars().collect();
(0..chars.len()).filter(|&i| chars[i..].starts_with(&piece)).collect()
};
let dots = columns(BANNER[0][2], "██");
let stems: Vec<usize> =
columns(BANNER[2][2], "▀██").into_iter().filter(|&i| i > 10).map(|i| i + 1).collect();
assert_eq!(dots, stems);
}

#[test]
fn small_windows_get_the_plain_title() {
assert_eq!(title(Rect::new(0, 0, 60, 30), 10).len(), 1);
Expand Down
Loading