notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 35613f2e74961f1123a3883c665d27095467a81a
parent 83fe173ba3490df663ba8f729a0c23ccd30f00ca
Author: kernelkind <kernelkind@gmail.com>
Date:   Thu,  5 Dec 2024 17:42:21 -0500

ConfigureDeck & EditDeck user interfaces

`./preview ConfigureDeckView`
`./preview EditDeckView`

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Asrc/deck_state.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 2++
Asrc/ui/configure_deck.rs | 324+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/edit_deck.rs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ui/mod.rs | 2++
Msrc/ui_preview/main.rs | 4++++
6 files changed, 488 insertions(+), 0 deletions(-)

diff --git a/src/deck_state.rs b/src/deck_state.rs @@ -0,0 +1,65 @@ +use crate::{app_style::emoji_font_family, decks::Deck}; + +/// State for UI creating/editing deck +pub struct DeckState { + pub deck_name: String, + pub selected_glyph: Option<char>, + pub deleting: bool, + pub selecting_glyph: bool, + pub warn_no_title: bool, + pub warn_no_icon: bool, + glyph_options: Option<Vec<char>>, +} + +impl DeckState { + pub fn load(&mut self, deck: &Deck) { + self.deck_name = deck.name.clone(); + self.selected_glyph = Some(deck.icon); + } + + pub fn from_deck(deck: &Deck) -> Self { + let deck_name = deck.name.clone(); + let selected_glyph = Some(deck.icon); + Self { + deck_name, + selected_glyph, + ..Default::default() + } + } + + pub fn clear(&mut self) { + *self = Default::default(); + } + + pub fn get_glyph_options(&mut self, ui: &egui::Ui) -> &Vec<char> { + self.glyph_options + .get_or_insert_with(|| available_characters(ui, emoji_font_family())) + } +} + +impl Default for DeckState { + fn default() -> Self { + Self { + deck_name: Default::default(), + selected_glyph: Default::default(), + deleting: Default::default(), + selecting_glyph: true, + warn_no_icon: Default::default(), + warn_no_title: Default::default(), + glyph_options: Default::default(), + } + } +} + +fn available_characters(ui: &egui::Ui, family: egui::FontFamily) -> Vec<char> { + ui.fonts(|f| { + f.lock() + .fonts + .font(&egui::FontId::new(10.0, family)) // size is arbitrary for getting the characters + .characters() + .iter() + .filter(|chr| !chr.is_whitespace() && !chr.is_ascii_control()) + .copied() + .collect() + }) +} diff --git a/src/lib.rs b/src/lib.rs @@ -12,6 +12,8 @@ mod app_style; mod args; mod colors; mod column; +mod deck_state; +mod decks; mod draft; mod filter; mod fonts; diff --git a/src/ui/configure_deck.rs b/src/ui/configure_deck.rs @@ -0,0 +1,324 @@ +use egui::{vec2, Button, Color32, Label, RichText, Stroke, Ui, Widget}; + +use crate::{ + app_style::{deck_icon_font_sized, get_font_size, NotedeckTextStyle}, + colors::PINK, + deck_state::DeckState, + fonts::NamedFontFamily, +}; + +use super::{ + anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, + padding, +}; + +pub struct ConfigureDeckView<'a> { + state: &'a mut DeckState, + create_button_text: String, +} + +pub struct ConfigureDeckResponse { + pub icon: char, + pub name: String, +} + +static CREATE_TEXT: &str = "Create Deck"; + +impl<'a> ConfigureDeckView<'a> { + pub fn new(state: &'a mut DeckState) -> Self { + Self { + state, + create_button_text: CREATE_TEXT.to_owned(), + } + } + + pub fn with_create_text(mut self, text: &str) -> Self { + self.create_button_text = text.to_owned(); + self + } + + pub fn ui(&mut self, ui: &mut Ui) -> Option<ConfigureDeckResponse> { + let title_font = egui::FontId::new( + get_font_size(ui.ctx(), &NotedeckTextStyle::Heading4), + egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()), + ); + padding(16.0, ui, |ui| { + ui.add(Label::new( + RichText::new("Deck name").font(title_font.clone()), + )); + ui.add_space(8.0); + ui.text_edit_singleline(&mut self.state.deck_name); + ui.add_space(8.0); + ui.add(Label::new( + RichText::new("We recommend short names") + .color(ui.visuals().noninteractive().fg_stroke.color) + .size(get_font_size(ui.ctx(), &NotedeckTextStyle::Small)), + )); + + ui.add_space(32.0); + ui.add(Label::new(RichText::new("Icon").font(title_font))); + + if ui + .add(deck_icon( + ui.id().with("config-deck"), + self.state.selected_glyph, + 38.0, + 64.0, + false, + )) + .clicked() + { + self.state.selecting_glyph = !self.state.selecting_glyph; + } + + if self.state.selecting_glyph { + let max_height = if ui.available_height() - 100.0 > 0.0 { + ui.available_height() - 100.0 + } else { + ui.available_height() + }; + egui::Frame::window(ui.style()).show(ui, |ui| { + let glyphs = self.state.get_glyph_options(ui); + if let Some(selected_glyph) = glyph_options_ui(ui, 16.0, max_height, glyphs) { + self.state.selected_glyph = Some(selected_glyph); + self.state.selecting_glyph = false; + } + }); + ui.add_space(16.0); + } + + if self.state.warn_no_icon && self.state.selected_glyph.is_some() { + self.state.warn_no_icon = false; + } + if self.state.warn_no_title && !self.state.deck_name.is_empty() { + self.state.warn_no_title = false; + } + + show_warnings(ui, self.state.warn_no_icon, self.state.warn_no_title); + + let mut resp = None; + if ui + .add(create_deck_button(&self.create_button_text)) + .clicked() + { + if self.state.deck_name.is_empty() { + self.state.warn_no_title = true; + } + if self.state.selected_glyph.is_none() { + self.state.warn_no_icon = true; + } + if !self.state.deck_name.is_empty() { + if let Some(glyph) = self.state.selected_glyph { + resp = Some(ConfigureDeckResponse { + icon: glyph, + name: self.state.deck_name.clone(), + }); + } + } + } + resp + }) + .inner + } +} + +fn show_warnings(ui: &mut Ui, warn_no_icon: bool, warn_no_title: bool) { + if warn_no_icon || warn_no_title { + let messages = [ + if warn_no_title { + "create a name for the deck" + } else { + "" + }, + if warn_no_icon { "select an icon" } else { "" }, + ]; + let message = messages + .iter() + .filter(|&&m| !m.is_empty()) + .copied() + .collect::<Vec<_>>() + .join(" and "); + + ui.add( + egui::Label::new( + RichText::new(format!("Please {}.", message)).color(ui.visuals().error_fg_color), + ) + .wrap(), + ); + } +} + +fn create_deck_button(text: &str) -> impl Widget + use<'_> { + move |ui: &mut egui::Ui| { + let size = vec2(108.0, 40.0); + ui.allocate_ui_with_layout(size, egui::Layout::top_down(egui::Align::Center), |ui| { + ui.add(Button::new(text).fill(PINK).min_size(size)) + }) + .inner + } +} + +pub fn deck_icon( + id: egui::Id, + glyph: Option<char>, + font_size: f32, + full_size: f32, + highlight: bool, +) -> impl Widget { + move |ui: &mut egui::Ui| -> egui::Response { + let max_size = full_size * ICON_EXPANSION_MULTIPLE; + + let helper = AnimationHelper::new(ui, id, vec2(max_size, max_size)); + let painter = ui.painter_at(helper.get_animation_rect()); + let bg_center = helper.get_animation_rect().center(); + + let (stroke, fill_color) = if highlight { + ( + ui.visuals().selection.stroke, + ui.visuals().widgets.noninteractive.weak_bg_fill, + ) + } else { + ( + Stroke::new( + ui.visuals().widgets.inactive.bg_stroke.width, + ui.visuals().widgets.inactive.weak_bg_fill, + ), + ui.visuals().widgets.noninteractive.weak_bg_fill, + ) + }; + + let radius = helper.scale_1d_pos((full_size / 2.0) - stroke.width); + painter.circle(bg_center, radius, fill_color, stroke); + + if let Some(glyph) = glyph { + let font = + deck_icon_font_sized(helper.scale_1d_pos(font_size / std::f32::consts::SQRT_2)); + let glyph_galley = + painter.layout_no_wrap(glyph.to_string(), font, ui.visuals().text_color()); + + let top_left = { + let mut glyph_rect = glyph_galley.rect; + glyph_rect.set_center(bg_center); + glyph_rect.left_top() + }; + + painter.galley(top_left, glyph_galley, Color32::WHITE); + } + + helper.take_animation_response() + } +} + +fn glyph_icon_max_size(ui: &egui::Ui, glyph: &char, font_size: f32) -> egui::Vec2 { + let painter = ui.painter(); + let font = deck_icon_font_sized(font_size * ICON_EXPANSION_MULTIPLE); + let glyph_galley = painter.layout_no_wrap(glyph.to_string(), font, Color32::WHITE); + glyph_galley.rect.size() +} + +fn glyph_icon(glyph: char, font_size: f32, max_size: egui::Vec2) -> impl Widget { + move |ui: &mut egui::Ui| { + let helper = AnimationHelper::new(ui, ("glyph", glyph), max_size); + let painter = ui.painter_at(helper.get_animation_rect()); + + let font = deck_icon_font_sized(helper.scale_1d_pos(font_size)); + let glyph_galley = painter.layout_no_wrap(glyph.to_string(), font, Color32::WHITE); + + let top_left = { + let mut glyph_rect = glyph_galley.rect; + glyph_rect.set_center(helper.get_animation_rect().center()); + glyph_rect.left_top() + }; + + painter.galley(top_left, glyph_galley, Color32::WHITE); + helper.take_animation_response() + } +} + +fn glyph_options_ui( + ui: &mut egui::Ui, + font_size: f32, + max_height: f32, + glyphs: &[char], +) -> Option<char> { + let mut selected_glyph = None; + egui::ScrollArea::vertical() + .max_height(max_height) + .show(ui, |ui| { + let max_width = ui.available_width(); + let mut row_glyphs = Vec::new(); + let mut cur_width = 0.0; + let spacing = ui.spacing().item_spacing.x; + + for (index, glyph) in glyphs.iter().enumerate() { + let next_glyph_size = glyph_icon_max_size(ui, glyph, font_size); + + if cur_width + spacing + next_glyph_size.x > max_width { + if let Some(selected) = paint_row(ui, &row_glyphs, font_size) { + selected_glyph = Some(selected); + } + row_glyphs.clear(); + cur_width = 0.0; + } + + cur_width += spacing; + cur_width += next_glyph_size.x; + row_glyphs.push(*glyph); + + if index == glyphs.len() - 1 { + if let Some(selected) = paint_row(ui, &row_glyphs, font_size) { + selected_glyph = Some(selected); + } + } + } + }); + selected_glyph +} + +fn paint_row(ui: &mut egui::Ui, row_glyphs: &[char], font_size: f32) -> Option<char> { + let mut selected_glyph = None; + ui.horizontal(|ui| { + for glyph in row_glyphs { + let glyph_size = glyph_icon_max_size(ui, glyph, font_size); + if ui.add(glyph_icon(*glyph, font_size, glyph_size)).clicked() { + selected_glyph = Some(*glyph); + } + } + }); + selected_glyph +} + +mod preview { + use crate::{ + deck_state::DeckState, + ui::{Preview, PreviewConfig, View}, + }; + + use super::ConfigureDeckView; + + pub struct ConfigureDeckPreview { + state: DeckState, + } + + impl ConfigureDeckPreview { + fn new() -> Self { + let state = DeckState::default(); + + ConfigureDeckPreview { state } + } + } + + impl View for ConfigureDeckPreview { + fn ui(&mut self, ui: &mut egui::Ui) { + ConfigureDeckView::new(&mut self.state).ui(ui); + } + } + + impl Preview for ConfigureDeckView<'_> { + type Prev = ConfigureDeckPreview; + + fn preview(_cfg: PreviewConfig) -> Self::Prev { + ConfigureDeckPreview::new() + } + } +} diff --git a/src/ui/edit_deck.rs b/src/ui/edit_deck.rs @@ -0,0 +1,91 @@ +use egui::Widget; + +use crate::deck_state::DeckState; + +use super::{ + configure_deck::{ConfigureDeckResponse, ConfigureDeckView}, + padding, +}; + +pub struct EditDeckView<'a> { + config_view: ConfigureDeckView<'a>, +} + +static EDIT_TEXT: &str = "Edit Deck"; + +pub enum EditDeckResponse { + Edit(ConfigureDeckResponse), + Delete, +} + +impl<'a> EditDeckView<'a> { + pub fn new(state: &'a mut DeckState) -> Self { + let config_view = ConfigureDeckView::new(state).with_create_text(EDIT_TEXT); + Self { config_view } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<EditDeckResponse> { + let mut edit_deck_resp = None; + + padding(egui::Margin::symmetric(16.0, 4.0), ui, |ui| { + if ui.add(delete_button()).clicked() { + edit_deck_resp = Some(EditDeckResponse::Delete); + } + }); + + if let Some(config_resp) = self.config_view.ui(ui) { + edit_deck_resp = Some(EditDeckResponse::Edit(config_resp)) + } + + edit_deck_resp + } +} + +fn delete_button() -> impl Widget { + |ui: &mut egui::Ui| { + let size = egui::vec2(108.0, 40.0); + ui.allocate_ui_with_layout(size, egui::Layout::top_down(egui::Align::Center), |ui| { + ui.add( + egui::Button::new("Delete Deck") + .fill(ui.visuals().error_fg_color) + .min_size(size), + ) + }) + .inner + } +} + +mod preview { + use crate::{ + deck_state::DeckState, + ui::{Preview, PreviewConfig, View}, + }; + + use super::EditDeckView; + + pub struct EditDeckPreview { + state: DeckState, + } + + impl EditDeckPreview { + fn new() -> Self { + let state = DeckState::default(); + + EditDeckPreview { state } + } + } + + impl View for EditDeckPreview { + fn ui(&mut self, ui: &mut egui::Ui) { + EditDeckView::new(&mut self.state).ui(ui); + } + } + + impl Preview for EditDeckView<'_> { + type Prev = EditDeckPreview; + + fn preview(_cfg: PreviewConfig) -> Self::Prev { + EditDeckPreview::new() + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs @@ -3,6 +3,8 @@ pub mod accounts; pub mod add_column; pub mod anim; pub mod column; +pub mod configure_deck; +pub mod edit_deck; pub mod mention; pub mod note; pub mod preview; diff --git a/src/ui_preview/main.rs b/src/ui_preview/main.rs @@ -1,3 +1,5 @@ +use notedeck::ui::configure_deck::ConfigureDeckView; +use notedeck::ui::edit_deck::EditDeckView; use notedeck::ui::{ account_login_view::AccountLoginView, accounts::AccountsView, add_column::AddColumnView, DesktopSidePanel, PostView, Preview, PreviewApp, PreviewConfig, ProfilePic, ProfilePreview, @@ -106,5 +108,7 @@ async fn main() { DesktopSidePanel, PostView, AddColumnView, + ConfigureDeckView, + EditDeckView, ); }