diff --git a/Cargo.lock b/Cargo.lock index e2a00f7..31adce7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1423,7 +1423,6 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "iced" version = "0.6.0" -source = "git+https://github.com/pop-os/libcosmic?rev=843919e44f0a00c33c29358359be5b4bfa41ab00#843919e44f0a00c33c29358359be5b4bfa41ab00" dependencies = [ "iced_core", "iced_dyrend", @@ -1441,7 +1440,6 @@ dependencies = [ [[package]] name = "iced_core" version = "0.6.2" -source = "git+https://github.com/pop-os/libcosmic?rev=843919e44f0a00c33c29358359be5b4bfa41ab00#843919e44f0a00c33c29358359be5b4bfa41ab00" dependencies = [ "bitflags", "palette", @@ -1451,7 +1449,6 @@ dependencies = [ [[package]] name = "iced_dyrend" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic?rev=843919e44f0a00c33c29358359be5b4bfa41ab00#843919e44f0a00c33c29358359be5b4bfa41ab00" dependencies = [ "iced_glow", "iced_graphics", @@ -1465,7 +1462,6 @@ dependencies = [ [[package]] name = "iced_futures" version = "0.5.1" -source = "git+https://github.com/pop-os/libcosmic?rev=843919e44f0a00c33c29358359be5b4bfa41ab00#843919e44f0a00c33c29358359be5b4bfa41ab00" dependencies = [ "futures", "log", @@ -1476,7 +1472,6 @@ dependencies = [ [[package]] name = "iced_glow" version = "0.5.1" -source = "git+https://github.com/pop-os/libcosmic?rev=843919e44f0a00c33c29358359be5b4bfa41ab00#843919e44f0a00c33c29358359be5b4bfa41ab00" dependencies = [ "bytemuck", "euclid", @@ -1491,7 +1486,6 @@ dependencies = [ [[package]] name = "iced_graphics" version = "0.5.0" -source = "git+https://github.com/pop-os/libcosmic?rev=843919e44f0a00c33c29358359be5b4bfa41ab00#843919e44f0a00c33c29358359be5b4bfa41ab00" dependencies = [ "bitflags", "bytemuck", @@ -1511,7 +1505,6 @@ dependencies = [ [[package]] name = "iced_lazy" version = "0.3.0" -source = "git+https://github.com/pop-os/libcosmic?rev=843919e44f0a00c33c29358359be5b4bfa41ab00#843919e44f0a00c33c29358359be5b4bfa41ab00" dependencies = [ "iced_native", "ouroboros 0.13.0", @@ -1520,7 +1513,6 @@ dependencies = [ [[package]] name = "iced_native" version = "0.7.0" -source = "git+https://github.com/pop-os/libcosmic?rev=843919e44f0a00c33c29358359be5b4bfa41ab00#843919e44f0a00c33c29358359be5b4bfa41ab00" dependencies = [ "iced_core", "iced_futures", @@ -1533,7 +1525,6 @@ dependencies = [ [[package]] name = "iced_softbuffer" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic?rev=843919e44f0a00c33c29358359be5b4bfa41ab00#843919e44f0a00c33c29358359be5b4bfa41ab00" dependencies = [ "cosmic-text 0.6.0", "iced_graphics", @@ -1548,7 +1539,6 @@ dependencies = [ [[package]] name = "iced_style" version = "0.5.1" -source = "git+https://github.com/pop-os/libcosmic?rev=843919e44f0a00c33c29358359be5b4bfa41ab00#843919e44f0a00c33c29358359be5b4bfa41ab00" dependencies = [ "iced_core", "once_cell", @@ -1558,7 +1548,6 @@ dependencies = [ [[package]] name = "iced_wgpu" version = "0.7.0" -source = "git+https://github.com/pop-os/libcosmic?rev=843919e44f0a00c33c29358359be5b4bfa41ab00#843919e44f0a00c33c29358359be5b4bfa41ab00" dependencies = [ "bitflags", "bytemuck", @@ -1578,7 +1567,6 @@ dependencies = [ [[package]] name = "iced_winit" version = "0.6.0" -source = "git+https://github.com/pop-os/libcosmic?rev=843919e44f0a00c33c29358359be5b4bfa41ab00#843919e44f0a00c33c29358359be5b4bfa41ab00" dependencies = [ "iced_futures", "iced_graphics", @@ -1734,7 +1722,6 @@ checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" [[package]] name = "libcosmic" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic?rev=843919e44f0a00c33c29358359be5b4bfa41ab00#843919e44f0a00c33c29358359be5b4bfa41ab00" dependencies = [ "apply", "cosmic-theme", @@ -2972,9 +2959,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.92" +version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7434af0dc1cbd59268aa98b4c22c131c0584d2232f6fb166efb993e2832e896a" +checksum = "cad406b69c91885b5107daf2c29572f6c8cdb3c66826821e286c533490c0bc76" dependencies = [ "itoa", "ryu", diff --git a/Cargo.toml b/Cargo.toml index 1acbec5..1bf3893 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,10 +15,11 @@ version = "0.7" features = ["syntect"] [dependencies.libcosmic] -git = "https://github.com/pop-os/libcosmic" -rev = "843919e44f0a00c33c29358359be5b4bfa41ab00" +#git = "https://github.com/pop-os/libcosmic" +#rev = "843919e44f0a00c33c29358359be5b4bfa41ab00" default-features = false features = ["winit_softbuffer"] +path = "../libcosmic" [dependencies.rfd] version = "0.10" diff --git a/src/main.rs b/src/main.rs index 3cc5eba..c80988e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,12 +3,12 @@ use cosmic::{ iced::{ self, - widget::{column, horizontal_space, pick_list, row}, + widget::{column, container, horizontal_space, pick_list, row, text}, Alignment, Application, Color, Command, Length, }, settings, theme::{self, Theme}, - widget::{button, toggler}, + widget::{button, segmented_button, toggler, view_switcher}, Element, }; use cosmic_text::{ @@ -16,6 +16,9 @@ use cosmic_text::{ }; use std::{env, fs, path::PathBuf, sync::Mutex}; +use self::menu_list::MenuList; +mod menu_list; + use self::text_box::text_box; mod text_box; @@ -34,15 +37,14 @@ static FONT_SIZES: &'static [Metrics] = &[ ]; fn main() -> cosmic::iced::Result { - env_logger::init(); + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); let mut settings = settings(); settings.window.min_size = Some((400, 100)); Window::run(settings) } -pub struct Window { - theme: Theme, +pub struct Tab { path_opt: Option, attrs: Attrs<'static>, #[cfg(not(feature = "vi"))] @@ -51,14 +53,29 @@ pub struct Window { editor: Mutex>, } -#[allow(dead_code)] -#[derive(Clone, Copy, Debug)] -pub enum Message { - Open, - Save, -} +impl Tab { + pub fn new() -> Self { + let attrs = cosmic_text::Attrs::new() + .monospaced(true) + .family(cosmic_text::Family::Monospace); + + let editor = SyntaxEditor::new( + Buffer::new(&FONT_SYSTEM, FONT_SIZES[1 /* Body */]), + &SYNTAX_SYSTEM, + "base16-eighties.dark", + ) + .unwrap(); + + #[cfg(feature = "vi")] + let editor = cosmic_text::ViEditor::new(editor); + + Self { + path_opt: None, + attrs, + editor: Mutex::new(editor), + } + } -impl Window { pub fn open(&mut self, path: PathBuf) { let mut editor = self.editor.lock().unwrap(); match editor.load_text(&path, self.attrs) { @@ -72,6 +89,66 @@ impl Window { } } } + + pub fn save(&mut self) { + if let Some(path) = &self.path_opt { + let editor = self.editor.lock().unwrap(); + let mut text = String::new(); + for line in editor.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); + } + } + } else { + log::warn!("tab has no path yet"); + } + } + + pub fn title(&self) -> String { + //TODO: show full title when there is a conflict + if let Some(path) = &self.path_opt { + match path.file_name() { + Some(file_name_os) => match file_name_os.to_str() { + Some(file_name) => file_name.to_string(), + None => format!("{}", path.display()), + }, + None => format!("{}", path.display()), + } + } else { + "New document".to_string() + } + } +} + +pub struct Window { + theme: Theme, + tab_model: segmented_button::SingleSelectModel, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug)] +pub enum Message { + Open, + Save, + Tab(segmented_button::Entity), + Todo, +} + +impl Window { + pub fn active_tab(&self) -> Option<&Tab> { + self.tab_model.active_data() + } + + pub fn active_tab_mut(&mut self) -> Option<&mut Tab> { + self.tab_model.active_data_mut() + } } impl Application for Window { @@ -81,32 +158,27 @@ impl Application for Window { type Theme = Theme; fn new(_flags: ()) -> (Self, Command) { - let attrs = cosmic_text::Attrs::new() - .monospaced(true) - .family(cosmic_text::Family::Monospace); + let mut tab_model = segmented_button::Model::builder() + .build(); - let mut editor = SyntaxEditor::new( - Buffer::new(&FONT_SYSTEM, FONT_SIZES[1 /* Body */]), - &SYNTAX_SYSTEM, - "base16-eighties.dark", - ) - .unwrap(); - - #[cfg(feature = "vi")] - let mut editor = cosmic_text::ViEditor::new(editor); - - update_attrs(&mut editor, attrs); - - let mut window = Window { - theme: Theme::Dark, - path_opt: None, - attrs, - editor: Mutex::new(editor), - }; + let mut tab = Tab::new(); if let Some(arg) = env::args().nth(1) { - window.open(PathBuf::from(arg)); + tab.open(PathBuf::from(arg)); } - (window, Command::none()) + + tab_model.insert() + .text(tab.title()) + .icon("text-x-generic") + .data(tab) + .activate(); + + ( + Window { + theme: Theme::Dark, + tab_model, + }, + Command::none() + ) } fn theme(&self) -> Theme { @@ -114,14 +186,9 @@ impl Application for Window { } 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()) + match self.active_tab() { + Some(tab) => tab.title(), + None => format!("COSMIC Text Editor"), } } @@ -129,57 +196,84 @@ impl Application for Window { match message { Message::Open => { if let Some(path) = rfd::FileDialog::new().pick_file() { - self.open(path); + let mut tab = Tab::new(); + tab.open(path); + + self.tab_model.insert() + .text(tab.title()) + .icon("text-x-generic") + .data(tab) + .activate(); } - } + }, Message::Save => { - if let Some(path) = &self.path_opt { - let editor = self.editor.lock().unwrap(); - let mut text = String::new(); - for line in editor.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); - } - } + match self.active_tab_mut() { + Some(tab) => tab.save(), + None => { + log::info!("TODO: NO TAB OPEN"); + }, } - } + }, + Message::Tab(entity) => self.tab_model.activate(entity), + Message::Todo => { + log::info!("TODO"); + }, } Command::none() } fn view(&self) -> Element { - let content: Element<_> = column![ - row![ - button(theme::Button::Secondary) - .text("Open") - .on_press(Message::Open), - button(theme::Button::Secondary) - .text("Save") - .on_press(Message::Save), - ] - .align_items(Alignment::Center) - .spacing(8), - text_box(&self.editor) + let menu_bar = row![ + MenuList::new(vec!["Open", "Save"], None, |item| { + match item { + "Open" => Message::Open, + "Save" => Message::Save, + _ => Message::Todo + } + }) + .padding(8) + .placeholder("File") + , + MenuList::new(vec!["Todo"], None, |_| Message::Todo) + .padding(8) + .placeholder("Edit") + , + MenuList::new(vec!["Todo"], None, |_| Message::Todo) + .padding(8) + .placeholder("View") + , + MenuList::new(vec!["Todo"], None, |_| Message::Todo) + .padding(8) + .placeholder("Help") + , ] - .spacing(8) - .padding(16) - .into(); + .align_items(Alignment::Start) + .padding(4) + .spacing(16); - // Uncomment to debug layout: content.explain(Color::WHITE) + let tab_bar = view_switcher::horizontal(&self.tab_model) + .on_activate(Message::Tab) + .width(Length::Shrink); + + let content: Element<_> = column![ + menu_bar, + column![ + tab_bar, + match self.active_tab() { + Some(tab) => { + text_box(&tab.editor) + .padding(8) + }, + None => { + panic!("TODO: No tab open"); + }, + } + ].padding([0, 16]) + ].into(); + + // Uncomment to debug layout: + //content.explain(Color::WHITE) content } } - -fn update_attrs<'a, T: Edit<'a>>(editor: &mut T, attrs: Attrs<'a>) { - editor.buffer_mut().lines.iter_mut().for_each(|line| { - line.set_attrs_list(AttrsList::new(attrs)); - }); -} diff --git a/src/menu_list.rs b/src/menu_list.rs new file mode 100644 index 0000000..b071552 --- /dev/null +++ b/src/menu_list.rs @@ -0,0 +1,637 @@ +//! Display a dropdown list of selectable values. +use cosmic::iced::{Background, Color}; +use cosmic::iced_native::alignment; +use cosmic::iced_native::event::{self, Event}; +use cosmic::iced_native::keyboard; +use cosmic::iced_native::layout; +use cosmic::iced_native::mouse; +use cosmic::iced_native::overlay; +use cosmic::iced_native::overlay::menu::{self, Menu}; +use cosmic::iced_native::renderer; +use cosmic::iced_native::text::{self, Text}; +use cosmic::iced_native::touch; +use cosmic::iced_native::widget::container; +use cosmic::iced_native::widget::scrollable; +use cosmic::iced_native::widget::tree::{self, Tree}; +use cosmic::iced_native::{ + Clipboard, Element, Layout, Length, Padding, Point, Rectangle, Shell, Size, + Widget, +}; +use std::borrow::Cow; + +/// The appearance of a pick list. +#[derive(Debug, Clone, Copy)] +pub struct Appearance { + /// The text [`Color`] of the pick list. + pub text_color: Color, + /// The placeholder [`Color`] of the pick list. + pub placeholder_color: Color, + /// The [`Background`] of the pick list. + pub background: Background, + /// The border radius of the pick list. + pub border_radius: f32, + /// The border width of the pick list. + pub border_width: f32, + /// The border color of the pick list. + pub border_color: Color, +} + +/// A set of rules that dictate the style of a container. +pub trait StyleSheet { + /// The supported style of the [`StyleSheet`]. + type Style: Default + Clone; + + /// Produces the active [`Appearance`] of a pick list. + fn active(&self, style: &::Style) -> Appearance; + + /// Produces the hovered [`Appearance`] of a pick list. + fn hovered(&self, style: &::Style) -> Appearance; +} + +impl StyleSheet for cosmic::theme::Theme { + type Style = (); + + fn active(&self, _style: &()) -> Appearance { + let cosmic = &self.cosmic().primary.component; + + Appearance { + text_color: cosmic.on.into(), + background: Color::TRANSPARENT.into(), + placeholder_color: cosmic.on.into(), + border_radius: 8.0, + border_width: 0.0, + border_color: Color::TRANSPARENT, + } + } + + fn hovered(&self, style: &()) -> Appearance { + let cosmic = &self.cosmic().primary.component; + + Appearance { + background: Background::Color(cosmic.hover.into()), + ..self.active(style) + } + } +} + +/// A widget for selecting a single value from a list of options. +#[allow(missing_debug_implementations)] +pub struct MenuList<'a, T, Message, Renderer> +where + [T]: ToOwned>, + Renderer: text::Renderer, + Renderer::Theme: StyleSheet, +{ + on_selected: Box Message + 'a>, + options: Cow<'a, [T]>, + placeholder: Option, + selected: Option, + width: Length, + padding: Padding, + text_size: Option, + font: Renderer::Font, + style: ::Style, +} + +impl<'a, T: 'a, Message, Renderer> MenuList<'a, T, Message, Renderer> +where + T: ToString + Eq, + [T]: ToOwned>, + Renderer: text::Renderer, + Renderer::Theme: StyleSheet + + scrollable::StyleSheet + + menu::StyleSheet + + container::StyleSheet, + ::Style: + From<::Style>, +{ + /// The default padding of a [`MenuList`]. + pub const DEFAULT_PADDING: Padding = Padding::new(5); + + /// Creates a new [`MenuList`] with the given list of options, the current + /// selected value, and the message to produce when an option is selected. + pub fn new( + options: impl Into>, + selected: Option, + on_selected: impl Fn(T) -> Message + 'a, + ) -> Self { + Self { + on_selected: Box::new(on_selected), + options: options.into(), + placeholder: None, + selected, + width: Length::Shrink, + text_size: None, + padding: Self::DEFAULT_PADDING, + font: Default::default(), + style: Default::default(), + } + } + + /// Sets the placeholder of the [`MenuList`]. + pub fn placeholder(mut self, placeholder: impl Into) -> Self { + self.placeholder = Some(placeholder.into()); + self + } + + /// Sets the width of the [`MenuList`]. + pub fn width(mut self, width: Length) -> Self { + self.width = width; + self + } + + /// Sets the [`Padding`] of the [`MenuList`]. + pub fn padding>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the text size of the [`MenuList`]. + pub fn text_size(mut self, size: u16) -> Self { + self.text_size = Some(size); + self + } + + /// Sets the font of the [`MenuList`]. + pub fn font(mut self, font: Renderer::Font) -> Self { + self.font = font; + self + } + + /// Sets the style of the [`MenuList`]. + pub fn style( + mut self, + style: impl Into<::Style>, + ) -> Self { + self.style = style.into(); + self + } +} + +impl<'a, T: 'a, Message, Renderer> Widget + for MenuList<'a, T, Message, Renderer> +where + T: Clone + ToString + Eq + 'static, + [T]: ToOwned>, + Message: 'a, + Renderer: text::Renderer + 'a, + Renderer::Theme: StyleSheet + + scrollable::StyleSheet + + menu::StyleSheet + + container::StyleSheet, + ::Style: + From<::Style>, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::>() + } + + fn state(&self) -> tree::State { + tree::State::new(State::::new()) + } + + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + Length::Shrink + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout( + renderer, + limits, + self.width, + self.padding, + self.text_size, + &self.font, + self.placeholder.as_deref(), + &self.options, + ) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + update( + event, + layout, + cursor_position, + shell, + self.on_selected.as_ref(), + self.selected.as_ref(), + &self.options, + || tree.state.downcast_mut::>(), + ) + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + mouse_interaction(layout, cursor_position) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + theme: &Renderer::Theme, + _style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + ) { + draw( + renderer, + theme, + layout, + cursor_position, + self.padding, + self.text_size, + &self.font, + self.placeholder.as_deref(), + self.selected.as_ref(), + &self.style, + ) + } + + fn overlay<'b>( + &'b self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + ) -> Option> { + let state = tree.state.downcast_mut::>(); + + overlay( + renderer, + layout, + state, + self.padding, + self.text_size, + self.font.clone(), + &self.options, + self.style.clone(), + ) + } +} + +impl<'a, T: 'a, Message, Renderer> From> + for Element<'a, Message, Renderer> +where + T: Clone + ToString + Eq + 'static, + [T]: ToOwned>, + Message: 'a, + Renderer: text::Renderer + 'a, + Renderer::Theme: StyleSheet + + scrollable::StyleSheet + + menu::StyleSheet + + container::StyleSheet, + ::Style: + From<::Style>, +{ + fn from(menu_list: MenuList<'a, T, Message, Renderer>) -> Self { + Self::new(menu_list) + } +} + +/// The local state of a [`MenuList`]. +#[derive(Debug)] +pub struct State { + menu: menu::State, + keyboard_modifiers: keyboard::Modifiers, + is_open: bool, + hovered_option: Option, + last_selection: Option, +} + +impl State { + /// Creates a new [`State`] for a [`MenuList`]. + pub fn new() -> Self { + Self { + menu: menu::State::default(), + keyboard_modifiers: keyboard::Modifiers::default(), + is_open: bool::default(), + hovered_option: Option::default(), + last_selection: Option::default(), + } + } +} + +impl Default for State { + fn default() -> Self { + Self::new() + } +} + +/// Computes the layout of a [`MenuList`]. +pub fn layout( + renderer: &Renderer, + limits: &layout::Limits, + width: Length, + padding: Padding, + text_size: Option, + font: &Renderer::Font, + placeholder: Option<&str>, + options: &[T], +) -> layout::Node +where + Renderer: text::Renderer, + T: ToString, +{ + use std::f32; + + let limits = limits.width(width).height(Length::Shrink).pad(padding); + + let text_size = text_size.unwrap_or_else(|| renderer.default_size()); + + let max_width = match width { + Length::Shrink => { + let measure = |label: &str| -> u32 { + let (width, _) = renderer.measure( + label, + text_size, + font.clone(), + Size::new(f32::INFINITY, f32::INFINITY), + ); + + width.round() as u32 + }; + + placeholder.map(measure).unwrap_or(100) + } + _ => 0, + }; + + let size = { + let intrinsic = Size::new( + max_width as f32, + f32::from(text_size), + ); + + limits.resolve(intrinsic).pad(padding) + }; + + layout::Node::new(size) +} + +/// Processes an [`Event`] and updates the [`State`] of a [`MenuList`] +/// accordingly. +pub fn update<'a, T, Message>( + event: Event, + layout: Layout<'_>, + cursor_position: Point, + shell: &mut Shell<'_, Message>, + on_selected: &dyn Fn(T) -> Message, + selected: Option<&T>, + options: &[T], + state: impl FnOnce() -> &'a mut State, +) -> event::Status +where + T: PartialEq + Clone + 'a, +{ + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + let state = state(); + + let event_status = if state.is_open { + // Event wasn't processed by overlay, so cursor was clicked either outside it's + // bounds or on the drop-down, either way we close the overlay. + state.is_open = false; + + event::Status::Captured + } else if layout.bounds().contains(cursor_position) { + state.is_open = true; + state.hovered_option = + options.iter().position(|option| Some(option) == selected); + + event::Status::Captured + } else { + event::Status::Ignored + }; + + if let Some(last_selection) = state.last_selection.take() { + shell.publish((on_selected)(last_selection)); + + state.is_open = false; + + event::Status::Captured + } else { + event_status + } + } + Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { y, .. }, + }) => { + let state = state(); + + if state.keyboard_modifiers.command() + && layout.bounds().contains(cursor_position) + && !state.is_open + { + fn find_next<'a, T: PartialEq>( + selected: &'a T, + mut options: impl Iterator, + ) -> Option<&'a T> { + let _ = options.find(|&option| option == selected); + + options.next() + } + + let next_option = if y < 0.0 { + if let Some(selected) = selected { + find_next(selected, options.iter()) + } else { + options.first() + } + } else if y > 0.0 { + if let Some(selected) = selected { + find_next(selected, options.iter().rev()) + } else { + options.last() + } + } else { + None + }; + + if let Some(next_option) = next_option { + shell.publish((on_selected)(next_option.clone())); + } + + event::Status::Captured + } else { + event::Status::Ignored + } + } + Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { + let state = state(); + + state.keyboard_modifiers = modifiers; + + event::Status::Ignored + } + _ => event::Status::Ignored, + } +} + +/// Returns the current [`mouse::Interaction`] of a [`MenuList`]. +pub fn mouse_interaction( + layout: Layout<'_>, + cursor_position: Point, +) -> mouse::Interaction { + let bounds = layout.bounds(); + let is_mouse_over = bounds.contains(cursor_position); + + if is_mouse_over { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } +} + +/// Returns the current overlay of a [`MenuList`]. +pub fn overlay<'a, T, Message, Renderer>( + renderer: &Renderer, + layout: Layout<'_>, + state: &'a mut State, + padding: Padding, + text_size: Option, + font: Renderer::Font, + options: &'a [T], + style: ::Style, +) -> Option> +where + T: Clone + ToString, + Message: 'a, + Renderer: text::Renderer + 'a, + Renderer::Theme: StyleSheet + + scrollable::StyleSheet + + menu::StyleSheet + + container::StyleSheet, + ::Style: + From<::Style>, +{ + if state.is_open { + let bounds = layout.bounds(); + + let text_size = text_size.unwrap_or_else(|| renderer.default_size()); + + let width = { + let measure = |label: &str| -> u32 { + let (width, _) = renderer.measure( + label, + text_size, + font.clone(), + Size::new(f32::INFINITY, f32::INFINITY), + ); + + width.round() as u32 + }; + + let labels = options.iter().map(ToString::to_string); + + let labels_width = + labels.map(|label| measure(&label)).max().unwrap_or(100); + + labels_width as u16 + padding.left + padding.right + }; + + let menu = Menu::new( + &mut state.menu, + options, + &mut state.hovered_option, + &mut state.last_selection, + ) + .width(width) + .padding(padding) + .font(font) + .style(style) + .text_size(text_size); + + Some(menu.overlay(layout.position(), bounds.height)) + } else { + None + } +} + +/// Draws a [`MenuList`]. +pub fn draw( + renderer: &mut Renderer, + theme: &Renderer::Theme, + layout: Layout<'_>, + cursor_position: Point, + padding: Padding, + text_size: Option, + font: &Renderer::Font, + placeholder: Option<&str>, + selected: Option<&T>, + style: &::Style, +) where + Renderer: text::Renderer, + Renderer::Theme: StyleSheet, + T: ToString, +{ + let bounds = layout.bounds(); + let is_mouse_over = bounds.contains(cursor_position); + let is_selected = selected.is_some(); + + let style = if is_mouse_over { + theme.hovered(style) + } else { + theme.active(style) + }; + + renderer.fill_quad( + renderer::Quad { + bounds, + border_color: style.border_color, + border_width: style.border_width, + border_radius: style.border_radius.into(), + }, + style.background, + ); + + let label = selected.map(ToString::to_string); + + if let Some(label) = label.as_deref().or(placeholder) { + let text_size = + f32::from(text_size.unwrap_or_else(|| renderer.default_size())); + + renderer.fill_text(Text { + content: label, + size: text_size, + font: font.clone(), + color: if is_selected { + style.text_color + } else { + style.placeholder_color + }, + bounds: Rectangle { + x: bounds.x + f32::from(padding.left), + y: bounds.center_y() - text_size / 2.0, + width: bounds.width - f32::from(padding.horizontal()), + height: text_size, + }, + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Top, + }); + } +}