Compare commits

..

No commits in common. "1c403f7d81b9fd4e6dbbc808b2a0fe1c35657247" and "77d12b2b010f10e3312b94110e7c53a00e50c63c" have entirely different histories.

17 changed files with 1824 additions and 419 deletions

1769
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -26,10 +26,9 @@ crisp = { git = "https://git.tfcconnection.org/chris/crisp", version = "0.1.3" }
rodio = { version = "0.20.1", features = ["symphonia-all", "tracing"] }
gstreamer = "0.23.3"
gstreamer-app = "0.23.3"
# cosmic-time = "0.2.0"
cosmic-time = "0.2.0"
url = "2"
colors-transform = "0.2.11"
# mupdf = "0.5.0"
# rfd = { version = "0.12.1", features = ["xdg-portal"], default-features = false }
[dependencies.iced_video_player]

View file

@ -3,7 +3,7 @@ use crate::{Background, Slide, SlideBuilder, TextAlignment};
use super::{
content::Content,
kinds::ServiceItemKind,
model::{LibraryKind, Model},
model::{get_db, LibraryKind, Model},
service_items::ServiceTrait,
};
use crisp::types::{Keyword, Symbol, Value};
@ -45,7 +45,13 @@ impl Content for Image {
}
fn background(&self) -> Option<Background> {
Background::try_from(self.path.clone()).ok()
if let Ok(background) =
Background::try_from(self.path.clone())
{
Some(background)
} else {
None
}
}
fn subtext(&self) -> String {

View file

@ -133,6 +133,12 @@ pub(crate) fn get_lists(exp: &Value) -> Vec<Value> {
#[cfg(test)]
mod test {
use std::fs::read_to_string;
use lexpr::{parse::Options, Parser};
use pretty_assertions::assert_eq;
use super::*;
// #[test]
// fn test_list() {

View file

@ -4,6 +4,8 @@ use cosmic::iced::Executor;
use miette::{miette, Result};
use sqlx::{Connection, SqliteConnection};
use super::kinds::ServiceItemKind;
#[derive(Debug, Clone)]
pub struct Model<T> {
pub items: Vec<T>,

View file

@ -13,7 +13,7 @@ use crate::{Background, Slide, SlideBuilder, TextAlignment};
use super::{
content::Content,
kinds::ServiceItemKind,
model::{LibraryKind, Model},
model::{get_db, LibraryKind, Model},
service_items::ServiceTrait,
};
@ -57,7 +57,13 @@ impl Content for Presentation {
}
fn background(&self) -> Option<Background> {
Background::try_from(self.path.clone()).ok()
if let Ok(background) =
Background::try_from(self.path.clone())
{
Some(background)
} else {
None
}
}
fn subtext(&self) -> String {

View file

@ -3,7 +3,7 @@ use std::ops::Deref;
use cosmic::iced::clipboard::mime::{AllowedMimeTypes, AsMimeTypes};
use crisp::types::{Keyword, Symbol, Value};
use miette::Result;
use miette::{miette, Result};
use tracing::{debug, error};
use crate::Slide;
@ -153,7 +153,8 @@ impl From<&Value> for ServiceItem {
database_id: 0,
kind: ServiceItemKind::Content(slide),
}
} else if let Some(background) =
} else {
if let Some(background) =
list.get(background_pos)
{
match background {
@ -175,9 +176,11 @@ impl From<&Value> for ServiceItem {
Value::Symbol(Symbol(s))
if s == "presentation" =>
{
Self::from(&Presentation::from(
Self::from(
&Presentation::from(
background,
))
),
)
}
_ => todo!(),
},
@ -197,6 +200,7 @@ impl From<&Value> for ServiceItem {
ServiceItem::default()
}
}
}
Value::Symbol(Symbol(s)) if s == "song" => {
let song = lisp_to_song(list.clone());
Self::from(&song)
@ -338,7 +342,7 @@ mod test {
use crate::core::presentations::PresKind;
use super::*;
use pretty_assertions::assert_eq;
use pretty_assertions::{assert_eq, assert_ne};
fn test_song() -> Song {
Song {

View file

@ -1,5 +1,6 @@
// use cosmic::dialog::ashpd::url::Url;
use crisp::types::{Keyword, Symbol, Value};
use gstreamer::query::Uri;
use iced_video_player::Video;
use miette::{miette, Result};
use serde::{Deserialize, Serialize};
@ -12,15 +13,7 @@ use tracing::error;
use super::songs::Song;
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
Serialize,
Deserialize,
Hash,
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum TextAlignment {
TopLeft,

View file

@ -8,14 +8,14 @@ use sqlx::{
pool::PoolConnection, query, sqlite::SqliteRow, FromRow, Row,
Sqlite, SqliteConnection, SqlitePool,
};
use tracing::error;
use tracing::{debug, error};
use crate::{core::slide, Slide, SlideBuilder};
use super::{
content::Content,
kinds::ServiceItemKind,
model::{LibraryKind, Model},
model::{get_db, LibraryKind, Model},
service_items::ServiceTrait,
slide::{Background, TextAlignment},
};
@ -132,7 +132,10 @@ impl FromRow<'_, SqliteRow> for Song {
}),
background: {
let string: String = row.try_get(7)?;
Background::try_from(string).ok()
match Background::try_from(string) {
Ok(background) => Some(background),
Err(_) => None,
}
},
text_alignment: Some({
let horizontal_alignment: String = row.try_get(3)?;
@ -420,7 +423,7 @@ pub async fn update_song_in_db(
if let Some(vo) = item.verse_order {
vo.into_iter()
.map(|mut s| {
s.push(' ');
s.push_str(" ");
s
})
.collect::<String>()
@ -535,6 +538,7 @@ impl Song {
lyric_list.push(lyric.to_string());
} else {
// error!("NOT WORKING!");
()
};
}
// for lyric in lyric_list.iter() {
@ -552,7 +556,7 @@ mod test {
use std::fs::read_to_string;
use super::*;
use pretty_assertions::assert_eq;
use pretty_assertions::{assert_eq, assert_ne};
#[test]
pub fn test_song_lyrics() {
@ -720,7 +724,7 @@ You saved my soul"
let lisp = read_to_string("./test_song.lisp").expect("oops");
let lisp_value = crisp::reader::read(&lisp);
match lisp_value {
Value::List(v) => v.first().unwrap().clone(),
Value::List(v) => v.get(0).unwrap().clone(),
_ => Value::Nil,
}
}

View file

@ -3,7 +3,7 @@ use crate::{Background, SlideBuilder, TextAlignment};
use super::{
content::Content,
kinds::ServiceItemKind,
model::{LibraryKind, Model},
model::{get_db, LibraryKind, Model},
service_items::ServiceTrait,
slide::Slide,
};
@ -50,7 +50,13 @@ impl Content for Video {
}
fn background(&self) -> Option<Background> {
Background::try_from(self.path.clone()).ok()
if let Ok(background) =
Background::try_from(self.path.clone())
{
Some(background)
} else {
None
}
}
fn subtext(&self) -> String {

View file

@ -37,14 +37,17 @@ pub fn parse_lisp(value: Value) -> Vec<ServiceItem> {
#[cfg(test)]
mod test {
use std::{fs::read_to_string, path::PathBuf};
use std::{
fs::read_to_string,
path::{Path, PathBuf},
};
use crate::{
core::{
images::Image, kinds::ServiceItemKind,
service_items::ServiceTrait, songs::Song, videos::Video,
},
Background, TextAlignment,
Background, Slide, SlideBuilder, TextAlignment,
};
use super::*;

View file

@ -1,4 +1,5 @@
use clap::{command, Parser};
use core::model::{get_db, LibraryKind};
use core::service_items::{ServiceItem, ServiceItemModel};
use core::slide::*;
use core::songs::Song;
@ -13,12 +14,13 @@ use cosmic::iced_widget::{column, row};
use cosmic::widget::dnd_destination::DragId;
use cosmic::widget::nav_bar::nav_bar_style;
use cosmic::widget::segmented_button::Entity;
use cosmic::widget::text;
use cosmic::widget::tooltip::Position as TPosition;
use cosmic::widget::{
button, horizontal_space, nav_bar, search_input, tooltip, Space,
button, horizontal_space, nav_bar, search_input, text_input,
tooltip, Space,
};
use cosmic::widget::{icon, slider};
use cosmic::widget::{text, toggler};
use cosmic::{executor, Application, ApplicationExt, Element};
use cosmic::{prelude::*, theme};
use cosmic::{widget::Container, Theme};
@ -330,7 +332,7 @@ impl cosmic::Application for App {
)
.class(cosmic::theme::style::Button::HeaderBar)
.on_press(Message::EditorToggle(
self.editor_mode.is_none(),
!self.editor_mode.is_some(),
)),
"Enter Edit Mode",
TPosition::Bottom,
@ -719,7 +721,7 @@ impl cosmic::Application for App {
let library = if self.library_open {
Container::new(if let Some(library) = &self.library {
library.view().map(Message::Library)
library.view().map(|m| Message::Library(m))
} else {
Space::new(0, 0).into()
})
@ -730,7 +732,7 @@ impl cosmic::Application for App {
};
let song_editor =
self.song_editor.view().map(Message::SongEditor);
self.song_editor.view().map(|m| Message::SongEditor(m));
let row = row![
Container::new(
@ -744,7 +746,8 @@ impl cosmic::Application for App {
.class(theme::style::Button::Transparent)
)
.center_y(Length::Fill)
.align_right(Length::FillPortion(1)),
.align_right(Length::Fill)
.width(Length::FillPortion(1)),
Container::new(slide_preview)
.center_y(Length::Fill)
.width(Length::FillPortion(3)),
@ -759,7 +762,8 @@ impl cosmic::Application for App {
.class(theme::style::Button::Transparent)
)
.center_y(Length::Fill)
.align_left(Length::FillPortion(1)),
.align_left(Length::Fill)
.width(Length::FillPortion(1)),
library
]
.width(Length::Fill)
@ -878,6 +882,8 @@ where
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
fn test_slide() -> String {
let slide = r#"(slide (image :source "./somehting.jpg" :fill cover

View file

@ -1,22 +1,21 @@
use std::rc::Rc;
use cosmic::{
iced::{
alignment::Vertical, clipboard::dnd::DndAction,
futures::FutureExt, Background, Border, Color, Length,
},
iced_core::widget::tree::State,
iced_widget::{column, row as rowm, text as textm},
theme,
widget::{
button, container, horizontal_space, icon, mouse_area,
responsive, row, scrollable, text, text_input, Container,
DndSource, Space, Widget,
DndSource, Icon, Space, Widget,
},
Element, Task,
};
use miette::{IntoDiagnostic, Result};
use sqlx::{pool::PoolConnection, Sqlite, SqlitePool};
use miette::{miette, IntoDiagnostic, Result};
use sqlx::{
pool::PoolConnection, Sqlite, SqliteConnection, SqlitePool,
};
use tracing::{debug, error, warn};
use crate::core::{
@ -360,7 +359,7 @@ impl<'a> Library {
|(index, item)| {
let service_item = item.to_service_item();
let drag_item =
Box::new(self.single_item(index, item, &model));
self.single_item(index, item, model);
let visual_item = self
.single_item(index, item, model)
.map(|_| Message::None);
@ -387,25 +386,11 @@ impl<'a> Library {
)),
)
.action(DndAction::Copy)
.drag_icon({
let model = model.kind.clone();
move |i| {
let state = State::None;
let icon = match model {
LibraryKind::Song => icon::from_name(
"folder-music-symbolic",
)
,
LibraryKind::Video => icon::from_name("folder-videos-symbolic"),
LibraryKind::Image => icon::from_name("folder-pictures-symbolic"),
LibraryKind::Presentation => icon::from_name("x-office-presentation-symbolic"),
};
(
icon.into(),
state,
i,
)
}})
// .drag_icon(move |i| {
// let state =
// drag_item.as_widget().state();
// (drag_item, state, i)
// })
.drag_content(move || {
service_item.to_owned()
})
@ -416,8 +401,7 @@ impl<'a> Library {
.spacing(2)
.width(Length::Fill),
)
.spacing(5)
.height(Length::Fill);
.spacing(5);
let library_toolbar = rowm!(
text_input("Search...", ""),

View file

@ -1,4 +1,4 @@
use miette::{IntoDiagnostic, Result};
use miette::{miette, IntoDiagnostic, Result};
use std::{fs::File, io::BufReader, path::PathBuf, sync::Arc};
use cosmic::{
@ -13,7 +13,7 @@ use cosmic::{
scrollable::{
scroll_to, AbsoluteOffset, Direction, Scrollbar,
},
span, stack,
span, stack, text,
},
prelude::*,
widget::{
@ -29,10 +29,13 @@ use url::Url;
use crate::{
core::{service_items::ServiceItemModel, slide::Slide},
ui::text_svg::{self, Font as SvgFont},
BackgroundKind,
};
use super::text_svg::{
self, shadow, stroke, Font as SvgFont, TextSvg,
};
const REFERENCE_WIDTH: f32 = 1920.0;
const REFERENCE_HEIGHT: f32 = 1080.0;
@ -525,43 +528,39 @@ async fn start_audio(sink: Arc<Sink>, audio: PathBuf) {
fn scale_font(font_size: f32, width: f32) -> f32 {
let scale_factor = (REFERENCE_WIDTH / width).sqrt();
// debug!(scale_factor);
if font_size > 0.0 {
let font_size = if font_size > 0.0 {
font_size / scale_factor
} else {
50.0
}
};
font_size
}
pub(crate) fn slide_view(
pub(crate) fn slide_view<'a>(
slide: Slide,
video: &Option<Video>,
video: &'a Option<Video>,
font: Font,
delegate: bool,
hide_mouse: bool,
) -> Element<'_, Message> {
) -> Element<'a, Message> {
responsive(move |size| {
let width = size.height * 16.0 / 9.0;
let font_size = scale_font(slide.font_size() as f32, width);
let slide_text = slide.text();
// SVG based
// let font = SvgFont::from(font).size(font_size.floor() as u8);
let slide_text = slide.text();
// let text = text_svg::TextSvg::new()
// .text(&slide_text)
// .fill("#fff")
// .shadow(text_svg::shadow(2, 2, 5, "#000000"))
// .stroke(text_svg::stroke(1, "#000"))
// .shadow(shadow(2, 2, 5, "#000000"))
// .stroke(stroke(1, "#000"))
// .font(font)
// .view()
// .map(|m| Message::None);
// let text = text!("{}", &slide_text);
// text widget based
let lines = slide_text.lines();
let text: Vec<Element<Message>> = lines
.map(|t| {
rich_text([span(format!("{}\n", t))
rich_text([span(format!("{}\n", t.to_string()))
.background(
Background::Color(Color::BLACK)
.scale_alpha(0.4),
@ -574,8 +573,6 @@ pub(crate) fn slide_view(
})
.collect();
let text = Column::with_children(text).spacing(6);
//Next
let text_container = Container::new(text)
.center(Length::Fill)
.align_x(Horizontal::Left);

View file

@ -31,10 +31,8 @@ use super::presenter::slide_view;
pub struct SongEditor {
pub song: Option<Song>,
title: String,
font_db: fontdb::Database,
fonts: Vec<(fontdb::ID, String)>,
fonts_combo: combo_box::State<String>,
font_sizes: combo_box::State<String>,
fonts: combo_box::State<String>,
font_sizes: Vec<String>,
font: String,
author: String,
audio: PathBuf,
@ -74,21 +72,42 @@ impl SongEditor {
pub fn new() -> Self {
let fonts = font_dir();
debug!(?fonts);
let mut font_db = fontdb::Database::new();
font_db.load_system_fonts();
let mut fonts: Vec<(fontdb::ID, String)> = font_db
let mut fontdb = fontdb::Database::new();
fontdb.load_system_fonts();
let fonts: Vec<String> = fontdb
.faces()
.map(|f| {
let id = f.id;
let font_base_name: String =
f.families.iter().map(|f| f.0.clone()).collect();
let font_weight = f.weight;
let font_style = f.style;
let font_stretch = f.stretch;
(id, font_base_name)
let mut font = f.to_owned().post_script_name;
if let Some(at) = font.find("-") {
let _ = font.split_off(at);
}
let indices: Vec<usize> = font
.chars()
.enumerate()
.filter(|(index, c)| {
c.is_uppercase() && *index != 0
})
.map(|(index, c)| index)
.collect();
let mut font_parts = vec![];
for index in indices.iter().rev() {
let (first, last) = font.split_at(*index);
font_parts.push(first);
if !last.is_empty() {
font_parts.push(last);
}
}
font_parts
.iter()
.map(|s| {
let mut s = s.to_string();
s.push(' ');
s
})
.collect()
})
.collect();
fonts.dedup();
// let fonts = vec![
// String::from("Quicksand"),
// String::from("Noto Sans"),
@ -115,16 +134,13 @@ impl SongEditor {
"70".to_string(),
"80".to_string(),
];
let font_texts = fonts.iter().map(|f| f.1.clone()).collect();
Self {
song: None,
font_db,
fonts,
fonts_combo: combo_box::State::new(font_texts),
fonts: combo_box::State::new(fonts),
title: "Death was Arrested".to_owned(),
font: "Quicksand".to_owned(),
font_size: 16,
font_sizes: combo_box::State::new(font_sizes),
font_sizes,
verse_order: "Death was Arrested".to_owned(),
lyrics: text_editor::Content::new(),
editing: false,
@ -151,7 +167,7 @@ impl SongEditor {
self.verse_order = verse_order
.into_iter()
.map(|mut s| {
s.push(' ');
s.push_str(" ");
s
})
.collect();
@ -173,23 +189,11 @@ impl SongEditor {
self.background = song.background;
}
Message::ChangeFont(font) => {
let font_id = self
.fonts
.iter()
.filter(|f| f.1 == font)
.map(|f| f.0)
.next();
if let Some(id) = font_id {
if let Some(face) = self.font_db.face(id) {
self.font = face.post_script_name.clone();
// self.current_font = Font::from(face);
}
}
self.font = font.clone();
let font_name = font.into_boxed_str();
let family = Family::Name(Box::leak(font_name));
let weight = Weight::Bold;
let weight = Weight::Normal;
let stretch = Stretch::Normal;
let style = Style::Normal;
let font = Font {
@ -201,7 +205,15 @@ impl SongEditor {
self.current_font = font;
// return self.update_song(song);
}
Message::ChangeFontSize(size) => self.font_size = size,
Message::ChangeFontSize(size) => {
if let Some(size) = self.font_sizes.get(size) {
if let Ok(size) = size.parse() {
debug!(font_size = size);
self.font_size = size;
// return self.update_song(song);
}
}
}
Message::ChangeTitle(title) => {
self.title = title.clone();
if let Some(song) = &mut self.song {
@ -215,6 +227,7 @@ impl SongEditor {
if let Some(mut song) = self.song.clone() {
let verse_order = verse_order
.split(" ")
.into_iter()
.map(|s| s.to_owned())
.collect();
song.verse_order = Some(verse_order);
@ -292,6 +305,20 @@ impl SongEditor {
.into_iter()
.enumerate()
.map(|(index, slide)| {
let svg = Handle::from_memory(r#"<svg viewBox="0 0 1280 720" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="shadow">
<feDropShadow dx="10" dy="10" stdDeviation="5" flood-color='#000' />
</filter>
</defs>
<text dominant-baseline="middle" text-anchor="middle" font-weight="bold" font-family="Quicksand" font-size="80" fill="white" stroke="black" stroke-width="2" style="filter:url(#shadow);">
<tspan x="50%" y="50" >Hello World this is</tspan>
<tspan x="50%" y="140">longer chunks of text</tspan>
<tspan x="50%" y="230">where we need to test whether the text</tspan>
<tspan x="50%" y="320">will look ok!</tspan>
</text>
</svg>"#.as_bytes());
stack!(
container(
slide_view(
slide,
@ -309,8 +336,9 @@ impl SongEditor {
.height(250)
.center_x(Length::Fill)
.padding([0, 20])
.clip(true)
.into()
.clip(true),
Svg::new(svg),
).into()
})
.collect();
scrollable(
@ -368,34 +396,22 @@ order",
fn toolbar(&self) -> Element<Message> {
let selected_font = &self.font;
let selected_font_size = {
let font_size_position = self
let selected_font_size = self
.font_sizes
.options()
.iter()
.position(|s| *s == self.font_size.to_string());
self.font_sizes
.options()
.get(font_size_position.unwrap_or_default())
};
let font_selector = combo_box(
&self.fonts_combo,
&self.fonts,
"Font",
Some(selected_font),
Message::ChangeFont,
)
.width(200);
let font_size = combo_box(
let font_size = dropdown(
&self.font_sizes,
"Font Size",
selected_font_size,
|size| {
Message::ChangeFontSize(
size.parse().expect("Should be a number"),
)
},
)
.width(theme::active().cosmic().space_xxl());
Message::ChangeFontSize,
);
let background_selector = button::icon(
icon::from_name("folder-pictures-symbolic").scale(2),
@ -411,7 +427,6 @@ order",
horizontal_space(),
background_selector
]
.spacing(10)
.into()
}

View file

@ -1,7 +1,4 @@
use std::{
fmt::Display,
hash::{Hash, Hasher},
};
use std::fmt::Display;
use colors_transform::Rgb;
use cosmic::{
@ -10,9 +7,9 @@ use cosmic::{
Length,
},
prelude::*,
widget::{container, lazy, responsive, svg::Handle, Svg},
widget::{container, responsive, svg::Handle, Svg},
};
use tracing::error;
use tracing::{debug, error};
use crate::TextAlignment;
@ -24,21 +21,9 @@ pub struct TextSvg {
stroke: Option<Stroke>,
fill: Color,
alignment: TextAlignment,
handle: Option<Handle>,
}
impl Hash for TextSvg {
fn hash<H: Hasher>(&self, state: &mut H) {
self.text.hash(state);
self.font.hash(state);
self.shadow.hash(state);
self.stroke.hash(state);
self.fill.hash(state);
self.alignment.hash(state);
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Font {
name: String,
weight: Weight,
@ -76,11 +61,11 @@ impl Font {
}
pub fn get_weight(&self) -> Weight {
self.weight
self.weight.clone()
}
pub fn get_style(&self) -> Style {
self.style
self.style.clone()
}
pub fn weight(mut self, weight: impl Into<Weight>) -> Self {
@ -107,12 +92,6 @@ impl Font {
#[derive(Clone, Debug, PartialEq)]
pub struct Color(Rgb);
impl Hash for Color {
fn hash<H: Hasher>(&self, state: &mut H) {
self.0.to_css_hex_string().hash(state);
}
}
impl Color {
pub fn from_hex_str(color: impl AsRef<str>) -> Color {
match Rgb::from_hex_str(color.as_ref()) {
@ -149,7 +128,7 @@ impl Display for Color {
}
}
#[derive(Clone, Debug, Default, PartialEq, Hash)]
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Shadow {
pub offset_x: i16,
pub offset_y: i16,
@ -157,7 +136,7 @@ pub struct Shadow {
pub color: Color,
}
#[derive(Clone, Debug, Default, PartialEq, Hash)]
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Stroke {
size: u16,
color: Color,
@ -174,8 +153,6 @@ impl TextSvg {
}
}
// pub fn build(self)
pub fn fill(mut self, color: impl Into<Color>) -> Self {
self.fill = color.into();
self
@ -249,11 +226,12 @@ impl TextSvg {
self.fill, stroke, text);
// debug!(final_svg);
lazy(self.clone(), move |_s| Svg::new(Handle::from_memory(
Box::leak(<std::string::String as Clone>::clone(&final_svg).into_boxed_str()).as_bytes(),
Svg::new(Handle::from_memory(
Box::leak(final_svg.into_boxed_str()).as_bytes(),
))
.width(Length::Fill)
.height(Length::Fill))
.height(Length::Fill)
.into()
})).width(Length::Fill).height(Length::Fill).into()
}

View file

@ -1,7 +1,6 @@
#+TITLE: The Task list for Lumina
* TODO Check into =mupdf-rs= for loading PDF's.
* TODO [#A] Text could be built by using SVG instead of the text element. Maybe I could construct my own text element even
This does almost work. There is a clear amount of lag or rather hang up since switching to the =text_svg= element. I think I may only keep it till I can figure out how to do strokes and shadows in iced's normal text element.