Part 8d, the last of stage 8, and with it everything on DESIGN.md's v1 list. Connect mode (C): the cursor goes to another node, a dashed arrow in the visual color follows it, and Enter links the two; then the cursor goes back to where C was typed (the user's pick). d removes the line between them (a branch is cut, and the child comes loose), > puts the node under the other one, and <Space> a, l, c, s and t style the line as you watch, making it when it isn't there yet. Everything connect mode changes is one undo group, and Esc takes it back with History::cancel, which leaves nothing to redo. From visual mode every selected node connects, and the line pickers go back to connect mode when they close. Portals show their document live, from a cache of what each document file holds (open documents from their buffers, the others from their files, looked at every second): the root's title, its color and how many nodes it has, or ? when the document isn't there. Enter or gf goes through, as a jump, and the pane's breadcrumb starts with the documents it came through. <Space>p types :portal (the user's pick): a path as :e takes it, or the title of a new document. Backlinks are put right whenever a document is opened or saved; the root shows how many, the Note view lists them, gu goes up to them (the user's pick) and :backlinks lists them. File names follow the root's title: when it changed, the next save renames the file (-2 when the name is taken; a folder's default document keeps its name), and the portals pointing at it, its backlinks in the documents it leads to, the recent documents, the capital marks and the jump lists follow. A portal whose file was moved by hand finds its document in that folder by uid. Portals in a register keep full paths, so a pasted portal leads to the same document from any folder. Co-Authored-By: Claude Opus 5.5 <[email protected]>
589 lines
24 KiB
Rust
589 lines
24 KiB
Rust
//! The outline: the tree as an indented list, to reorganize and to read.
|
|
//!
|
|
//! ```text
|
|
//! ▾ Garden plans
|
|
//! ├ Soil #todo
|
|
//! ├ ▸ Raised beds ¶ +2
|
|
//! ├ ▾ Watering
|
|
//! │ ├ Rain barrel
|
|
//! │ └ ▸ Drip line +1
|
|
//! └ » Kitchen
|
|
//! ◦ ask about compost ¶
|
|
//! ```
|
|
//!
|
|
//! A row for each node on show (it folds the way every view does), with its
|
|
//! fold marker, its tags, `¶` when it has a body and `+N` for what a fold
|
|
//! hides. The tree lines take the parent's color, as branches do on the
|
|
//! canvas. `zb` shows every body under its title, to read the document like
|
|
//! a page.
|
|
//!
|
|
//! `j` and `k` go row by row, `h` and `l` to the parent and the first child.
|
|
//! In move mode a node moves through the tree rather than across the canvas:
|
|
//! `j` and `k` swap it with its siblings, `h` takes it out a level, and `l`
|
|
//! puts it under the one above.
|
|
//!
|
|
//! Rows wider than the view (a deep tree, a long title) are cut at its right
|
|
//! edge; `zh` and `zl` or the wheel sideways scroll them.
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
use ratatui::Frame;
|
|
use ratatui::layout::{Position, Rect};
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
use crate::app::action::{Action, AppAction, Message};
|
|
use crate::app::commands;
|
|
use crate::app::connect;
|
|
use crate::app::mode::Mode;
|
|
use crate::app::popup::MenuFor;
|
|
use crate::app::text_edit::{EditAt, StartEdit, TextEdit};
|
|
use crate::app::visual::selection;
|
|
use crate::input::bindings::Context;
|
|
use crate::input::command::{Command, Motion};
|
|
use crate::input::engine::Typed;
|
|
use crate::input::mouse::{Cell, Gesture};
|
|
use crate::model::{Content, Direction, EditOp, Fold, NodeId, Visible};
|
|
use crate::ui::text_editor::layout;
|
|
use crate::view::{View, ViewContext, ViewKind, contexts_for};
|
|
|
|
/// How close the cursor's row may come to the top or the bottom before the
|
|
/// outline scrolls, like vim's `scrolloff`.
|
|
const SCROLL_OFF: usize = 3;
|
|
/// How many rows the mouse wheel scrolls.
|
|
const WHEEL_ROWS: usize = 3;
|
|
/// How many columns `zh`, `zl` and the wheel sideways scroll.
|
|
const SCROLL_COLS: u16 = 6;
|
|
/// How many columns of a title stay in view, at least, when the outline
|
|
/// scrolls sideways to show the cursor's row.
|
|
const TITLE_ROOM: u16 = 12;
|
|
/// How many columns each level of the tree is indented.
|
|
const INDENT: u16 = 4;
|
|
|
|
/// The outline view.
|
|
#[derive(Debug, Default)]
|
|
pub struct OutlineView {
|
|
/// Where on screen the view is.
|
|
area: Rect,
|
|
/// The first row on show.
|
|
scroll: usize,
|
|
/// How many columns the rows are scrolled to the left.
|
|
side: u16,
|
|
/// Every body shows under its title (`zb`).
|
|
bodies: bool,
|
|
/// The rows laid out last: what's drawn, and what a click lands on.
|
|
rows: Vec<Row>,
|
|
/// The cursor at the last layout. When it moves, the view follows.
|
|
last_cursor: Option<NodeId>,
|
|
/// The view was scrolled by hand: it stays put until the cursor moves.
|
|
scrolled: bool,
|
|
/// The title being edited, as drawn.
|
|
editing: Option<Editing>,
|
|
/// What's selected in visual mode.
|
|
picked: BTreeSet<NodeId>,
|
|
}
|
|
|
|
/// One row, ready to draw.
|
|
#[derive(Debug, Clone)]
|
|
struct Row {
|
|
/// The node it's about (a line of a body is about its node).
|
|
id: NodeId,
|
|
line: Line<'static>,
|
|
/// Where the title, or the line of the body, starts.
|
|
text_col: u16,
|
|
/// On the title row of a node with children: where the fold marker is,
|
|
/// and whether the children are folded away.
|
|
marker: Option<(u16, bool)>,
|
|
/// The title row, rather than a line of the body.
|
|
title: bool,
|
|
}
|
|
|
|
/// The title being edited, as drawn: where its text starts, scrolled
|
|
/// sideways so that the text cursor stays in view.
|
|
#[derive(Debug, Clone)]
|
|
struct Editing {
|
|
row: usize,
|
|
text_col: u16,
|
|
/// How many columns of the text are scrolled off to the left.
|
|
skip: usize,
|
|
text: String,
|
|
/// The text cursor's column, counted from the start of the text.
|
|
cursor_col: usize,
|
|
}
|
|
|
|
impl OutlineView {
|
|
pub fn new() -> OutlineView {
|
|
OutlineView::default()
|
|
}
|
|
|
|
/// Lays out a row for every node on show (and the lines of their bodies,
|
|
/// when bodies show).
|
|
fn build(&mut self, ctx: &ViewContext<'_>) {
|
|
let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs);
|
|
let visible = Visible::new(doc, ctx.cursor, ctx.settings.focus);
|
|
let edit = ctx.mode.title_edit();
|
|
self.picked = match ctx.mode {
|
|
Mode::Visual(visual) => selection(visual, ctx, ViewKind::Outline).into_iter().collect(),
|
|
Mode::Connect(connecting) => connect::others(connecting, ctx.cursor).into_iter().collect(),
|
|
_ => BTreeSet::new(),
|
|
};
|
|
// The color of the lines from a node to its children, as on the
|
|
// canvas: the node's own, or dim when it has none.
|
|
let line_style = |id: NodeId| {
|
|
let color =
|
|
doc.node(id).map_or(Color::Reset, |node| theme.node_style(node, id == doc.root()).color);
|
|
Style::new().fg(if color == Color::Reset { theme.muted } else { color })
|
|
};
|
|
self.rows.clear();
|
|
self.editing = None;
|
|
for &id in &visible.order {
|
|
// Top-down: the top-level node this one is under, …, its parent.
|
|
let mut path: Vec<NodeId> = doc.ancestors(id).collect();
|
|
path.reverse();
|
|
// The lines of the levels above, going on down past this row,
|
|
// then this node's joint to its parent's line. The lines of its
|
|
// body go on below the way its children's would.
|
|
let mut lines = Lines::default();
|
|
let mut body_lines = Lines::default();
|
|
body_lines.push(" ", Style::new());
|
|
if let Some(&parent) = path.last() {
|
|
lines.push(" ", Style::new());
|
|
for pair in path.windows(2) {
|
|
let going_on = doc.next_sibling(pair[1]).is_some();
|
|
lines.push_level(going_on, glyphs.tree_line(), line_style(pair[0]));
|
|
}
|
|
let last = doc.next_sibling(id).is_none();
|
|
body_lines = lines.clone();
|
|
body_lines.push_level(!last, glyphs.tree_line(), line_style(parent));
|
|
lines.push(format!("{} ", glyphs.tree_joint(last)), line_style(parent));
|
|
}
|
|
self.add_title(ctx, id, &visible, lines, edit);
|
|
self.add_body(ctx, id, body_lines);
|
|
}
|
|
}
|
|
|
|
/// The title row of a node: its marker, title, tags, `¶` and `+N`.
|
|
fn add_title(
|
|
&mut self,
|
|
ctx: &ViewContext<'_>,
|
|
id: NodeId,
|
|
visible: &Visible,
|
|
mut lines: Lines,
|
|
edit: Option<&TextEdit>,
|
|
) {
|
|
let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs);
|
|
let Some(node) = doc.node(id) else { return };
|
|
let top_level = lines.spans.is_empty();
|
|
let style = ctx.node_style(id, node);
|
|
let color = style.color;
|
|
let has_children = !doc.children(id).is_empty();
|
|
let folded = visible.hidden.contains_key(&id);
|
|
let marker = if node.is_portal() {
|
|
Some(glyphs.portal())
|
|
} else if has_children {
|
|
Some(glyphs.fold(folded))
|
|
} else if top_level {
|
|
Some(glyphs.loose())
|
|
} else {
|
|
None
|
|
};
|
|
let marker_at = (has_children && !node.is_portal()).then_some((lines.width, folded));
|
|
if let Some(marker) = marker {
|
|
lines.push(format!("{marker} "), Style::new().fg(color));
|
|
}
|
|
let text_col = lines.width;
|
|
let muted = Style::new().fg(theme.muted);
|
|
let title_style = if id == ctx.cursor {
|
|
Style::new().fg(Color::Black).bg(theme.accent).add_modifier(Modifier::BOLD)
|
|
} else if self.picked.contains(&id) {
|
|
Style::new().fg(Color::Black).bg(theme.visual)
|
|
} else if style.faded {
|
|
muted
|
|
} else {
|
|
Style::new()
|
|
};
|
|
match edit.filter(|edit| edit.node == id) {
|
|
Some(edit) => {
|
|
// The title as typed, scrolled so the text cursor shows.
|
|
let text = edit.editor.text();
|
|
let room =
|
|
usize::from(self.area.width.saturating_add(self.side).saturating_sub(text_col)).max(1);
|
|
let cursor_col = text[..edit.editor.cursor()].width();
|
|
let (shown, skip) = cut_left(text, (cursor_col + 1).saturating_sub(room));
|
|
let color = if edit.editor.is_typing() { theme.insert } else { theme.accent };
|
|
lines.push(shown.to_string(), Style::new().fg(color).add_modifier(Modifier::BOLD));
|
|
self.editing = Some(Editing {
|
|
row: self.rows.len(),
|
|
text_col,
|
|
skip,
|
|
text: text.to_string(),
|
|
cursor_col,
|
|
});
|
|
}
|
|
None => lines.push(node.title().to_string(), title_style),
|
|
}
|
|
if !node.tags.is_empty() {
|
|
lines.push(format!(" {}", node.tags), muted);
|
|
}
|
|
let has_body = matches!(&node.content, Content::Idea(idea) if !idea.body.is_empty());
|
|
if has_body && !self.bodies {
|
|
lines.push(format!(" {}", glyphs.body_mark()), muted);
|
|
}
|
|
if let Some(badge) = ctx.badge(id, node) {
|
|
lines.push(format!(" {badge}"), muted);
|
|
}
|
|
if let Some(count) = visible.hidden.get(&id) {
|
|
lines.push(format!(" +{count}"), muted);
|
|
}
|
|
self.rows.push(Row { id, line: Line::from(lines.spans), text_col, marker: marker_at, title: true });
|
|
}
|
|
|
|
/// The lines of a node's body, under its title, when bodies show.
|
|
fn add_body(&mut self, ctx: &ViewContext<'_>, id: NodeId, lines: Lines) {
|
|
if !self.bodies {
|
|
return;
|
|
}
|
|
let Some(Content::Idea(idea)) = ctx.doc.node(id).map(|node| &node.content) else { return };
|
|
let body = idea.body.as_str();
|
|
if body.is_empty() {
|
|
return;
|
|
}
|
|
let text_col = lines.width;
|
|
let room = usize::from(self.area.width.saturating_sub(text_col)).max(8);
|
|
for range in layout::rows(body, room) {
|
|
let text = body[range].trim_end_matches('\n').to_string();
|
|
let mut spans = lines.spans.clone();
|
|
spans.push(Span::raw(text));
|
|
self.rows.push(Row { id, line: Line::from(spans), text_col, marker: None, title: false });
|
|
}
|
|
}
|
|
|
|
/// Scrolls so the cursor's row shows, a few rows from the edges.
|
|
fn show_cursor_row(&mut self, cursor: NodeId) {
|
|
let Some(index) = self.rows.iter().position(|row| row.id == cursor && row.title) else { return };
|
|
let height = usize::from(self.area.height).max(1);
|
|
let off = SCROLL_OFF.min(height.saturating_sub(1) / 2);
|
|
if index < self.scroll + off {
|
|
self.scroll = index.saturating_sub(off);
|
|
} else if index + off >= self.scroll + height {
|
|
self.scroll = index + off + 1 - height;
|
|
}
|
|
}
|
|
|
|
/// Scrolls by some rows, up when negative. The cursor stays where it is.
|
|
fn scroll_by(&mut self, rows: isize) {
|
|
self.scroll = self.scroll.saturating_add_signed(rows).min(self.rows.len().saturating_sub(1));
|
|
self.scrolled = true;
|
|
}
|
|
|
|
/// Scrolls sideways by some columns, left when negative: as far as it
|
|
/// takes for the widest row to end at the right edge.
|
|
fn scroll_side(&mut self, cols: i32) {
|
|
let side = i32::from(self.side).saturating_add(cols).clamp(0, i32::from(self.widest_overhang()));
|
|
self.side = u16::try_from(side).unwrap_or(0);
|
|
}
|
|
|
|
/// How far the widest row goes past the view's right edge.
|
|
fn widest_overhang(&self) -> u16 {
|
|
let widest = self.rows.iter().map(|row| row.line.width()).max().unwrap_or(0);
|
|
u16::try_from(widest).unwrap_or(u16::MAX).saturating_sub(self.area.width)
|
|
}
|
|
|
|
/// Scrolls sideways, if it has to, so the start of the cursor's title is
|
|
/// in view with some of the title after it. Returns whether it scrolled.
|
|
fn show_cursor_title(&mut self, cursor: NodeId) -> bool {
|
|
let Some(row) = self.rows.iter().find(|row| row.id == cursor && row.title) else { return false };
|
|
let before = self.side;
|
|
let room = TITLE_ROOM.min(self.area.width / 2);
|
|
if row.text_col < self.side {
|
|
self.side = row.text_col.saturating_sub(INDENT);
|
|
} else if row.text_col + room > self.side + self.area.width {
|
|
self.side = row.text_col + room - self.area.width;
|
|
}
|
|
self.side != before
|
|
}
|
|
|
|
/// The row and column under a terminal cell, if it's in the view: the
|
|
/// column counted from the rows' start, however far they're scrolled.
|
|
fn local(&self, cell: Cell) -> Option<(u16, usize)> {
|
|
let at = Position::new(cell.col, cell.row);
|
|
self.area.contains(at).then(|| {
|
|
let col = (at.x - self.area.x).saturating_add(self.side);
|
|
(col, self.scroll + usize::from(at.y - self.area.y))
|
|
})
|
|
}
|
|
|
|
/// Where a click lands in the title being edited, if it's on its text.
|
|
fn text_offset_at(&self, (col, row): (u16, usize)) -> Option<usize> {
|
|
let editing =
|
|
self.editing.as_ref().filter(|editing| editing.row == row && col >= editing.text_col)?;
|
|
let column = usize::from(col - editing.text_col) + editing.skip;
|
|
Some(layout::offset(&editing.text, &layout::rows(&editing.text, usize::MAX), 0, column))
|
|
}
|
|
|
|
/// The terminal cell of the text cursor, while a title is edited.
|
|
fn text_cursor(&self) -> Option<Position> {
|
|
let editing = self.editing.as_ref()?;
|
|
let row = u16::try_from(editing.row.checked_sub(self.scroll)?).ok()?;
|
|
let col = usize::from(editing.text_col) + editing.cursor_col - editing.skip;
|
|
let col = u16::try_from(col.checked_sub(usize::from(self.side))?).ok()?;
|
|
(row < self.area.height && col < self.area.width)
|
|
.then(|| Position::new(self.area.x + col, self.area.y + row))
|
|
}
|
|
|
|
/// A left press: on a row, its node becomes the cursor; on a fold marker,
|
|
/// the branch folds or unfolds too.
|
|
fn press(&self, at: (u16, usize), ctx: &ViewContext<'_>) -> Vec<Action> {
|
|
let Some(row) = self.rows.get(at.1) else { return Vec::new() };
|
|
let mut actions = vec![Action::cursor(row.id)];
|
|
if row.title
|
|
&& let Some((col, folded)) = row.marker
|
|
&& col == at.0
|
|
&& ctx.doc.contains(row.id)
|
|
{
|
|
let fold = if folded { Fold::Open } else { Fold::Closed };
|
|
actions.push(Action::edit(EditOp::SetFold { ids: vec![row.id], fold }));
|
|
}
|
|
actions
|
|
}
|
|
|
|
/// Where a double-click on a title lands in it, to start editing there.
|
|
fn title_spot(&self, row: &Row, col: u16, ctx: &ViewContext<'_>) -> EditAt {
|
|
let Some(node) = ctx.doc.node(row.id) else { return EditAt::End };
|
|
if col < row.text_col {
|
|
return EditAt::End;
|
|
}
|
|
let title = node.title().as_str();
|
|
EditAt::Offset(layout::offset(
|
|
title,
|
|
&layout::rows(title, usize::MAX),
|
|
0,
|
|
usize::from(col - row.text_col),
|
|
))
|
|
}
|
|
}
|
|
|
|
/// The spans of a row as it's built, and how wide they are so far.
|
|
#[derive(Debug, Clone, Default)]
|
|
struct Lines {
|
|
spans: Vec<Span<'static>>,
|
|
width: u16,
|
|
}
|
|
|
|
impl Lines {
|
|
fn push(&mut self, text: impl Into<String>, style: Style) {
|
|
let text = text.into();
|
|
self.width = self.width.saturating_add(u16::try_from(text.width()).unwrap_or(u16::MAX));
|
|
self.spans.push(Span::styled(text, style));
|
|
}
|
|
|
|
/// One level of the tree: its line going on down, or room where it ended.
|
|
fn push_level(&mut self, going_on: bool, line: char, style: Style) {
|
|
let indent = usize::from(INDENT);
|
|
let text = if going_on { format!("{line}{}", " ".repeat(indent - 1)) } else { " ".repeat(indent) };
|
|
self.push(text, style);
|
|
}
|
|
}
|
|
|
|
/// A row without its first `columns` columns: what shows when the outline
|
|
/// is scrolled sideways.
|
|
fn skip_columns(line: &Line<'static>, columns: usize) -> Line<'static> {
|
|
if columns == 0 {
|
|
return line.clone();
|
|
}
|
|
let mut left = columns;
|
|
let mut spans = Vec::new();
|
|
for span in &line.spans {
|
|
let width = span.content.width();
|
|
if left == 0 {
|
|
spans.push(span.clone());
|
|
} else if width <= left {
|
|
left -= width;
|
|
} else {
|
|
let (rest, cut) = cut_left(&span.content, left);
|
|
// A wide character cut in half leaves its other half blank.
|
|
let blank = " ".repeat(cut - left);
|
|
spans.push(Span::styled(format!("{blank}{rest}"), span.style));
|
|
left = 0;
|
|
}
|
|
}
|
|
Line::from(spans)
|
|
}
|
|
|
|
/// `text` without its first `columns` columns, and how many columns that
|
|
/// took off (a wide character isn't cut in half, so it can be one more).
|
|
fn cut_left(text: &str, columns: usize) -> (&str, usize) {
|
|
let mut cut = 0;
|
|
for (at, c) in text.char_indices() {
|
|
if cut >= columns {
|
|
return (&text[at..], cut);
|
|
}
|
|
cut += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
|
|
}
|
|
("", cut)
|
|
}
|
|
|
|
fn say(text: &str) -> Vec<Action> {
|
|
vec![Action::say(Message::info(text))]
|
|
}
|
|
|
|
impl View for OutlineView {
|
|
fn kind(&self) -> ViewKind {
|
|
ViewKind::Outline
|
|
}
|
|
|
|
fn contexts(&self, mode: &Mode) -> &'static [Context] {
|
|
contexts_for(
|
|
mode,
|
|
&[Context::Outline, Context::Normal, Context::Motions],
|
|
&[Context::Visual, Context::Outline, Context::Motions],
|
|
&[Context::Connect, Context::Outline, Context::Motions],
|
|
)
|
|
}
|
|
|
|
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
|
|
self.area = area;
|
|
let moved = self.last_cursor.replace(ctx.cursor) != Some(ctx.cursor);
|
|
if moved {
|
|
self.scrolled = false;
|
|
}
|
|
self.build(ctx);
|
|
if !self.scrolled || self.editing.is_some() {
|
|
self.show_cursor_row(ctx.cursor);
|
|
}
|
|
// Sideways, the cursor's title comes into view when the cursor
|
|
// moves, and stays in view while it's edited (and the title being
|
|
// typed is laid out for where the rows are, so it's laid out again).
|
|
let before = self.side;
|
|
if moved || self.editing.is_some() {
|
|
self.show_cursor_title(ctx.cursor);
|
|
}
|
|
self.side = self.side.min(self.widest_overhang());
|
|
if self.side != before && self.editing.is_some() {
|
|
self.build(ctx);
|
|
}
|
|
self.scroll = self.scroll.min(self.rows.len().saturating_sub(1));
|
|
}
|
|
|
|
fn render(&self, frame: &mut Frame, _ctx: &ViewContext<'_>) {
|
|
let buf = frame.buffer_mut();
|
|
let rows = self.rows.iter().skip(self.scroll).take(usize::from(self.area.height));
|
|
for (row, y) in rows.zip(self.area.y..) {
|
|
let line = skip_columns(&row.line, usize::from(self.side));
|
|
buf.set_line(self.area.x, y, &line, self.area.width);
|
|
}
|
|
if let Some(cell) = self.text_cursor() {
|
|
frame.set_cursor_position(cell);
|
|
}
|
|
}
|
|
|
|
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
|
match ctx.mode {
|
|
Mode::Move(grab) => return commands::reorder(typed, grab, ctx),
|
|
Mode::Normal | Mode::Visual(_) | Mode::Connect(_) => {}
|
|
Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None,
|
|
}
|
|
let rows = isize::try_from(typed.times()).unwrap_or(1);
|
|
let cols = i32::from(SCROLL_COLS) * i32::try_from(typed.times()).unwrap_or(1);
|
|
let half_page = isize::try_from(self.area.height / 2).unwrap_or(1).max(1);
|
|
// hjkl follow the rows: the next and previous in reading order, the
|
|
// parent and the first child.
|
|
let tree =
|
|
|motion: Motion| commands::shared(&Typed { command: Command::Move(motion), ..*typed }, ctx);
|
|
match typed.command {
|
|
Command::Move(Motion::Down) => return tree(Motion::Next),
|
|
Command::Move(Motion::Up) => return tree(Motion::Previous),
|
|
Command::Move(Motion::Left) => return tree(Motion::Parent),
|
|
Command::Move(Motion::Right) => return tree(Motion::FirstChild),
|
|
Command::ScrollDown => self.scroll_by(rows),
|
|
Command::ScrollUp => self.scroll_by(-rows),
|
|
Command::HalfPageDown => self.scroll_by(half_page * rows),
|
|
Command::HalfPageUp => self.scroll_by(-half_page * rows),
|
|
Command::ScrollLeft => self.scroll_side(-cols),
|
|
Command::ScrollRight => self.scroll_side(cols),
|
|
Command::Center => {
|
|
if let Some(index) = self.rows.iter().position(|row| row.id == ctx.cursor && row.title) {
|
|
self.scroll = index.saturating_sub(usize::from(self.area.height / 2));
|
|
self.scrolled = true;
|
|
}
|
|
}
|
|
Command::ToggleBodies => {
|
|
self.bodies = !self.bodies;
|
|
let text = if self.bodies {
|
|
"every body shows under its title: zb hides them"
|
|
} else {
|
|
"bodies hidden: ¶ marks the nodes that have one"
|
|
};
|
|
return Some(say(text));
|
|
}
|
|
_ => return None,
|
|
}
|
|
Some(Vec::new())
|
|
}
|
|
|
|
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action> {
|
|
match gesture {
|
|
Gesture::Press { at, .. } => {
|
|
let Some(at) = self.local(at) else { return Vec::new() };
|
|
let Some(edit) = ctx.mode.title_edit() else { return self.press(at, ctx) };
|
|
// While a title is edited, a click in it moves the text
|
|
// cursor; a click anywhere else keeps the edit, finishes it,
|
|
// and does what a click does.
|
|
if let Some(offset) = self.text_offset_at(at) {
|
|
return vec![Action::App(AppAction::TextCursor(offset))];
|
|
}
|
|
let mut actions = edit.finish(ctx);
|
|
actions.extend(self.press(at, ctx));
|
|
actions
|
|
}
|
|
Gesture::DoublePress { at } => {
|
|
let Some(at) = self.local(at) else { return Vec::new() };
|
|
if let Some(offset) = self.text_offset_at(at) {
|
|
return vec![Action::App(AppAction::TextCursor(offset))];
|
|
}
|
|
let Some(row) = self.rows.get(at.1).filter(|row| row.title) else { return Vec::new() };
|
|
let mut actions = ctx.mode.title_edit().map(|edit| edit.finish(ctx)).unwrap_or_default();
|
|
let start = StartEdit::title(self.title_spot(row, at.0, ctx));
|
|
actions.extend([Action::cursor(row.id), Action::App(AppAction::StartEdit(start))]);
|
|
actions
|
|
}
|
|
Gesture::Scroll { direction, .. } => {
|
|
let rows = isize::try_from(WHEEL_ROWS).unwrap_or(1);
|
|
match direction {
|
|
Direction::Down => self.scroll_by(rows),
|
|
Direction::Up => self.scroll_by(-rows),
|
|
Direction::Left => self.scroll_side(-i32::from(SCROLL_COLS)),
|
|
Direction::Right => self.scroll_side(i32::from(SCROLL_COLS)),
|
|
}
|
|
Vec::new()
|
|
}
|
|
Gesture::RightPress { at: cell } => {
|
|
let Some(row) = self.local(cell).and_then(|at| self.rows.get(at.1)) else {
|
|
return Vec::new();
|
|
};
|
|
let mut actions = ctx.mode.title_edit().map(|edit| edit.finish(ctx)).unwrap_or_default();
|
|
let (what, col, row_at) = (MenuFor::Node(row.id), cell.col, cell.row);
|
|
actions.extend([
|
|
Action::cursor(row.id),
|
|
Action::App(AppAction::OpenMenu { what, col, row: row_at }),
|
|
]);
|
|
actions
|
|
}
|
|
Gesture::Drag { .. } | Gesture::Release { .. } => Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn cursor_area(&self, ctx: &ViewContext<'_>) -> Option<Rect> {
|
|
let index = self.rows.iter().position(|row| row.id == ctx.cursor && row.title)?;
|
|
let row =
|
|
u16::try_from(index.checked_sub(self.scroll)?).ok().filter(|&row| row < self.area.height)?;
|
|
let width = self.rows[index].line.width();
|
|
let width = u16::try_from(width).unwrap_or(u16::MAX).min(self.area.width).max(1);
|
|
Some(Rect::new(self.area.x, self.area.y + row, width, 1))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|