Skip to content

Commit 78f835d

Browse files
authored
Add support for pasting multiline/HTML/RTF text (#10)
* Fix pasting multiline text and ensure each line becomes a proper paragraph * Add RTF/HTML paste support * Fix clippy lints * Fix more clippy lints * Fix platform-specific clippy lints
1 parent c2996c8 commit 78f835d

File tree

8 files changed

+1324
-37
lines changed

8 files changed

+1324
-37
lines changed

Cargo.lock

Lines changed: 433 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gui/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ repository.workspace = true
99
[dependencies]
1010
piki-core = { version = "0.2.0", path = "../core" }
1111
fltk = { version = "1.5.20", features = ["use-wayland"] }
12+
fltk-sys = "1.5.20"
1213
pulldown-cmark = "0.13.0"
13-
tdoc = { version = "0.8.0" }
1414
clap = { version = "4.5", features = ["derive"] }
1515
walkdir = "2.5"
1616
regex = "1.10"
@@ -19,6 +19,10 @@ serde = { version = "1.0", features = ["derive"] }
1919
toml = "0.9"
2020
unicode-segmentation = "1.10"
2121
chrono = "0.4.42"
22+
arboard = "3.4"
23+
tdoc = "0.8.1"
24+
objc = "0.2"
25+
rtf-parser = "0.4"
2226

2327
[dev-dependencies]
2428
insta = "1.34"

gui/src/clipboard.rs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
use std::io::Cursor;
2+
3+
use tdoc::{Document, html, markdown};
4+
5+
use crate::rtf;
6+
7+
#[derive(Debug)]
8+
pub enum ClipboardDocumentError {
9+
Empty,
10+
ClipboardUnavailable(String),
11+
Parse(String),
12+
}
13+
14+
/// Read the system clipboard and convert it into a `tdoc::Document`.
15+
/// Accepts an optional plain-text fallback (typically provided by FLTK on platforms
16+
/// where arboard isn't available) along with additional format notes supplied by the caller.
17+
pub fn read_document_from_system(
18+
fallback_plain: Option<&str>,
19+
platform_formats: &[String],
20+
platform_rtf: Option<&[u8]>,
21+
) -> Result<Document, ClipboardDocumentError> {
22+
let mut diagnostics = platform_formats.to_vec();
23+
24+
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
25+
{
26+
let result = match read_with_arboard(&mut diagnostics, platform_rtf) {
27+
Ok(doc) => Ok(doc),
28+
Err(err) => {
29+
if let Some(text) = fallback_plain {
30+
diagnostics.push(format!(
31+
"fallback:text/plain ({} bytes from FLTK)",
32+
text.len()
33+
));
34+
match document_from_plaintext(text) {
35+
Ok(doc) => Ok(doc),
36+
Err(parse_err) => Err(parse_err),
37+
}
38+
} else {
39+
Err(err)
40+
}
41+
}
42+
};
43+
log_formats(&diagnostics);
44+
result
45+
}
46+
47+
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
48+
{
49+
let result = fallback_plain
50+
.map(|text| {
51+
diagnostics.push(format!(
52+
"fallback:text/plain ({} bytes from FLTK)",
53+
text.len()
54+
));
55+
document_from_plaintext(text)
56+
})
57+
.unwrap_or(Err(ClipboardDocumentError::Empty));
58+
log_formats(&diagnostics);
59+
return result;
60+
}
61+
}
62+
63+
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
64+
fn read_with_arboard(
65+
diagnostics: &mut Vec<String>,
66+
platform_rtf: Option<&[u8]>,
67+
) -> Result<Document, ClipboardDocumentError> {
68+
use arboard::Clipboard;
69+
70+
let mut clipboard = Clipboard::new()
71+
.map_err(|err| ClipboardDocumentError::ClipboardUnavailable(err.to_string()))?;
72+
73+
match clipboard.get().html() {
74+
Ok(html) if !html.trim().is_empty() => {
75+
diagnostics.push(format!("arboard:text/html ({} bytes)", html.len()));
76+
if let Ok(doc) = document_from_html(&html) {
77+
return Ok(doc);
78+
} else {
79+
diagnostics.push("arboard:text/html parse failed".to_string());
80+
}
81+
}
82+
Ok(_) => {
83+
diagnostics.push("arboard:text/html (empty payload)".to_string());
84+
}
85+
Err(arboard::Error::ContentNotAvailable) => {
86+
diagnostics.push("arboard:text/html unavailable".to_string());
87+
}
88+
Err(err) => {
89+
diagnostics.push(format!("arboard:text/html error ({err})"));
90+
}
91+
}
92+
93+
if let Some(rtf_bytes) = platform_rtf {
94+
diagnostics.push(format!("platform:public.rtf ({} bytes)", rtf_bytes.len()));
95+
match rtf::parse_rtf_document(rtf_bytes) {
96+
Ok(doc) => return Ok(doc),
97+
Err(err) => diagnostics.push(format!("platform:public.rtf parse failed ({err})")),
98+
}
99+
}
100+
101+
let text = clipboard.get_text().map_err(|err| match err {
102+
arboard::Error::ContentNotAvailable => ClipboardDocumentError::Empty,
103+
other => ClipboardDocumentError::ClipboardUnavailable(other.to_string()),
104+
})?;
105+
106+
diagnostics.push(format!("arboard:text/plain ({} bytes)", text.len()));
107+
108+
document_from_plaintext(&text)
109+
}
110+
111+
fn document_from_plaintext(text: &str) -> Result<Document, ClipboardDocumentError> {
112+
if text.trim().is_empty() {
113+
return Err(ClipboardDocumentError::Empty);
114+
}
115+
116+
markdown::parse(Cursor::new(text.as_bytes()))
117+
.map_err(|err| ClipboardDocumentError::Parse(err.to_string()))
118+
}
119+
120+
fn document_from_html(html_content: &str) -> Result<Document, ClipboardDocumentError> {
121+
if html_content.trim().is_empty() {
122+
return Err(ClipboardDocumentError::Empty);
123+
}
124+
125+
html::parse(Cursor::new(html_content.as_bytes()))
126+
.map_err(|err| ClipboardDocumentError::Parse(err.to_string()))
127+
}
128+
129+
fn log_formats(formats: &[String]) {
130+
if formats.is_empty() {
131+
eprintln!("[piki] Clipboard formats during paste: (none detected)");
132+
} else {
133+
eprintln!(
134+
"[piki] Clipboard formats during paste: {}",
135+
formats.join(", ")
136+
);
137+
}
138+
}

gui/src/fltk_structured_rich_display.rs

Lines changed: 180 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
11
// FLTK integration for StructuredRichDisplay widget
2+
#![allow(unexpected_cfgs)]
23

4+
use crate::clipboard;
35
use crate::fltk_draw_context::FltkDrawContext;
46
use crate::responsive_scrollbar::ResponsiveScrollbar;
57
use crate::richtext::structured_document::{BlockType, InlineContent};
68
use crate::richtext::structured_rich_display::StructuredRichDisplay;
79
use fltk::{app::MouseWheel, enums::*, prelude::*};
810
use std::cell::RefCell;
11+
use std::ffi::CStr;
12+
#[cfg(target_os = "macos")]
13+
use std::ffi::CString;
914
use std::rc::Rc;
1015
use std::time::Instant;
1116

17+
#[cfg(target_os = "macos")]
18+
use objc::rc::autoreleasepool;
19+
#[cfg(target_os = "macos")]
20+
use objc::runtime::Object;
21+
#[cfg(target_os = "macos")]
22+
use objc::{class, msg_send, sel, sel_impl};
23+
1224
type Callback<T> = Rc<RefCell<Option<Box<dyn Fn(T) + 'static>>>>;
1325
type MutCallback<T> = Rc<RefCell<Option<Box<dyn FnMut(T) + 'static>>>>;
1426
type MutCallback0 = Rc<RefCell<Option<Box<dyn FnMut() + 'static>>>>;
@@ -1811,14 +1823,45 @@ impl FltkStructuredRichDisplay {
18111823
}
18121824
Event::Paste => {
18131825
if edit_mode {
1814-
let pasted = fltk::app::event_text();
1815-
if !pasted.is_empty() {
1826+
let fallback_text = fltk::app::event_text();
1827+
let (platform_formats, platform_rtf) = inspect_platform_clipboard();
1828+
let fallback_ref = if fallback_text.is_empty() {
1829+
None
1830+
} else {
1831+
Some(fallback_text.as_str())
1832+
};
1833+
1834+
let mut applied = false;
1835+
1836+
if let Ok(doc) = clipboard::read_document_from_system(
1837+
fallback_ref,
1838+
&platform_formats,
1839+
platform_rtf.as_deref(),
1840+
) {
18161841
let mut disp = display.borrow_mut();
1817-
let _ = disp.editor_mut().paste(&pasted);
1818-
if let Some(cb) = &mut *change_cb.borrow_mut() {
1819-
(cb)();
1842+
if disp.editor_mut().insert_document(&doc).is_ok() {
1843+
if let Some(cb) = &mut *change_cb.borrow_mut() {
1844+
(cb)();
1845+
}
1846+
w.redraw();
1847+
applied = true;
1848+
}
1849+
}
1850+
1851+
if !applied {
1852+
let fallback_ref = if fallback_text.is_empty() {
1853+
None
1854+
} else {
1855+
Some(fallback_text.as_str())
1856+
};
1857+
if let Some(text) = fallback_ref {
1858+
let mut disp = display.borrow_mut();
1859+
let _ = disp.editor_mut().paste(text);
1860+
if let Some(cb) = &mut *change_cb.borrow_mut() {
1861+
(cb)();
1862+
}
1863+
w.redraw();
18201864
}
1821-
w.redraw();
18221865
}
18231866
true
18241867
} else {
@@ -1949,3 +1992,134 @@ impl FltkStructuredRichDisplay {
19491992
blocks.get(idx).map(|b| b.block_type.clone())
19501993
}
19511994
}
1995+
1996+
fn inspect_platform_clipboard() -> (Vec<String>, Option<Vec<u8>>) {
1997+
let mut formats = Vec::new();
1998+
#[cfg_attr(not(target_os = "macos"), allow(unused_mut))]
1999+
let mut rtf_payload = None;
2000+
2001+
unsafe {
2002+
let raw = fltk_sys::fl::Fl_event_clipboard_type();
2003+
if !raw.is_null() {
2004+
let fmt = CStr::from_ptr(raw as *const std::os::raw::c_char)
2005+
.to_string_lossy()
2006+
.into_owned();
2007+
formats.push(describe_platform_format("fltk", &fmt));
2008+
if rtf_payload.is_none() && fmt.eq_ignore_ascii_case("public.rtf") {
2009+
// FLTK doesn't expose raw data, rely on macOS helper below.
2010+
}
2011+
}
2012+
}
2013+
2014+
#[cfg(target_os = "macos")]
2015+
{
2016+
let (mut mac_formats, mac_rtf) = macos_pasteboard_formats_and_rtf();
2017+
formats.append(&mut mac_formats);
2018+
if rtf_payload.is_none() {
2019+
rtf_payload = mac_rtf;
2020+
}
2021+
}
2022+
2023+
(formats, rtf_payload)
2024+
}
2025+
2026+
fn is_supported_type(name: &str) -> bool {
2027+
let lower = name.to_ascii_lowercase();
2028+
matches!(
2029+
lower.as_str(),
2030+
"text/plain"
2031+
| "public.utf8-plain-text"
2032+
| "public.utf16-plain-text"
2033+
| "public.html"
2034+
| "text/html"
2035+
| "public.rtf"
2036+
| "apple web archive pasteboard type"
2037+
| "apple html pasteboard type"
2038+
| "nspasteboardtypehtml"
2039+
| "nspasteboardtypestring"
2040+
)
2041+
}
2042+
2043+
fn describe_platform_format(source: &str, format_name: &str) -> String {
2044+
let status = if is_supported_type(format_name) {
2045+
"supported"
2046+
} else {
2047+
"unsupported"
2048+
};
2049+
format!("{source}:{format_name} ({status})")
2050+
}
2051+
2052+
#[cfg(target_os = "macos")]
2053+
fn macos_pasteboard_formats_and_rtf() -> (Vec<String>, Option<Vec<u8>>) {
2054+
use std::collections::HashSet;
2055+
autoreleasepool(|| unsafe {
2056+
let mut formats = Vec::new();
2057+
let mut seen = HashSet::new();
2058+
let mut rtf_payload = None;
2059+
let pasteboard: *mut Object = msg_send![class!(NSPasteboard), generalPasteboard];
2060+
if pasteboard.is_null() {
2061+
return (formats, rtf_payload);
2062+
}
2063+
let items: *mut Object = msg_send![pasteboard, pasteboardItems];
2064+
if items.is_null() {
2065+
return (formats, rtf_payload);
2066+
}
2067+
let item_count: usize = msg_send![items, count];
2068+
for i in 0..item_count {
2069+
let item: *mut Object = msg_send![items, objectAtIndex: i];
2070+
if item.is_null() {
2071+
continue;
2072+
}
2073+
let types: *mut Object = msg_send![item, types];
2074+
if types.is_null() {
2075+
continue;
2076+
}
2077+
let type_count: usize = msg_send![types, count];
2078+
for j in 0..type_count {
2079+
let ty: *mut Object = msg_send![types, objectAtIndex: j];
2080+
if ty.is_null() {
2081+
continue;
2082+
}
2083+
let utf8: *const std::os::raw::c_char = msg_send![ty, UTF8String];
2084+
if utf8.is_null() {
2085+
continue;
2086+
}
2087+
let value = CStr::from_ptr(utf8).to_string_lossy().into_owned();
2088+
if seen.insert(value.clone()) {
2089+
formats.push(describe_platform_format("macos", &value));
2090+
}
2091+
if rtf_payload.is_none() && value.eq_ignore_ascii_case("public.rtf") {
2092+
rtf_payload = read_pasteboard_data(item, "public.rtf");
2093+
}
2094+
}
2095+
}
2096+
(formats, rtf_payload)
2097+
})
2098+
}
2099+
2100+
#[cfg(target_os = "macos")]
2101+
#[allow(unsafe_op_in_unsafe_fn)]
2102+
unsafe fn read_pasteboard_data(item: *mut Object, type_name: &str) -> Option<Vec<u8>> {
2103+
let ns_type = nsstring_from_str(type_name);
2104+
if ns_type.is_null() {
2105+
return None;
2106+
}
2107+
let data: *mut Object = msg_send![item, dataForType: ns_type];
2108+
if data.is_null() {
2109+
return None;
2110+
}
2111+
let length: usize = msg_send![data, length];
2112+
let bytes: *const u8 = msg_send![data, bytes];
2113+
if bytes.is_null() || length == 0 {
2114+
return None;
2115+
}
2116+
let slice = std::slice::from_raw_parts(bytes, length);
2117+
Some(slice.to_vec())
2118+
}
2119+
2120+
#[cfg(target_os = "macos")]
2121+
#[allow(unsafe_op_in_unsafe_fn)]
2122+
unsafe fn nsstring_from_str(value: &str) -> *mut Object {
2123+
let c_string = CString::new(value).unwrap();
2124+
msg_send![class!(NSString), stringWithUTF8String: c_string.as_ptr()]
2125+
}

gui/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Library exports for piki
2+
pub mod clipboard;
23
pub mod content;
34
pub mod context_menu;
45
pub mod draw_context;
@@ -9,5 +10,6 @@ pub mod link_handler;
910
pub mod page_ui;
1011
pub mod responsive_scrollbar;
1112
pub mod richtext;
13+
pub mod rtf;
1214
pub mod theme;
1315
pub mod ui_adapters;

0 commit comments

Comments
 (0)