ConfigureDeck & EditDeck user interfaces

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

Signed-off-by: kernelkind <kernelkind@gmail.com>
This commit is contained in:
kernelkind
2024-12-05 17:42:21 -05:00
parent 83fe173ba3
commit 35613f2e74
6 changed files with 488 additions and 0 deletions

65
src/deck_state.rs Normal file
View File

@@ -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()
})
}

View File

@@ -12,6 +12,8 @@ mod app_style;
mod args;
mod colors;
mod column;
mod deck_state;
mod decks;
mod draft;
mod filter;
mod fonts;

324
src/ui/configure_deck.rs Normal file
View File

@@ -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()
}
}
}

91
src/ui/edit_deck.rs Normal file
View File

@@ -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()
}
}
}

View File

@@ -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;

View File

@@ -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,
);
}