Add label for links

This commit is contained in:
2026-09-09 21:47:34 +02:00
parent a64a5327e7
commit e7d8c6081b
10 changed files with 431 additions and 15 deletions
+5 -1
View File
@@ -267,11 +267,15 @@ impl App {
&mut self.doc,
NodeKind::Link {
url: "https://".to_owned(),
label: None,
},
at,
);
self.show_inspector = true;
self.set_status("Added a link node — set its URL in the inspector", ctx);
self.set_status(
"Added a link node — set its URL and display text in the inspector",
ctx,
);
}
fn insert_group(&mut self, ctx: &Context) {
+30 -5
View File
@@ -107,18 +107,43 @@ fn node_section(
set_file(doc, id, Some(path), Some(sub));
}
}
NodeKind::Link { url } => {
let mut buffer = url.clone();
NodeKind::Link { url, label } => {
let mut url_buffer = url.clone();
ui.label(field_label(palette, "URL"));
if ui
.add(TextEdit::singleline(&mut buffer).desired_width(f32::INFINITY))
.add(TextEdit::singleline(&mut url_buffer).desired_width(f32::INFINITY))
.changed()
{
doc.begin_change("Edit URL");
if let Some(NodeKind::Link { url }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) {
*url = buffer;
if let Some(NodeKind::Link { url, .. }) =
doc.canvas.node_mut(id).map(|n| &mut n.kind)
{
*url = url_buffer;
}
}
ui.add_space(6.0);
ui.label(field_label(palette, "Display text"));
let mut label_buffer = label.clone().unwrap_or_default();
if ui
.add(
TextEdit::singleline(&mut label_buffer)
.desired_width(f32::INFINITY)
.hint_text("Shown instead of the URL"),
)
.changed()
{
doc.begin_change("Edit display text");
if let Some(NodeKind::Link { label, .. }) =
doc.canvas.node_mut(id).map(|n| &mut n.kind)
{
// Kept verbatim so a space typed mid-word is not trimmed
// away under the cursor; blank labels simply go away.
*label = (!label_buffer.is_empty()).then_some(label_buffer);
}
}
ui.add_space(6.0);
if ui.button("Open in browser").clicked() {
out.open_target = Some(url.clone());
}
+47 -2
View File
@@ -165,6 +165,10 @@ pub enum NodeKind {
},
Link {
url: String,
/// Text shown in place of the URL when the node is drawn on the canvas.
/// Not part of JSON Canvas 1.0; written as a `label` attribute, the name
/// groups and edges already use for the same idea.
label: Option<String>,
},
Group {
label: Option<String>,
@@ -242,7 +246,10 @@ impl Node {
}
}
NodeKind::File { file, .. } => file_name(file).to_owned(),
NodeKind::Link { url } => truncate(url.trim_start_matches("https://"), 40),
NodeKind::Link { url, label } => match display_text(label.as_deref()) {
Some(label) => truncate(label, 40),
None => truncate(url.trim_start_matches("https://"), 40),
},
NodeKind::Group { label, .. } => label.clone().unwrap_or_else(|| "Group".to_owned()),
NodeKind::Unknown { type_name } => format!("<{type_name}>"),
}
@@ -265,8 +272,9 @@ impl Serialize for Node {
map.insert("file".into(), Value::String(file.clone()));
insert_opt_str(&mut map, "subpath", subpath.as_deref());
}
NodeKind::Link { url } => {
NodeKind::Link { url, label } => {
map.insert("url".into(), Value::String(url.clone()));
insert_opt_str(&mut map, "label", label.as_deref());
}
NodeKind::Group {
label,
@@ -315,6 +323,7 @@ impl<'de> Deserialize<'de> for Node {
},
"link" => NodeKind::Link {
url: take_string(&mut map, "url").unwrap_or_default(),
label: take_string(&mut map, "label"),
},
"group" => NodeKind::Group {
label: take_string(&mut map, "label"),
@@ -638,6 +647,11 @@ fn truncate(s: &str, max: usize) -> String {
}
}
/// A label that carries something to show: present, and not just whitespace.
pub fn display_text(label: Option<&str>) -> Option<&str> {
label.map(str::trim).filter(|s| !s.is_empty())
}
/// The last component of a `/`- or `\`-separated path.
pub fn file_name(path: &str) -> &str {
path.rsplit(['/', '\\']).next().unwrap_or(path)
@@ -731,6 +745,37 @@ mod tests {
assert!(canvas.to_json().contains("nope"));
}
#[test]
fn a_link_node_can_carry_a_display_text() {
let json = r#"{"nodes":[{"id":"a","type":"link","url":"https://jsoncanvas.org",
"label":"The spec","x":0,"y":0,"width":400,"height":400}]}"#;
let canvas = Canvas::from_json(json).unwrap();
let NodeKind::Link { label, .. } = &canvas.nodes[0].kind else {
panic!("expected a link node");
};
assert_eq!(label.as_deref(), Some("The spec"));
assert_eq!(canvas.nodes[0].title(), "The spec");
let out = canvas.to_json();
assert!(out.contains("\"label\": \"The spec\""), "{out}");
// Without one, the node still describes itself by its URL and no
// `label` attribute is written.
let plain = Canvas::from_json(SAMPLE).unwrap();
assert_eq!(plain.nodes[3].title(), "jsoncanvas.org");
let out = serde_json::to_string(&plain.nodes[3]).unwrap();
assert!(!out.contains("label"), "{out}");
}
#[test]
fn a_blank_display_text_falls_back_to_the_url() {
assert_eq!(display_text(Some(" ")), None);
assert_eq!(display_text(Some(" Docs ")), Some("Docs"));
let json = r#"{"nodes":[{"id":"a","type":"link","url":"https://example.com",
"label":" "}]}"#;
let canvas = Canvas::from_json(json).unwrap();
assert_eq!(canvas.nodes[0].title(), "example.com");
}
#[test]
fn removing_a_node_removes_its_edges() {
let mut canvas = Canvas::from_json(SAMPLE).unwrap();
+17 -5
View File
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
use eframe::egui::{self, Align, Layout, RichText, Ui};
use crate::markdown;
use crate::model::{Node, NodeKind, file_name};
use crate::model::{Node, NodeKind, display_text, file_name};
use crate::theme::Palette;
/// Files larger than this are not previewed as text.
@@ -101,7 +101,7 @@ pub fn render_body(ui: &mut Ui, node: &Node, ctx: &mut BodyContext<'_>) -> Optio
match &node.kind {
NodeKind::Text { text } => render_text(ui, text, ctx),
NodeKind::File { file, subpath } => render_file(ui, file, subpath.as_deref(), ctx),
NodeKind::Link { url } => render_link(ui, url, ctx),
NodeKind::Link { url, label } => render_link(ui, url, label.as_deref(), ctx),
NodeKind::Group { .. } => None,
NodeKind::Unknown { type_name } => {
ui.label(
@@ -259,8 +259,14 @@ fn excerpt_for(text: &str, subpath: Option<&str>) -> String {
}
}
fn render_link(ui: &mut Ui, url: &str, ctx: &mut BodyContext<'_>) -> Option<String> {
fn render_link(
ui: &mut Ui,
url: &str,
label: Option<&str>,
ctx: &mut BodyContext<'_>,
) -> Option<String> {
let palette = ctx.palette;
let display = display_text(label);
let mut activated = None;
ui.with_layout(Layout::top_down(Align::Min), |ui| {
ui.horizontal(|ui| {
@@ -274,9 +280,15 @@ fn render_link(ui: &mut Ui, url: &str, ctx: &mut BodyContext<'_>) -> Option<Stri
);
});
ui.add_space(2.0);
// With a display text the URL itself moves to the tooltip, so the node
// still says where it goes without spelling the address out.
let (text, size, hover) = match display {
Some(display) => (display, 15.0, url),
None => (url, 12.0, "Open in the default browser"),
};
if ui
.link(RichText::new(url).size(12.0).color(palette.accent))
.on_hover_text("Open in the default browser")
.link(RichText::new(text).size(size).color(palette.accent))
.on_hover_text(hover)
.clicked()
{
activated = Some(url.to_owned());
+1 -1
View File
@@ -1337,7 +1337,7 @@ impl CanvasView {
match &node.kind {
NodeKind::Text { .. } | NodeKind::Group { .. } => self.edit_node(id),
NodeKind::File { file, .. } => output.open_target = Some(file.clone()),
NodeKind::Link { url } => output.open_target = Some(url.clone()),
NodeKind::Link { url, .. } => output.open_target = Some(url.clone()),
NodeKind::Unknown { .. } => {}
}
}