From be5b66e2a95c7a93555a580bc3f751eae1a51d6e Mon Sep 17 00:00:00 2001 From: Avinash Thakur Date: Wed, 21 Dec 2022 19:39:00 +0530 Subject: [PATCH] initial implementation of get/set_bytes & get_formats for X11(linux) --- .gitignore | 1 + examples/get_bytes.rs | 46 +++++ examples/hello_world.rs | 13 +- examples/set_bytes.rs | 11 ++ src/common.rs | 5 + src/lib.rs | 35 ++++ src/platform/linux/mod.rs | 26 +++ src/platform/linux/wayland.rs | 17 ++ src/platform/linux/x11.rs | 304 ++++++++++++++++------------------ src/platform/osx.rs | 12 ++ src/platform/windows.rs | 12 ++ 11 files changed, 321 insertions(+), 161 deletions(-) create mode 100644 examples/get_bytes.rs create mode 100644 examples/set_bytes.rs diff --git a/.gitignore b/.gitignore index 8d723beb..b2bd2b76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target .vscode *.png +examples/tmp/* \ No newline at end of file diff --git a/examples/get_bytes.rs b/examples/get_bytes.rs new file mode 100644 index 00000000..6fc32cda --- /dev/null +++ b/examples/get_bytes.rs @@ -0,0 +1,46 @@ +use std::{fs, path::PathBuf}; + +use arboard::Clipboard; + +const FILE_TYPES: &'static [(&[u8], &str)] = &[ + (b"image/png", "png"), + (b"image/jpeg", "jpeg"), + (b"image/bmp", "bmp"), + (b"image/gif", "gif"), + (b"text/html", "html"), + (b"text/plain", "txt"), + (b"text/uri-list", "txt"), + (b"SAVE_TARGETS", "sav.txt"), +]; + +fn main() { + let mut clipboard = Clipboard::new().unwrap(); + + println!("Formats available are: {:#?}", clipboard.get_formats()); + + let tmp_path = PathBuf::from("./examples/tmp/"); + if tmp_path.exists() { + fs::remove_dir_all(&tmp_path).unwrap(); + } + fs::create_dir_all(&tmp_path).unwrap(); + + for (mime, ext) in FILE_TYPES.iter() { + + let path = tmp_path.join(&format!( + "output-{}.{ext}", + mime.into_iter() + .map(|c| if (*c as char).is_alphanumeric() { *c as char } else { '-' }) + .collect::() + )); + + if let Ok(data) = clipboard.get_bytes(mime) { + println!("Saving {:?} as {}", mime, path.display()); + fs::write(path, data).unwrap(); + } else { + println!( + r#""{}" mime-type not available"#, + String::from_utf8_lossy(mime) // mime.into_iter().map(|c| *c as char).collect::() + ) + } + } +} diff --git a/examples/hello_world.rs b/examples/hello_world.rs index 07f8c19d..d2b50535 100644 --- a/examples/hello_world.rs +++ b/examples/hello_world.rs @@ -4,9 +4,20 @@ use simple_logger::SimpleLogger; fn main() { SimpleLogger::new().init().unwrap(); let mut clipboard = Clipboard::new().unwrap(); + + for format in [b"text/html" as &[u8], b"text/plain", b"image/png", b"application/json"] { + println!("Format: {:?}", String::from_utf8_lossy(format)); + println!( + "Content: {:#?}", + clipboard + .get_bytes(format) + .map(|bytes| bytes.into_iter().map(|c| c as char).collect::()) + ); + } + println!("Clipboard text was: {:?}", clipboard.get_text()); let the_string = "Hello, world!"; - clipboard.set_text(the_string).unwrap(); + // clipboard.set_text(the_string).unwrap(); println!("But now the clipboard text should be: \"{}\"", the_string); } diff --git a/examples/set_bytes.rs b/examples/set_bytes.rs new file mode 100644 index 00000000..7bb96ae6 --- /dev/null +++ b/examples/set_bytes.rs @@ -0,0 +1,11 @@ +use arboard::Clipboard; +use simple_logger::SimpleLogger; +use std::{fs, thread, time::Duration}; + +fn main() { + SimpleLogger::new().init().unwrap(); + let mut ctx = Clipboard::new().unwrap(); + ctx.set_bytes(fs::read("./examples/ferris.png").unwrap(), &b"image/png".to_owned()).unwrap(); + println!("Copied rust logo(staying for 5s)"); + thread::sleep(Duration::from_secs(2)); +} diff --git a/src/common.rs b/src/common.rs index 95c63f6f..284f0855 100644 --- a/src/common.rs +++ b/src/common.rs @@ -54,6 +54,10 @@ pub enum Error { #[error("The image or the text that was about the be transferred to/from the clipboard could not be converted to the appropriate format.")] ConversionFailure, + /// This can happen if format is not available. + #[error("Possible format is unsupported")] + FormatUnsupported, + /// Any error that doesn't fit the other error types. /// /// The `description` field is only meant to help the developer and should not be relied on as a @@ -79,6 +83,7 @@ impl std::fmt::Debug for Error { ClipboardNotSupported, ClipboardOccupied, ConversionFailure, + FormatUnsupported, Unknown { .. } ); f.write_fmt(format_args!("{} - \"{}\"", name, self)) diff --git a/src/lib.rs b/src/lib.rs index fbbd0b86..97fae57a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -73,6 +73,21 @@ impl Clipboard { self.get().text() } + /// Fetches utf-8 text from the clipboard and returns it. + pub fn get_formats(&mut self) -> Result, Error> { + self.get().formats() + } + + /// Fetches utf-8 text from the clipboard and returns it. + pub fn get_bytes(&mut self, format: &[u8]) -> Result, Error> { + self.get().bytes(format) + } + + /// Set bytes + pub fn set_bytes<'a, T: Into>>(&mut self, bytes: T, format: &[u8]) -> Result<(), Error> { + self.set().bytes(bytes, format) + } + /// Places the text onto the clipboard. Any valid utf-8 string is accepted. pub fn set_text<'a, T: Into>>(&mut self, text: T) -> Result<(), Error> { self.set().text(text) @@ -155,6 +170,16 @@ impl Get<'_> { pub fn image(self) -> Result, Error> { self.platform.image() } + + /// Get formats/mimes available in clipboard + pub fn formats(self) -> Result, Error> { + self.platform.formats() + } + + /// Get formats/mimes available in clipboard + pub fn bytes(self, format: &[u8]) -> Result, Error> { + self.platform.bytes(format) + } } /// A builder for an operation that sets a value to the clipboard. @@ -164,6 +189,16 @@ pub struct Set<'clipboard> { } impl Set<'_> { + /// Completes the "set" operation by placing text onto the clipboard. Any valid UTF-8 string + /// is accepted. + pub fn bytes<'a, T: Into>>( + self, + bytes: T, + format: &[u8], + ) -> Result<(), Error> { + let bytes = bytes.into(); + self.platform.bytes(bytes, format.into()) + } /// Completes the "set" operation by placing text onto the clipboard. Any valid UTF-8 string /// is accepted. pub fn text<'a, T: Into>>(self, text: T) -> Result<(), Error> { diff --git a/src/platform/linux/mod.rs b/src/platform/linux/mod.rs index 14dc9fe1..2492ddcd 100644 --- a/src/platform/linux/mod.rs +++ b/src/platform/linux/mod.rs @@ -122,6 +122,22 @@ impl<'clipboard> Get<'clipboard> { Clipboard::WlDataControl(clipboard) => clipboard.get_image(self.selection), } } + + pub(crate) fn formats(self) -> Result, Error> { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.get_formats(self.selection), + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.get_formats(self.selection), + } + } + + pub(crate) fn bytes(self, format: &[u8]) -> Result, Error> { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.get_bytes(self.selection, format.into()), + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.get_bytes(self.selection, format.into()), + } + } } /// Linux-specific extensions to the [`Get`](super::Get) builder. @@ -167,6 +183,16 @@ impl<'clipboard> Set<'clipboard> { } } + pub(crate) fn bytes(self, bytes: Cow<'_, [u8]>, format: Cow<'_, [u8]>) -> Result<(), Error> { + match self.clipboard { + Clipboard::X11(clipboard) => { + clipboard.set_bytes(bytes, format, self.selection, self.wait) + } + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.set_bytes(bytes, self.selection, self.wait), + } + } + #[cfg(feature = "image-data")] pub(crate) fn image(self, image: ImageData<'_>) -> Result<(), Error> { match self.clipboard { diff --git a/src/platform/linux/wayland.rs b/src/platform/linux/wayland.rs index 955a7368..159e2713 100644 --- a/src/platform/linux/wayland.rs +++ b/src/platform/linux/wayland.rs @@ -54,6 +54,14 @@ impl Clipboard { Ok(Self {}) } + pub(crate) fn get_formats(&mut self, selection: LinuxClipboardKind) -> Result, Error> { + todo!() + } + + pub(crate) fn get_bytes(&mut self, selection: LinuxClipboardKind, format: &[u8]) -> Result, Error> { + todo!() + } + pub(crate) fn get_text(&mut self, selection: LinuxClipboardKind) -> Result { use wl_clipboard_rs::paste::MimeType; @@ -92,6 +100,15 @@ impl Clipboard { Ok(()) } + pub(crate) fn set_bytes( + &self, bytes: Cow<'_, Vec>, + format: Cow<'_, [u8]>, + selection: LinuxClipboardKind, + wait: bool, + ) -> Result<()> { + todo!() + } + pub(crate) fn set_html( &self, html: Cow<'_, str>, diff --git a/src/platform/linux/x11.rs b/src/platform/linux/x11.rs index 14c7865e..31d014fb 100644 --- a/src/platform/linux/x11.rs +++ b/src/platform/linux/x11.rs @@ -33,8 +33,7 @@ use x11rb::{ protocol::{ xproto::{ Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, EventMask, PropMode, Property, - PropertyNotifyEvent, SelectionNotifyEvent, SelectionRequestEvent, Time, WindowClass, - SELECTION_NOTIFY_EVENT, + SelectionNotifyEvent, SelectionRequestEvent, Time, WindowClass, SELECTION_NOTIFY_EVENT, }, Event, }, @@ -131,6 +130,8 @@ struct Inner { serve_stopped: AtomicBool, } +const ANY_PROPERTY: u32 = NONE; + impl XContext { fn new() -> Result { // create a new connection to an X11 server @@ -191,10 +192,11 @@ struct ClipboardData { format: Atom, } -enum ReadSelNotifyResult { - GotData(Vec), - IncrStarted, - EventNotRecognized, +#[derive(Debug)] +enum ContextState { + // None state is not used + SentConvertSelection, + UseIncr, } impl Inner { @@ -235,12 +237,13 @@ impl Inner { .conn .set_selection_owner(server_win, self.atom_of(selection), Time::CURRENT_TIME) .map_err(|_| Error::ClipboardOccupied)?; - - self.server.conn.flush().map_err(into_unknown)?; + self.server.conn.sync().map_err(into_unknown)?; + // self.server.conn.flush().map_err(into_unknown)?; // Just setting the data, and the `serve_requests` will take care of the rest. let selection = self.selection_of(selection); let mut data_guard = selection.data.write(); + println!("Data write"); *data_guard = Some(data); // Lock the mutex to both ensure that no wakers of `data_changed` can wake us between @@ -250,6 +253,7 @@ impl Inner { // Notify any existing waiting threads that we have changed the data in the selection. // It is important that the mutex is locked to prevent this notification getting lost. + println!("Notify all"); selection.data_changed.notify_all(); if wait { @@ -284,10 +288,11 @@ impl Inner { // return Ok(data) // } let reader = XContext::new()?; + let selection_atom = self.atom_of(selection); trace!("Trying to get the clipboard data."); for format in formats { - match self.read_single(&reader, selection, *format) { + match self.read_single(&reader, selection_atom, *format) { Ok(bytes) => { return Ok(ClipboardData { bytes, format: *format }); } @@ -300,12 +305,19 @@ impl Inner { Err(Error::ContentNotAvailable) } + /// NOTE: On X11, clipboard is not persisted. i.e. if the app which created clipboard data closes, the data is lost. + /// There are ways to implement clipboard persistence. + /// Ex https://www.freedesktop.org/wiki/ClipboardManager/ + /// Derived from [doOut](https://github.com/astrand/xclip/blob/b372f73579d30f9ba998ffd0a73694e7abe2c313/xclip.c#L714) in `xclip` + /// More resources: + /// * https://www.uninformativ.de/blog/postings/2017-04-02/0/POSTING-en.html fn read_single( &self, reader: &XContext, - selection: LinuxClipboardKind, + selection: Atom, target_format: Atom, ) -> Result> { + println!("Selection owner is {:x}", reader.conn.get_selection_owner(selection).map_err(into_unknown)?.reply().map_err(into_unknown)?.owner); // Delete the property so that we can detect (using property notify) // when the selection owner receives our request. reader @@ -318,7 +330,7 @@ impl Inner { .conn .convert_selection( reader.win_id, - self.atom_of(selection), + selection, target_format, self.atoms.ARBOARD_CLIPBOARD, Time::CURRENT_TIME, @@ -329,9 +341,10 @@ impl Inner { trace!("Finished `convert_selection`"); let mut incr_data: Vec = Vec::new(); - let mut using_incr = false; - let mut timeout_end = Instant::now() + LONG_TIMEOUT_DUR; + let mut timeout_end = Instant::now() + LONG_TIMEOUT_DUR;// Duration::from_millis(4000); + + let mut context_state = ContextState::SentConvertSelection; while Instant::now() < timeout_end { let event = reader.conn.poll_for_event().map_err(into_unknown)?; @@ -342,48 +355,58 @@ impl Inner { continue; } }; - match event { - // The first response after requesting a selection. - Event::SelectionNotify(event) => { - trace!("Read SelectionNotify"); - let result = self.handle_read_selection_notify( - reader, - target_format, - &mut using_incr, - &mut incr_data, - event, - )?; - match result { - ReadSelNotifyResult::GotData(data) => return Ok(data), - ReadSelNotifyResult::IncrStarted => { - // This means we received an indication that an the - // data is going to be sent INCRementally. Let's - // reset our timeout. - timeout_end += SHORT_TIMEOUT_DUR; - } - ReadSelNotifyResult::EventNotRecognized => (), + match context_state { + ContextState::SentConvertSelection => { + let Event::SelectionNotify(evt) = event else { + continue + }; + if evt.property == 0 { + // Bad Target + return Err(Error::FormatUnsupported); + } + let reply = reader + .conn + .get_property( + true, + reader.win_id, + evt.property, + ANY_PROPERTY, + 0, + u32::MAX / 4, + ) + .map_err(into_unknown)? + .reply() + .map_err(into_unknown)?; + + if reply.type_ == self.atoms.INCR { + reader.conn.flush().map_err(into_unknown)?; + context_state = ContextState::UseIncr; + continue; } + return Ok(reply.value); } - // If the previous SelectionNotify event specified that the data - // will be sent in INCR segments, each segment is transferred in - // a PropertyNotify event. - Event::PropertyNotify(event) => { - let result = self.handle_read_property_notify( - reader, - target_format, - using_incr, - &mut incr_data, - &mut timeout_end, - event, - )?; - if result { + ContextState::UseIncr => { + let Event::PropertyNotify(evt) = event else { + continue; + }; + if evt.state != Property::NEW_VALUE { + continue; + } + let reply = reader + .conn + .get_property(true, evt.window, evt.atom, ANY_PROPERTY, 0, u32::MAX / 4) + .map_err(into_unknown)? + .reply() + .map_err(into_unknown)?; + if reply.value_len == 0 { return Ok(incr_data); } + incr_data.extend(reply.value); + timeout_end = Instant::now() + SHORT_TIMEOUT_DUR; + reader.conn.flush().map_err(into_unknown)?; } - _ => log::trace!("An unexpected event arrived while reading the clipboard."), - } + }; } - log::info!("Time-out hit while reading the clipboard."); Err(Error::ContentNotAvailable) } @@ -454,115 +477,6 @@ impl Inner { }) } - fn handle_read_selection_notify( - &self, - reader: &XContext, - target_format: u32, - using_incr: &mut bool, - incr_data: &mut Vec, - event: SelectionNotifyEvent, - ) -> Result { - // The property being set to NONE means that the `convert_selection` - // failed. - - // According to: https://tronche.com/gui/x/icccm/sec-2.html#s-2.4 - // the target must be set to the same as what we requested. - if event.property == NONE || event.target != target_format { - return Err(Error::ContentNotAvailable); - } - if self.kind_of(event.selection).is_none() { - log::info!("Received a SelectionNotify for a selection other than CLIPBOARD, PRIMARY or SECONDARY. This is unexpected."); - return Ok(ReadSelNotifyResult::EventNotRecognized); - } - if *using_incr { - log::warn!("Received a SelectionNotify while already expecting INCR segments."); - return Ok(ReadSelNotifyResult::EventNotRecognized); - } - // request the selection - let mut reply = reader - .conn - .get_property(true, event.requestor, event.property, event.target, 0, u32::MAX / 4) - .map_err(into_unknown)? - .reply() - .map_err(into_unknown)?; - - // trace!("Property.type: {:?}", self.atom_name(reply.type_)); - - // we found something - if reply.type_ == target_format { - Ok(ReadSelNotifyResult::GotData(reply.value)) - } else if reply.type_ == self.atoms.INCR { - // Note that we call the get_property again because we are - // indicating that we are ready to receive the data by deleting the - // property, however deleting only works if the type matches the - // property type. But the type didn't match in the previous call. - reply = reader - .conn - .get_property( - true, - event.requestor, - event.property, - self.atoms.INCR, - 0, - u32::MAX / 4, - ) - .map_err(into_unknown)? - .reply() - .map_err(into_unknown)?; - log::trace!("Receiving INCR segments"); - *using_incr = true; - if reply.value_len == 4 { - let min_data_len = reply.value32().and_then(|mut vals| vals.next()).unwrap_or(0); - incr_data.reserve(min_data_len as usize); - } - Ok(ReadSelNotifyResult::IncrStarted) - } else { - // this should never happen, we have sent a request only for supported types - Err(Error::Unknown { - description: String::from("incorrect type received from clipboard"), - }) - } - } - - /// Returns Ok(true) when the incr_data is ready - fn handle_read_property_notify( - &self, - reader: &XContext, - target_format: u32, - using_incr: bool, - incr_data: &mut Vec, - timeout_end: &mut Instant, - event: PropertyNotifyEvent, - ) -> Result { - if event.atom != self.atoms.ARBOARD_CLIPBOARD || event.state != Property::NEW_VALUE { - return Ok(false); - } - if !using_incr { - // This must mean the selection owner received our request, and is - // now preparing the data - return Ok(false); - } - let reply = reader - .conn - .get_property(true, event.window, event.atom, target_format, 0, u32::MAX / 4) - .map_err(into_unknown)? - .reply() - .map_err(into_unknown)?; - - // log::trace!("Received segment. value_len {}", reply.value_len,); - if reply.value_len == 0 { - // This indicates that all the data has been sent. - return Ok(true); - } - incr_data.extend(reply.value); - - // Let's reset our timeout, since we received a valid chunk. - *timeout_end = Instant::now() + SHORT_TIMEOUT_DUR; - - // Not yet complete - Ok(false) - } - fn handle_selection_request(&self, event: SelectionRequestEvent) -> Result<()> { let selection = match self.kind_of(event.selection) { Some(kind) => kind, @@ -575,8 +489,13 @@ impl Inner { let success; // we are asked for a list of supported conversion targets if event.target == self.atoms.TARGETS { - trace!("Handling TARGETS, dst property is {}", self.atom_name_dbg(event.property)); + trace!( + "Handling TARGETS, dst property is {}, asked by {:x}", + self.atom_name_dbg(event.property), + event.requestor + ); let mut targets = Vec::with_capacity(10); + targets.push(self.atoms.TARGETS); targets.push(self.atoms.SAVE_TARGETS); let data = self.selection_of(selection).data.read(); @@ -760,9 +679,10 @@ fn serve_requests(context: Arc) -> Result<(), Box> } Event::SelectionRequest(event) => { trace!( - "SelectionRequest - selection is: {}, target is {}", + "SelectionRequest - selection is: {}, target is {}, property is {}", context.atom_name_dbg(event.selection), context.atom_name_dbg(event.target), + context.atom_name_dbg(event.property), ); // Someone is requesting the clipboard content from us. context.handle_selection_request(event).map_err(into_unknown)?; @@ -844,6 +764,50 @@ impl Clipboard { Ok(Self { inner: ctx }) } + pub(crate) fn get_formats(&self, selection: LinuxClipboardKind) -> Result> { + let formats = [self.inner.atoms.TARGETS]; + let result = self.inner.read(&formats, selection).map_err(into_unknown)?; + let reader = XContext::new()?; + let names = result + .bytes + .chunks_exact(4) // we should get vec of atoms (u32) + .map(|chunk| { + let mut four_bytes = [0;4]; + four_bytes.copy_from_slice(chunk); + let atom = u32::from_ne_bytes(four_bytes); + let name = String::from_utf8( + reader + .conn + .get_atom_name(atom) + .map_err(into_unknown)? + .reply() + .map_err(into_unknown)?.name) + .map_err(into_unknown)?; + Ok(name) + }) + .collect::>>()?; + Ok(names) + } + + pub(crate) fn get_bytes( + &self, + selection: LinuxClipboardKind, + format: &[u8], + ) -> Result> { + let format_atom = self + .inner + .server + .conn + .intern_atom(false, &format) + .map_err(into_unknown)? + .reply() + .map_err(into_unknown)? + .atom; + let formats = [format_atom]; + let result = self.inner.read(&formats, selection)?; + Ok(result.bytes) + } + pub(crate) fn get_text(&self, selection: LinuxClipboardKind) -> Result { let formats = [ self.inner.atoms.UTF8_STRING, @@ -876,6 +840,26 @@ impl Clipboard { self.inner.write(data, selection, wait) } + pub(crate) fn set_bytes( + &self, + bytes: Cow<'_, [u8]>, + format: Cow<'_, [u8]>, + selection: LinuxClipboardKind, + wait: bool, + ) -> Result<()> { + let format_atom = self + .inner + .server + .conn + .intern_atom(false, &format) + .map_err(into_unknown)? + .reply() + .map_err(into_unknown)? + .atom; + let data = vec![ClipboardData { bytes: bytes.into_owned(), format: format_atom }]; + self.inner.write(data, selection, wait) + } + pub(crate) fn set_html( &self, html: Cow<'_, str>, diff --git a/src/platform/osx.rs b/src/platform/osx.rs index 8b41def7..53130b5a 100644 --- a/src/platform/osx.rs +++ b/src/platform/osx.rs @@ -247,6 +247,14 @@ impl<'clipboard> Get<'clipboard> { Err(_) => Err(Error::ConversionFailure), } } + + pub(crate) fn formats(self) -> Result, Error> { + todo!() + } + + pub(crate) fn bytes(self, format: &[u8]) -> Result, Error> { + todo!() + } } pub(crate) struct Set<'clipboard> { @@ -271,6 +279,10 @@ impl<'clipboard> Set<'clipboard> { } } + pub(crate) fn bytes(self, bytes: Cow<'_, Vec>, format: &[u8]) -> Result<(), Error> { + todo!() + } + pub(crate) fn html(self, html: Cow<'_, str>, alt: Option>) -> Result<(), Error> { self.clipboard.clear(); // Text goes to the clipboard as UTF-8 but may be interpreted as Windows Latin 1. diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 39bdc2ef..b6e5b2a6 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -490,6 +490,14 @@ impl<'clipboard> Get<'clipboard> { read_cf_dibv5(&data) } + + pub(crate) fn formats(self) -> Result, Error> { + todo!() + } + + pub(crate) fn bytes(self, format: &[u8]) -> Result, Error> { + todo!() + } } pub(crate) struct Set<'clipboard> { @@ -513,6 +521,10 @@ impl<'clipboard> Set<'clipboard> { add_clipboard_exclusions(open_clipboard, self.exclude_from_cloud, self.exclude_from_history) } + pub(crate) fn bytes(self, bytes: Cow<'_, Vec>, format: &[u8]) -> Result<(), Error> { + todo!() + } + pub(crate) fn html(self, html: Cow<'_, str>, alt: Option>) -> Result<(), Error> { let open_clipboard = self.clipboard?;