diff --git a/editor-libcosmic-image.sh b/editor-libcosmic-image.sh new file mode 100755 index 0000000..444e2cb --- /dev/null +++ b/editor-libcosmic-image.sh @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: MIT OR Apache-2.0 + +RUST_LOG="cosmic_text=debug,editor_libcosmic_image=debug" cargo run --release --package editor-libcosmic-image -- "$@" diff --git a/examples/editor-libcosmic-image/Cargo.toml b/examples/editor-libcosmic-image/Cargo.toml new file mode 100644 index 0000000..58cbb0e --- /dev/null +++ b/examples/editor-libcosmic-image/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "editor-libcosmic-image" +version = "0.1.0" +authors = ["Jeremy Soller "] +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +cosmic-text = { path = "../../" } +env_logger = "0.9" +fontdb = "0.9" +lazy_static = "1.4" +log = "0.4" + +[dependencies.libcosmic] +git = "https://github.com/pop-os/libcosmic" +branch = "cosmic-design-system" +#path = "../../../libcosmic" + +[dependencies.rfd] +version = "0.10" +#TODO: iced portal +#default-features = false +#features = ["xdg-portal"] diff --git a/examples/editor-libcosmic-image/src/main.rs b/examples/editor-libcosmic-image/src/main.rs new file mode 100644 index 0000000..5e61aad --- /dev/null +++ b/examples/editor-libcosmic-image/src/main.rs @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use cosmic::{ + iced::{ + self, + Alignment, + Application, + Command, + Element, + Length, + Theme, + widget::{ + column, + horizontal_space, + pick_list, + row, + text, + }, + }, + settings, + widget::{ + button, + toggler, + }, +}; +use cosmic_text::{ + Attrs, + AttrsList, + FontSystem, + SwashCache, + TextBuffer, + TextMetrics, +}; +use std::{ + env, + fs, + path::PathBuf, + sync::Mutex, +}; + +use self::text_box::text_box; +mod text_box; + +lazy_static::lazy_static! { + static ref FONT_SYSTEM: FontSystem<'static> = FontSystem::new(); +} + +static FONT_SIZES: &'static [TextMetrics] = &[ + TextMetrics::new(10, 14), // Caption + TextMetrics::new(14, 20), // Body + TextMetrics::new(20, 28), // Title 4 + TextMetrics::new(24, 32), // Title 3 + TextMetrics::new(28, 36), // Title 2 + TextMetrics::new(32, 44), // Title 1 +]; + +fn main() -> cosmic::iced::Result { + env_logger::init(); + + let mut settings = settings(); + settings.window.min_size = Some((400, 100)); + Window::run(settings) +} + +pub struct Window { + theme: Theme, + path_opt: Option, + attrs: Attrs<'static>, + buffer: Mutex>, + cache: Mutex>, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug)] +pub enum Message { + Open, + Save, + Bold(bool), + Italic(bool), + Monospaced(bool), + MetricsChanged(TextMetrics), + ThemeChanged(&'static str), +} + +impl Window { + pub fn open(&mut self, path: PathBuf) { + let mut buffer = self.buffer.lock().unwrap(); + match fs::read_to_string(&path) { + Ok(text) => { + log::info!("opened '{}'", path.display()); + buffer.set_text(&text, self.attrs); + self.path_opt = Some(path); + }, + Err(err) => { + log::error!("failed to open '{}': {}", path.display(), err); + buffer.set_text("", self.attrs); + self.path_opt = None; + } + } + } +} + +impl Application for Window { + type Executor = iced::executor::Default; + type Flags = (); + type Message = Message; + type Theme = Theme; + + fn new(_flags: ()) -> (Self, Command) { + let attrs = cosmic_text::Attrs::new() + .monospaced(true) + .family(cosmic_text::Family::Monospace); + + let buffer = TextBuffer::new( + &FONT_SYSTEM, + FONT_SIZES[1 /* Body */], + ); + + let cache = SwashCache::new(&FONT_SYSTEM); + + let mut window = Window { + theme: Theme::Dark, + path_opt: None, + attrs, + buffer: Mutex::new(buffer), + cache: Mutex::new(cache), + }; + if let Some(arg) = env::args().nth(1) { + window.open(PathBuf::from(arg)); + } + (window, Command::none()) + } + + fn theme(&self) -> Theme { + self.theme + } + + fn title(&self) -> String { + if let Some(path) = &self.path_opt { + format!("COSMIC Text - {} - {}", FONT_SYSTEM.locale, path.display()) + } else { + format!("COSMIC Text - {}", FONT_SYSTEM.locale) + } + } + + fn update(&mut self, message: Message) -> iced::Command { + match message { + Message::Open => { + if let Some(path) = rfd::FileDialog::new().pick_file() { + self.open(path); + } + }, + Message::Save => { + if let Some(path) = &self.path_opt { + let buffer = self.buffer.lock().unwrap(); + let mut text = String::new(); + for line in buffer.lines.iter() { + text.push_str(line.text()); + text.push('\n'); + } + match fs::write(path, text) { + Ok(()) => { + log::info!("saved '{}'", path.display()); + }, + Err(err) => { + log::error!("failed to save '{}': {}", path.display(), err); + } + } + } + }, + Message::Bold(bold) => { + self.attrs = self.attrs.weight(if bold { + cosmic_text::Weight::BOLD + } else { + cosmic_text::Weight::NORMAL + }); + + let mut buffer = self.buffer.lock().unwrap(); + for line in buffer.lines.iter_mut() { + line.set_attrs_list(AttrsList::new(self.attrs)); + } + }, + Message::Italic(italic) => { + self.attrs = self.attrs.style(if italic { + cosmic_text::Style::Italic + } else { + cosmic_text::Style::Normal + }); + + let mut buffer = self.buffer.lock().unwrap(); + for line in buffer.lines.iter_mut() { + line.set_attrs_list(AttrsList::new(self.attrs)); + } + }, + Message::Monospaced(monospaced) => { + self.attrs = self.attrs + .family(if monospaced { + cosmic_text::Family::Monospace + } else { + cosmic_text::Family::SansSerif + }) + .monospaced(monospaced); + + let mut buffer = self.buffer.lock().unwrap(); + for line in buffer.lines.iter_mut() { + line.set_attrs_list(AttrsList::new(self.attrs)); + } + }, + Message::MetricsChanged(metrics) => { + let mut buffer = self.buffer.lock().unwrap(); + buffer.set_metrics(metrics); + }, + Message::ThemeChanged(theme) => match theme { + "Dark" => self.theme = Theme::Dark, + "Light" => self.theme = Theme::Light, + _ => (), + }, + } + + Command::none() + } + + fn view(&self) -> Element { + static THEMES: &'static [&'static str] = &["Dark", "Light"]; + let theme_picker = pick_list( + THEMES, + Some(match self.theme { + Theme::Dark => THEMES[0], + Theme::Light => THEMES[1], + }), + Message::ThemeChanged + ); + + let font_size_picker = { + let buffer = self.buffer.lock().unwrap(); + pick_list( + FONT_SIZES, + Some(buffer.metrics()), + Message::MetricsChanged + ) + }; + + column![ + row![ + button!("Open").on_press(Message::Open), + button!("Save").on_press(Message::Save), + horizontal_space(Length::Fill), + text("Bold:"), + toggler(None, self.attrs.weight == cosmic_text::Weight::BOLD, Message::Bold), + text("Italic:"), + toggler(None, self.attrs.style == cosmic_text::Style::Italic, Message::Italic), + text("Monospaced:"), + toggler(None, self.attrs.monospaced, Message::Monospaced), + text("Theme:"), + theme_picker, + text("Font Size:"), + font_size_picker, + ] + .align_items(Alignment::Center) + .spacing(8) + , + text_box(&self.buffer, &self.cache) + ] + .spacing(8) + .padding(16) + .into() + } +} diff --git a/examples/editor-libcosmic-image/src/text_box.rs b/examples/editor-libcosmic-image/src/text_box.rs new file mode 100644 index 0000000..fb6be90 --- /dev/null +++ b/examples/editor-libcosmic-image/src/text_box.rs @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use cosmic::iced_native::{ + {Color, Element, Length, Point, Rectangle, Shell, Theme}, + clipboard::Clipboard, + event::{ + Event, + Status, + }, + image, + keyboard::{Event as KeyEvent, KeyCode}, + layout::{self, Layout}, + mouse::{self, Button, Event as MouseEvent, ScrollDelta}, + renderer, + widget::{self, tree, Widget}, +}; +use cosmic_text::{ + SwashCache, + TextAction, + TextBuffer, +}; +use std::{ + sync::Mutex, + time::Instant, +}; + +pub struct Appearance { + background_color: Option, +} + +pub trait StyleSheet { + fn appearance(&self) -> Appearance; +} + +impl StyleSheet for Theme { + fn appearance(&self) -> Appearance { + match self { + Theme::Dark => Appearance { + background_color: Some(Color::from_rgb8(0x34, 0x34, 0x34)), + }, + Theme::Light => Appearance { + background_color: Some(Color::from_rgb8(0xFC, 0xFC, 0xFC)), + }, + } + } +} + +pub struct TextBox<'a> { + buffer: &'a Mutex>, + cache: &'a Mutex>, + pixels_opt: Option<(u32, u32, Vec)>, +} + +impl<'a> TextBox<'a> { + pub fn new(buffer: &'a Mutex>, cache: &'a Mutex>) -> Self { + Self { + buffer, + cache, + pixels_opt: None, + } + } +} + +pub fn text_box<'a>(buffer: &'a Mutex>, cache: &'a Mutex>) -> TextBox<'a> { + TextBox::new(buffer, cache) +} + +impl<'a, Message, Renderer> Widget for TextBox<'a> +where + Renderer: renderer::Renderer + image::Renderer, + Renderer::Theme: StyleSheet, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::new()) + } + + fn width(&self) -> Length { + Length::Fill + } + + fn height(&self) -> Length { + Length::Fill + } + + fn layout( + &self, + _renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::Node::new(limits.max()) + } + + fn mouse_interaction( + &self, + _tree: &widget::Tree, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + if layout.bounds().contains(cursor_position) { + mouse::Interaction::Text + } else { + mouse::Interaction::Idle + } + } + + fn draw( + &self, + _tree: &widget::Tree, + renderer: &mut Renderer, + theme: &Renderer::Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor_position: Point, + _viewport: &Rectangle, + ) { + if let Some(background_color) = theme.appearance().background_color { + renderer.fill_quad( + renderer::Quad { + bounds: layout.bounds(), + border_radius: 0.0, + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + background_color + ); + } + + if let Some((w, h, pixels)) = &self.pixels_opt { + let handle = image::Handle::from_pixels(*w, *h, pixels.clone()); + image::Renderer::draw(renderer, handle, layout.bounds()); + } + } + + fn on_event( + &mut self, + tree: &mut widget::Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + _shell: &mut Shell<'_, Message>, + ) -> Status { + let state = tree.state.downcast_mut::(); + let mut buffer = self.buffer.lock().unwrap(); + + let layout_w = layout.bounds().width as i32; + let layout_h = layout.bounds().height as i32; + buffer.set_size(layout_w, layout_h); + + let mut status = Status::Ignored; + match event { + Event::Keyboard(KeyEvent::KeyPressed { key_code, modifiers }) => match key_code { + KeyCode::Left => { + buffer.action(TextAction::Left); + status = Status::Captured; + }, + KeyCode::Right => { + buffer.action(TextAction::Right); + status = Status::Captured; + }, + KeyCode::Up => { + buffer.action(TextAction::Up); + status = Status::Captured; + }, + KeyCode::Down => { + buffer.action(TextAction::Down); + status = Status::Captured; + }, + KeyCode::Home => { + buffer.action(TextAction::Home); + status = Status::Captured; + }, + KeyCode::End => { + buffer.action(TextAction::End); + status = Status::Captured; + }, + KeyCode::PageUp => { + buffer.action(TextAction::PageUp); + status = Status::Captured; + }, + KeyCode::PageDown => { + buffer.action(TextAction::PageDown); + status = Status::Captured; + }, + KeyCode::Enter => { + buffer.action(TextAction::Enter); + status = Status::Captured; + }, + KeyCode::Backspace => { + buffer.action(TextAction::Backspace); + status = Status::Captured; + }, + KeyCode::Delete => { + buffer.action(TextAction::Delete); + status = Status::Captured; + }, + _ => () + }, + Event::Keyboard(KeyEvent::CharacterReceived(character)) => { + buffer.action(TextAction::Insert(character)); + status = Status::Captured; + }, + Event::Mouse(MouseEvent::ButtonPressed(Button::Left)) => { + if layout.bounds().contains(cursor_position) { + buffer.action(TextAction::Click { + x: (cursor_position.x - layout.bounds().x) as i32, + y: (cursor_position.y - layout.bounds().y) as i32, + }); + state.is_dragging = true; + status = Status::Captured; + } + }, + Event::Mouse(MouseEvent::ButtonReleased(Button::Left)) => { + state.is_dragging = false; + status = Status::Captured; + }, + Event::Mouse(MouseEvent::CursorMoved { .. }) => { + if state.is_dragging { + buffer.action(TextAction::Drag { + x: (cursor_position.x - layout.bounds().x) as i32, + y: (cursor_position.y - layout.bounds().y) as i32, + }); + status = Status::Captured; + } + }, + Event::Mouse(MouseEvent::WheelScrolled { delta }) => match delta { + ScrollDelta::Lines { x, y } => { + buffer.action(TextAction::Scroll { + lines: (-y * 6.0) as i32, + }); + status = Status::Captured; + }, + _ => (), + }, + _ => () + } + + if buffer.cursor_moved { + buffer.shape_until_cursor(); + buffer.cursor_moved = false; + } else { + buffer.shape_until_scroll(); + } + + if layout_w < 0 || layout_h < 0 { + // Invalid size, clear pixels + self.pixels_opt = None; + } else if buffer.redraw { + // Redraw buffer to image + + let instant = Instant::now(); + + let mut pixels = vec![0; layout_w as usize * layout_h as usize * 4]; + + //TODO: load from theme somehow + let text_color = cosmic_text::Color::rgb(0xFF, 0xFF, 0xFF); + buffer.draw(&mut self.cache.lock().unwrap(), text_color, |start_x, start_y, w, h, color| { + let alpha = (color.0 >> 24) & 0xFF; + if alpha == 0 { + // Do not draw if alpha is zero + return; + } + + for y in start_y..start_y + h as i32{ + if y < 0 || y >= layout_h { + // Skip if y out of bounds + continue; + } + + let offset_y = y as usize * layout_w as usize * 4; + for x in start_x..start_x + w as i32 { + if x < 0 || x >= layout_w { + // Skip if x out of bounds + continue; + } + + let offset = offset_y + x as usize * 4; + + let mut current = + pixels[offset + 2] as u32 | + (pixels[offset + 1] as u32) << 8 | + (pixels[offset] as u32) << 16 | + (pixels[offset + 3] as u32) << 24; + + if alpha >= 255 || current == 0 { + // Alpha is 100% or current is null, replace with no blending + current = color.0; + } else { + // Alpha blend with current value + let n_alpha = 255 - alpha; + let rb = ((n_alpha * (current & 0x00FF00FF)) + (alpha * (color.0 & 0x00FF00FF))) >> 8; + let ag = (n_alpha * ((current & 0xFF00FF00) >> 8)) + + (alpha * (0x01000000 | ((color.0 & 0x0000FF00) >> 8))); + current = (rb & 0x00FF00FF) | (ag & 0xFF00FF00); + } + + pixels[offset + 2] = current as u8; + pixels[offset + 1] = (current >> 8) as u8; + pixels[offset] = (current >> 16) as u8; + pixels[offset + 3] = (current >> 24) as u8; + } + } + }); + + self.pixels_opt = Some((layout_w as u32, layout_h as u32, pixels)); + + buffer.redraw = false; + + let duration = instant.elapsed(); + log::debug!("redraw: {:?}", duration); + } + + status + } +} + +impl<'a, Message, Renderer> From> for Element<'a, Message, Renderer> +where + Renderer: renderer::Renderer + image::Renderer, + Renderer::Theme: StyleSheet, +{ + fn from(text_box: TextBox<'a>) -> Self { + Self::new(text_box) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct State { + is_dragging: bool, +} + +impl State { + /// Creates a new [`State`]. + pub fn new() -> State { + State::default() + } +}