diff options
| author | pml68 <contact@pml68.dev> | 2025-03-30 16:24:46 +0200 |
|---|---|---|
| committer | pml68 <contact@pml68.dev> | 2025-03-30 16:24:46 +0200 |
| commit | b86e2076d759c3726b4406abbd61290fd41581ce (patch) | |
| tree | c1d184ffc89151017a18f72104d891489196c1ef | |
| download | iced_dialog-b86e2076d759c3726b4406abbd61290fd41581ce.tar.gz | |
feat: initial commit
| -rw-r--r-- | .cargo/config.toml | 3 | ||||
| -rw-r--r-- | .gitattributes | 6 | ||||
| -rw-r--r-- | .github/workflows/ci.yml | 21 | ||||
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | Cargo.toml | 56 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | example/Cargo.toml | 8 | ||||
| -rw-r--r-- | example/src/main.rs | 62 | ||||
| -rw-r--r-- | rustfmt.toml | 3 | ||||
| -rw-r--r-- | src/dialog.rs | 282 | ||||
| -rw-r--r-- | src/lib.rs | 46 |
12 files changed, 521 insertions, 0 deletions
diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..feb6aad --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[alias] +lint = "clippy --no-deps -- -D warnings" +lint-all = "clippy --no-deps -- -D clippy::pedantic" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2742e4d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* text=auto eol=lf + +# Older git versions try to fix line endings on images, this prevents it. +*.png binary +*.jpg binary +*.ico binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..daff1be --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI +on: [push, pull_request] +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: hecrj/setup-rust-action@v2 + with: + components: clippy + - uses: actions/checkout@master + - name: Install dependencies + run: | + export DEBIAN_FRONTED=noninteractive + sudo apt-get -qq update + sudo apt-get install -y libxkbcommon-dev + - name: Check lints + run: cargo lint + - name: Run tests + run: cargo test --verbose + - name: Build example + run: cargo build -p example diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..16c55e1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "iced_dialog" +description = "A custom dialog widget for `iced`" +authors = ["pml68 <contact@pml68.dev>"] +version = "0.14.0-dev" +edition = "2024" +license = "MIT" +readme = "README.md" +repository = "https://github.com/pml68/iced_dialog" +categories = ["gui"] +keywords = ["gui", "ui", "graphics", "interface", "widgets"] +rust-version = "1.85" + +[dependencies] +iced_widget.workspace = true +iced_core.workspace = true + +[workspace.dependencies.iced_widget] +git = "https://github.com/iced-rs/iced" +branch = "master" +features = ["advanced"] + +[workspace.dependencies.iced_core] +git = "https://github.com/iced-rs/iced" +branch = "master" +features = ["advanced"] + +[workspace] +members = ["example"] + +[package.metadata.docs.rs] +rustdoc-args = ["--cfg", "docsrs"] +all-features = true + +[lints.rust] +missing_docs = "deny" +unsafe_code = "deny" +unused_results = "deny" + +[lints.clippy] +type-complexity = "allow" +semicolon_if_nothing_returned = "deny" +trivially-copy-pass-by-ref = "deny" +default_trait_access = "deny" +match-wildcard-for-single-variants = "deny" +redundant-closure-for-method-calls = "deny" +filter_map_next = "deny" +manual_let_else = "deny" +unused_async = "deny" +from_over_into = "deny" +needless_borrow = "deny" +new_without_default = "deny" +useless_conversion = "deny" + +[lints.rustdoc] +broken_intra_doc_links = "forbid" @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Polesznyák Márk + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..18d0e0a --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# iced_dialog + +Custom dialog for [`iced`](https://iced.rs) + +## Example +See the [/example](/example) directory. + +You can run it like this: +```bash +cargo run -p example +``` diff --git a/example/Cargo.toml b/example/Cargo.toml new file mode 100644 index 0000000..7bc22dc --- /dev/null +++ b/example/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "example" +version = "0.0.1" +edition = "2024" + +[dependencies] +iced = { git = "https://github.com/iced-rs/iced", branch = "master" } +iced_dialog = { path = ".." } diff --git a/example/src/main.rs b/example/src/main.rs new file mode 100644 index 0000000..0234319 --- /dev/null +++ b/example/src/main.rs @@ -0,0 +1,62 @@ +use iced::{ + Element, Length, Task, + widget::{center, column, text}, +}; +use iced_dialog::{button, dialog}; + +#[derive(Default)] +struct State { + is_open: bool, + action_text: String, +} + +#[derive(Debug, Clone)] +enum Message { + OpenDialog, + Saved, + Cancelled, +} + +fn main() -> iced::Result { + iced::run("Dialog Example", State::update, State::view) +} + +impl State { + fn update(&mut self, message: Message) -> Task<Message> { + match message { + Message::OpenDialog => self.is_open = true, + Message::Saved => { + self.action_text = "User saved their work".to_owned(); + self.is_open = false; + } + Message::Cancelled => { + self.action_text = "User cancelled the dialog".to_owned(); + self.is_open = false; + } + } + Task::none() + } + + fn view(&self) -> Element<'_, Message> { + let base = center( + column![ + text(&self.action_text), + iced::widget::button("Open Dialog") + .on_press(Message::OpenDialog) + ] + .spacing(14.0), + ) + .width(Length::Fill) + .height(Length::Fill); + + let dialog_content = text("Do you want to save?"); + + dialog(self.is_open, base, dialog_content) + .title("Save") + .push_button(button("Save").on_press(Message::Saved)) + .push_button(button("Cancel").on_press(Message::Cancelled)) + .width(350) + .height(234) + .into() + } +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..e029395 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +edition = "2024" +group_imports = "StdExternalCrate" +max_width = 80 diff --git a/src/dialog.rs b/src/dialog.rs new file mode 100644 index 0000000..d1e7b9d --- /dev/null +++ b/src/dialog.rs @@ -0,0 +1,282 @@ +//! Dialogs can be used to provide users with +//! important information and make them act on it. +use iced_core::{ + self as core, Alignment, Element, Length, Padding, Pixels, + alignment::Vertical, color, +}; +use iced_widget::{ + Column, Container, Row, Theme, center, container, mouse_area, opaque, + stack, text, vertical_space, +}; + +/// A message dialog. +/// +/// Only the content is required, [`buttons`] and the [`title`] are optional. +/// +/// [`buttons`]: Dialog::with_buttons +/// [`title`]: Dialog::title +pub struct Dialog<'a, Message, Theme, Renderer> +where + Renderer: 'a + core::text::Renderer, + Theme: 'a + Catalog, +{ + is_open: bool, + base: Element<'a, Message, Theme, Renderer>, + title: Option<String>, + content: Element<'a, Message, Theme, Renderer>, + buttons: Vec<Element<'a, Message, Theme, Renderer>>, + font: Option<Renderer::Font>, + width: Length, + height: Length, + spacing: f32, + padding: Padding, + button_alignment: Alignment, + title_class: <Theme as text::Catalog>::Class<'a>, + container_class: <Theme as container::Catalog>::Class<'a>, +} + +impl<'a, Message, Theme, Renderer> Dialog<'a, Message, Theme, Renderer> +where + Renderer: 'a + core::Renderer + core::text::Renderer, + Theme: 'a + Catalog, + Message: 'a + Clone, + <Theme as container::Catalog>::Class<'a>: + From<container::StyleFn<'a, Theme>>, +{ + /// Creates a new [`Dialog`] with the given base and dialog content. + pub fn new( + is_open: bool, + base: impl Into<Element<'a, Message, Theme, Renderer>>, + content: impl Into<Element<'a, Message, Theme, Renderer>>, + ) -> Self { + Self::with_buttons(is_open, base, content, Vec::new()) + } + + /// Creates a new [`Dialog`] with the given base, dialog content and buttons. + pub fn with_buttons( + is_open: bool, + base: impl Into<Element<'a, Message, Theme, Renderer>>, + content: impl Into<Element<'a, Message, Theme, Renderer>>, + buttons: Vec<Element<'a, Message, Theme, Renderer>>, + ) -> Self { + Self { + is_open, + base: base.into(), + title: None, + content: content.into(), + buttons, + font: None, + width: 400.into(), + height: 260.into(), + spacing: 8.0, + padding: 24.into(), + button_alignment: Alignment::Start, + title_class: <Theme as Catalog>::default_title(), + container_class: <Theme as Catalog>::default_container(), + } + } + + /// Sets the [`Dialog`]'s title. + pub fn title(mut self, title: impl Into<String>) -> Self { + self.title = Some(title.into()); + self + } + + /// Sets the [`Dialog`]'s width. + pub fn width(mut self, width: impl Into<Length>) -> Self { + self.width = width.into(); + self + } + + /// Sets the [`Dialog`]'s height. + pub fn height(mut self, height: impl Into<Length>) -> Self { + self.height = height.into(); + self + } + + /// Sets the [`Dialog`]'s padding. + pub fn padding(mut self, padding: impl Into<Padding>) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the [`Dialog`]'s spacing. + pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self { + self.spacing = spacing.into().0; + self + } + + /// Sets the vertical alignment of the [`Dialog`]'s buttons. + pub fn align_buttons(mut self, align: impl Into<Vertical>) -> Self { + self.button_alignment = Alignment::from(align.into()); + self + } + + /// Sets the [`Font`] of the [`Dialog`]'s title. + /// + /// [`Font`]: https://docs.iced.rs/iced_core/text/trait.Renderer.html#associatedtype.Font + pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self { + self.font = Some(font.into()); + self + } + + /// Adds a button to the [`Dialog`]. + pub fn push_button( + mut self, + button: impl Into<Element<'a, Message, Theme, Renderer>>, + ) -> Self { + self.buttons.push(button.into()); + self + } + + /// Adds a button to the [`Dialog`], if `Some`. + pub fn push_button_maybe( + self, + button: Option<impl Into<Element<'a, Message, Theme, Renderer>>>, + ) -> Self { + if let Some(button) = button { + self.push_button(button) + } else { + self + } + } + + /// Extends the [`Dialog`] with the given buttons. + pub fn extend( + self, + buttons: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>, + ) -> Self { + buttons.into_iter().fold(self, Self::push_button) + } + + /// Sets the style of the [`Dialog`]'s title. + #[must_use] + pub fn title_style( + mut self, + style: impl Fn(&Theme) -> text::Style + 'a, + ) -> Self + where + <Theme as text::Catalog>::Class<'a>: From<text::StyleFn<'a, Theme>>, + { + self.title_class = (Box::new(style) as text::StyleFn<'a, Theme>).into(); + self + } + + /// Sets the style of the [`Dialog`]'s container. + #[must_use] + pub fn container_style( + mut self, + style: impl Fn(&Theme) -> container::Style + 'a, + ) -> Self + where + <Theme as container::Catalog>::Class<'a>: + From<container::StyleFn<'a, Theme>>, + { + self.container_class = + (Box::new(style) as container::StyleFn<'a, Theme>).into(); + self + } + + fn view(self) -> Element<'a, Message, Theme, Renderer> { + if self.is_open { + let contents = Container::new( + Column::new() + .push_maybe(self.title.map(|title| { + let text = text(title) + .size(20) + .line_height(text::LineHeight::Absolute(Pixels( + 26.0, + ))) + .class(self.title_class); + + if let Some(font) = self.font { + text.font(font) + } else { + text + } + })) + .push(vertical_space().height(12)) + .push(self.content), + ) + .width(Length::Fill) + .padding(self.padding); + + let buttons = Container::new( + Row::with_children(self.buttons).spacing(self.spacing), + ) + .height(80) + .padding(self.padding); + + let dialog = Container::new( + Column::new() + .push(contents) + .push(vertical_space()) + .push(buttons), + ) + .width(self.width) + .height(self.height) + .class(self.container_class) + .clip(true); + + modal(self.base, dialog) + } else { + self.base + } + } +} + +impl<'a, Message, Theme, Renderer> From<Dialog<'a, Message, Theme, Renderer>> + for Element<'a, Message, Theme, Renderer> +where + Renderer: 'a + core::Renderer + core::text::Renderer, + Theme: 'a + Catalog, + Message: 'a + Clone, + <Theme as container::Catalog>::Class<'a>: + From<container::StyleFn<'a, Theme>>, +{ + fn from(dialog: Dialog<'a, Message, Theme, Renderer>) -> Self { + dialog.view() + } +} + +fn modal<'a, Message, Theme, Renderer>( + base: impl Into<Element<'a, Message, Theme, Renderer>>, + content: impl Into<Element<'a, Message, Theme, Renderer>>, +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a + Clone, + Renderer: 'a + core::Renderer, + Theme: 'a + container::Catalog, + Theme::Class<'a>: From<container::StyleFn<'a, Theme>>, +{ + let area = + mouse_area(center(opaque(content)).style(|_theme| container::Style { + background: Some(color!(0x000000, 0.3).into()), + ..Default::default() + })); + + stack![base.into(), opaque(area)].into() +} + +/// The theme catalog of a [`Dialog`]. +pub trait Catalog: text::Catalog + container::Catalog { + /// The default class for the [`Dialog`]'s title. + fn default_title<'a>() -> <Self as text::Catalog>::Class<'a> { + <Self as text::Catalog>::default() + } + + /// The default class for the [`Dialog`]'s container. + fn default_container<'a>() -> <Self as container::Catalog>::Class<'a> { + <Self as container::Catalog>::default() + } +} + +impl Catalog for Theme { + fn default_container<'a>() -> <Self as container::Catalog>::Class<'a> { + Box::new(|theme| { + container::background( + theme.extended_palette().background.base.color, + ) + }) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..856a45c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,46 @@ +//! Custom dialog for `iced` +//! +//! # Example +//! See [here](https://github.com/pml68/iced_dialog/tree/master/example) +pub mod dialog; +use dialog::Dialog; +use iced_core as core; +use iced_core::alignment::Horizontal; +use iced_widget::Button; +use iced_widget::{container, text}; + +/// Creates a new [`Dialog`] with the given base and dialog content. +pub fn dialog<'a, Message, Theme, Renderer>( + is_open: bool, + base: impl Into<core::Element<'a, Message, Theme, Renderer>>, + content: impl Into<core::Element<'a, Message, Theme, Renderer>>, +) -> Dialog<'a, Message, Theme, Renderer> +where + Renderer: 'a + core::Renderer + core::text::Renderer, + Theme: 'a + dialog::Catalog, + Message: 'a + Clone, + <Theme as container::Catalog>::Class<'a>: + From<container::StyleFn<'a, Theme>>, +{ + Dialog::new(is_open, base, content) +} + +/// Pre-styled [`Button`] for [`Dialog`]s. +/// +/// [`Button`]: https://docs.iced.rs/iced/widget/struct.Button.html +pub fn button<'a, Message, Theme, Renderer>( + content: &'a str, +) -> Button<'a, Message, Theme, Renderer> +where + Theme: 'a + iced_widget::button::Catalog + text::Catalog, + Renderer: 'a + core::Renderer + core::text::Renderer, +{ + iced_widget::button( + text(content) + .size(14) + .line_height(text::LineHeight::Absolute(core::Pixels(20.0))) + .align_x(Horizontal::Center), + ) + .height(32) + .width(core::Length::Fill) +} |
