Skip to content

Commit

Permalink
Add initial "change topology" support
Browse files Browse the repository at this point in the history
Nodes can now be copied and pasted with an internal clipboard. What doesn't work yet is the author metadata.

Nodes *can* be copied and pasted betwee stories but the author's id will be reset to the head's.

This is the downside to storing the authors in the story and not the nodes themselves. It might be worth it to have a database of authors in the app itself. This is one way to solve it without copying the data on every single node.
  • Loading branch information
mdegans committed Jun 6, 2024
1 parent ecb1ab2 commit 453083b
Show file tree
Hide file tree
Showing 5 changed files with 73 additions and 8 deletions.
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Notable features:

Coming soon:

- Keyboard shortcuts.
- Multiple tabs and windows

Additionally, one goal of `weave` is feature parity with [`loom`](https://github.com/socketteer/loom?tab=readme-ov-file#features).

Expand All @@ -44,7 +44,7 @@ Additionally, one goal of `weave` is feature parity with [`loom`](https://github
- ☑️ Tree view
- ✅ Explore tree visually with mouse
- ✅ Expand and collapse nodes
- 🔲 Change tree topology
- Change tree topology
- ✅ Edit nodes in place
- 🔲 Navigation
- ✅ Hotkeys
Expand All @@ -58,7 +58,7 @@ Additionally, one goal of `weave` is feature parity with [`loom`](https://github
- ✅ Serializable application state, including stories, to JSON.
- ✅ Open/save trees as JSON files
- 🔲 Work with trees in multiple tabs
- 🔲 Combine multiple trees
- Combine multiple trees

# Notable issues

Expand All @@ -70,5 +70,4 @@ Additionally, one goal of `weave` is feature parity with [`loom`](https://github
- It is not currently possible to have a scrollable viewport so it's
recommended to collapse nodes if things get cluttered. This is because the
nodes are implemented with [`egui::containers::Window`](https://docs.rs/egui/latest/egui/containers/struct.Window.html) which ignore scrollable areas. This is fixable
but not easily and not cleanly. When it is resolved the central panel will be
split into story and node views.
but not easily and not cleanly.
2 changes: 2 additions & 0 deletions resources/SHORTCUTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
- `Command/Ctrl + S` Save story to JSON.
- `Command/Ctrl + O` Load story from JSON.
- `Command/Ctrl + N` New paragraph with the default author.
- `Command/Ctrl + ,` Cut the active node (and all of it's children).
- `Command/Ctrl + .` Paste clipboard contents as a child of the active node.
- `Command/Ctrl + DELETE` Delete the selected paragraph _and all children_.
- `Command/Ctrl + Shift + S` Export story to markdown/txt.
- `Command/Ctrl + Shift + N` New Untitled story.
Expand Down
41 changes: 40 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ mod settings;
use {
self::settings::{BackendOptions, Settings},
crate::{
node::Action,
node::{self, Action, Meta, Node},
story::{DrawMode, Story},
},
egui::TextBuffer,
};

#[derive(Default, PartialEq, derive_more::Display)]
Expand Down Expand Up @@ -94,6 +95,8 @@ pub struct App {
settings: Settings,
left_sidebar: LeftSidebar,
right_sidebar: RightSidebar,
/// Temporary node storage for copy/paste.
node_clipboard: Option<Node<Meta>>,
/// Modal error messages.
errors: Vec<Error>,
/// Commonmark cache
Expand Down Expand Up @@ -1083,6 +1086,18 @@ impl App {
}
}

/// Draw clipboard.
pub fn draw_clipboard(&mut self, ctx: &egui::Context) {
if let Some(node) = &self.node_clipboard {
egui::TopBottomPanel::bottom("clipboard").show(ctx, |ui| {
let mut text =
node.to_string().chars().take(20).collect::<String>();
text.push_str(&format!("... (and {} children)", node.count()));
ui.horizontal(|ui| ui.label("Clipboard:") | ui.label(text))
});
}
}

/// Handle input events (keyboard shortcuts, etc).
pub fn handle_input(
&mut self,
Expand Down Expand Up @@ -1128,6 +1143,29 @@ impl App {
story.decapitate();
}
}
// Command + ,: Cut selected node.
if !self.generation_in_progress
&& input.key_pressed(egui::Key::Comma)
{
if let Some(story) = self.story_mut() {
self.node_clipboard = story.decapitate();
}
}
// Command + .: Paste node from clipboard.
if !self.generation_in_progress
&& input.key_pressed(egui::Key::Period)
{
let node = self.node_clipboard.take();
if let Some(story) = self.story_mut() {
if let Some(node) = node {
story.paste_node(node);
}
} else {
// Put the node back. We do this because multiple
// mutable references to self are not allowed.
self.node_clipboard = node;
}
}
}
// Command + Shift + key shortcuts
if input.modifiers.command && input.modifiers.shift {
Expand Down Expand Up @@ -1186,6 +1224,7 @@ impl eframe::App for App {
// handle any dialog that might be open
self.draw_left_sidebar(ctx, frame);
self.draw_right_sidebar(ctx, frame);
self.draw_clipboard(ctx);
self.draw_central_panel(ctx, frame);
}

Expand Down
15 changes: 15 additions & 0 deletions src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,26 @@ impl<T> Node<T> {
}
}

/// Set author for the node and all children.
pub fn set_author(&mut self, author_id: u8) {
self.author_id = author_id;
for child in self.children.iter_mut() {
child.set_author(author_id);
}
}

/// Returns true if the node has no children.
pub fn is_leaf(&self) -> bool {
self.children.is_empty()
}

/// Count the number of nodes in the tree including self.
///
/// This is O(n) where n is the number of nodes, but n should be small
pub fn count(&self) -> usize {
1 + self.children.iter().map(Self::count).sum::<usize>()
}

/// Adds a child to self. Returns the index of the child.
pub fn add_child(&mut self, child: Node<T>) -> usize {
self.children.push(child);
Expand Down
14 changes: 12 additions & 2 deletions src/story.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ impl Story {
.map(|(id, author)| (id as u8, author.as_str()))
}

/// Add a node to the story's head node.
pub fn paste_node(&mut self, mut node: Node<Meta>) {
// We do this for now to avoid a crash. We can't transfer author ids
// between stories yet, so we reset them to the head's author.
node.set_author(self.head().author_id);
self.head_mut().add_child(node);
}

/// Add paragraph to the story's head node.
///
/// # Panics
Expand Down Expand Up @@ -189,7 +197,7 @@ impl Story {
/// Remove the head as well as all its children.
///
/// Note: The root node is never removed.
pub fn decapitate(&mut self) {
pub fn decapitate(&mut self) -> Option<Node<Meta>> {
if let Some(path) = &mut self.active_path {
if path.is_empty() {
// There is always at least one node in the story.
Expand All @@ -202,9 +210,11 @@ impl Story {
}
// This wil now be the parent of the head node. We remove the
// child index we just popped.
node.children.remove(head_index);
return Some(node.children.remove(head_index));
}
}

return None;
}

/// Convert the story to a string with options
Expand Down

0 comments on commit 453083b

Please sign in to comment.