feat: UI progress

This commit is contained in:
kieran 2024-10-18 13:10:47 +01:00
parent 5bed3fa86f
commit b4a6991007
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
22 changed files with 343 additions and 79 deletions

View File

@ -2,15 +2,15 @@
git clone https://github.com/v0l/ffmpeg-kit.git
export ANDROID_SDK_ROOT=$ANDROID_HOME
cd ffmpeg-kit && ./android.sh \
--disable-x86 \
--disable-x86-64 \
--disable-arm-v7a \
--disable-arm-v7a-neon \
--enable-openssl \
--api-level=28 \
--no-ffmpeg-kit-protocols \
--no-archive
#cd ffmpeg-kit && ./android.sh \
# --disable-x86 \
# --disable-x86-64 \
# --disable-arm-v7a \
# --disable-arm-v7a-neon \
# --enable-openssl \
# --api-level=28 \
# --no-ffmpeg-kit-protocols \
# --no-archive
NDK_VER="28.0.12433566"
ARCH="arm64"
@ -20,6 +20,8 @@ export FFMPEG_DIR="$(pwd)/ffmpeg-kit/prebuilt/$PLATFORM-$ARCH/ffmpeg"
export PKG_CONFIG_SYSROOT_DIR="$(pwd)/ffmpeg-kit/prebuilt/$PLATFORM-$ARCH/pkgconfig"
# DIRTY HACK !!
mkdir -p ./target/x/debug/android/$ARCH/cargo/$TRIPLET/release/deps
mkdir -p ./target/x/release/android/$ARCH/cargo/$TRIPLET/release/deps
cp "$ANDROID_HOME/ndk/$NDK_VER/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/$TRIPLET/35/libcamera2ndk.so" \
./target/x/debug/android/$ARCH/cargo/$TRIPLET/debug/deps
cp "$ANDROID_HOME/ndk/$NDK_VER/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/$TRIPLET/35/libcamera2ndk.so" \

View File

@ -60,6 +60,7 @@ impl App for ZapStreamApp {
egui::CentralPanel::default()
.frame(app_frame)
.show(ctx, |ui| {
ui.visuals_mut().override_text_color = Some(Color32::WHITE);
self.router.show(ui);
});
}

View File

@ -8,8 +8,10 @@ mod note_util;
mod route;
mod services;
mod stream_info;
pub mod widgets;
pub mod theme;
mod widgets;
mod theme;
mod note_store;
#[cfg(target_os = "android")]
use winit::platform::android::activity::AndroidApp;
@ -31,7 +33,7 @@ pub async fn android_main(app: AndroidApp) {
builder.with_android_app(app_clone_for_event_loop);
}));
let external_data_path = app
let data_path = app
.external_data_path()
.expect("external data path")
.to_path_buf();
@ -39,6 +41,6 @@ pub async fn android_main(app: AndroidApp) {
let _res = eframe::run_native(
"zap.stream",
options,
Box::new(move |cc| Ok(Box::new(ZapStreamApp::new(cc, external_data_path)))),
Box::new(move |cc| Ok(Box::new(ZapStreamApp::new(cc, data_path)))),
);
}

48
src/note_store.rs Normal file
View File

@ -0,0 +1,48 @@
use crate::link::NostrLink;
use nostrdb::Note;
use std::borrow::Borrow;
use std::collections::HashMap;
pub struct NoteStore<'a> {
events: HashMap<String, Note<'a>>,
}
impl<'a> NoteStore<'a> {
pub fn new() -> Self {
Self {
events: HashMap::new()
}
}
pub fn from_vec(events: Vec<Note<'a>>) -> Self {
let mut store = Self::new();
for note in events {
store.add(note);
}
store
}
pub fn add(&mut self, note: Note<'a>) -> Option<Note<'a>> {
let k = Self::key(&note);
if let Some(v) = self.events.get(&k) {
if v.created_at() < note.created_at() {
return self.events.insert(k, note);
}
}
self.events.insert(k, note)
}
pub fn remove(&mut self, note: &Note<'a>) -> Option<Note<'a>> {
self.events.remove(&Self::key(note))
}
pub fn key(note: &Note<'a>) -> String {
NostrLink::from_note(note)
.to_tag_value()
}
pub fn iter(&self) -> impl Iterator<Item=&Note<'a>> {
self.events.values()
}
}

View File

@ -1,3 +1,4 @@
use crate::note_store::NoteStore;
use crate::note_util::OwnedNote;
use crate::route::RouteServices;
use crate::services::ndb_wrapper::{NDBWrapper, SubWrapper};
@ -39,6 +40,7 @@ impl NostrWidget for HomePage {
.map_while(|f| f.map_or(None, |f| Some(f)))
.collect();
let events = NoteStore::from_vec(events);
ScrollArea::vertical()
.show(ui, |ui| {
widgets::StreamList::new(&events, &services).ui(ui)

46
src/route/login.rs Normal file
View File

@ -0,0 +1,46 @@
use crate::route::{RouteAction, RouteServices, Routes};
use crate::widgets::{Button, NostrWidget};
use egui::{Color32, Response, RichText, Ui};
use nostr_sdk::util::hex;
pub struct LoginPage {
key: String,
error: Option<String>,
}
impl LoginPage {
pub fn new() -> Self {
Self {
key: String::new(),
error: None,
}
}
}
impl NostrWidget for LoginPage {
fn render(&mut self, ui: &mut Ui, services: &RouteServices<'_>) -> Response {
ui.vertical_centered(|ui| {
ui.spacing_mut().item_spacing.y = 8.;
ui.label(RichText::new("Login").size(32.));
ui.label("Pubkey");
ui.text_edit_singleline(&mut self.key);
if Button::new()
.show(ui, |ui| {
ui.label("Login")
}).clicked() {
if let Ok(pk) = hex::decode(&self.key) {
if let Ok(pk) = pk.as_slice().try_into() {
services.action(RouteAction::LoginPubkey(pk));
services.navigate(Routes::HomePage);
return;
}
}
self.error = Some("Invalid pubkey".to_string());
}
if let Some(e) = &self.error {
ui.label(RichText::new(e).color(Color32::RED));
}
}).response
}
}

View File

@ -2,6 +2,7 @@ use crate::link::NostrLink;
use crate::note_util::OwnedNote;
use crate::route;
use crate::route::home::HomePage;
use crate::route::login::LoginPage;
use crate::route::stream::StreamPage;
use crate::services::image_cache::ImageCache;
use crate::services::ndb_wrapper::NDBWrapper;
@ -18,11 +19,12 @@ use std::path::PathBuf;
mod home;
mod stream;
mod login;
#[derive(PartialEq)]
pub enum Routes {
HomePage,
Event {
EventPage {
link: NostrLink,
event: Option<OwnedNote>,
},
@ -30,6 +32,7 @@ pub enum Routes {
link: NostrLink,
profile: Option<OwnedNote>,
},
LoginPage,
// special kind for modifying route state
Action(RouteAction),
@ -37,7 +40,8 @@ pub enum Routes {
#[derive(PartialEq)]
pub enum RouteAction {
Login([u8; 32]),
/// Login with public key
LoginPubkey([u8; 32]),
}
pub struct Router {
@ -72,10 +76,14 @@ impl Router {
let w = HomePage::new(&self.ndb, tx);
self.current_widget = Some(Box::new(w));
}
Routes::Event { link, .. } => {
Routes::EventPage { link, .. } => {
let w = StreamPage::new_from_link(&self.ndb, tx, link.clone());
self.current_widget = Some(Box::new(w));
}
Routes::LoginPage => {
let w = LoginPage::new();
self.current_widget = Some(Box::new(w));
}
_ => warn!("Not implemented"),
}
self.current = route;
@ -85,10 +93,11 @@ impl Router {
let tx = self.ndb.start_transaction();
// handle app state changes
while let Some(r) = self.router.read(ui).next() {
let mut q = self.router.read(ui);
while let Some(r) = q.next() {
if let Routes::Action(a) = &r {
match a {
RouteAction::Login(k) => self.login = Some(k.clone()),
RouteAction::LoginPubkey(k) => self.login = Some(k.clone()),
_ => info!("Not implemented"),
}
} else {
@ -138,4 +147,10 @@ impl<'a> RouteServices<'a> {
warn!("Failed to navigate");
}
}
pub fn action(&self, route: RouteAction) {
if let Err(e) = self.router.send(Routes::Action(route)) {
warn!("Failed to navigate");
}
}
}

View File

@ -74,10 +74,12 @@ impl NostrWidget for StreamPage {
let h = ui.available_height();
ui.allocate_ui(Vec2::new(w, h - chat_h), |ui| {
if let Some(c) = self.chat.as_mut() {
c.render(ui, services)
c.render(ui, services);
} else {
ui.label("Loading..")
ui.label("Loading..");
}
// consume rest of space
ui.add_space(ui.available_height());
});
ui.allocate_ui(Vec2::new(w, chat_h), |ui| {
self.new_msg.render(ui, services)

View File

@ -7,7 +7,8 @@ use nostrdb::{
Error, Filter, Ndb, NdbProfile, Note, NoteKey, ProfileRecord, QueryResult, Subscription,
Transaction,
};
use std::sync::{Arc, RwLock};
use std::collections::HashSet;
use std::sync::{Arc, Mutex, RwLock};
use tokio::sync::mpsc::UnboundedSender;
pub struct NDBWrapper {
@ -15,6 +16,7 @@ pub struct NDBWrapper {
ndb: Ndb,
client: Client,
query_manager: QueryManager<Client>,
profiles: Mutex<HashSet<[u8; 32]>>,
}
/// Automatic cleanup for subscriptions
@ -70,6 +72,7 @@ impl NDBWrapper {
ndb,
client,
query_manager: qm,
profiles: Mutex::new(HashSet::new()),
}
}
@ -144,11 +147,13 @@ impl NDBWrapper {
// TODO: fix this shit
if p.is_none() {
self.query_manager.queue_query("profile", &[
nostr::Filter::new()
.kinds([Kind::Metadata])
.authors([PublicKey::from_slice(pubkey).unwrap()])
])
if self.profiles.lock().unwrap().insert(*pubkey) {
self.query_manager.queue_query("profile", &[
nostr::Filter::new()
.kinds([Kind::Metadata])
.authors([PublicKey::from_slice(pubkey).unwrap()])
])
}
}
let sub = None;
(p, sub)

View File

@ -64,6 +64,12 @@ impl Query {
let now = Utc::now();
let id = Uuid::new_v4();
// remove filters already sent
next = next
.into_iter()
.filter(|f| self.traces.len() == 0 || !self.traces.iter().all(|y| y.filters.iter().any(|z| z == f)))
.collect();
// force profile queries into single filter
if next.iter().all(|f| if let Some(k) = &f.kinds {
k.len() == 1 && k.first().unwrap().as_u16() == 0
@ -76,11 +82,6 @@ impl Query {
]
}
// remove filters already sent
next = next
.into_iter()
.filter(|f| !self.traces.iter().any(|y| y.filters.iter().any(|z| z.eq(f))))
.collect();
if next.len() == 0 {
return None;

View File

@ -11,6 +11,12 @@ pub trait StreamInfo {
fn stream(&self) -> Option<&str>;
fn starts(&self) -> u64;
fn image(&self) -> Option<&str>;
fn status(&self) -> Option<&str>;
fn viewers(&self) -> Option<u32>;
}
impl<'a> StreamInfo for Note<'a> {
@ -50,6 +56,7 @@ impl<'a> StreamInfo for Note<'a> {
}
}
fn starts(&self) -> u64 {
if let Some(s) = self.get_tag_value("starts") {
s.variant().str()
@ -58,4 +65,29 @@ impl<'a> StreamInfo for Note<'a> {
self.created_at()
}
}
fn image(&self) -> Option<&str> {
if let Some(s) = self.get_tag_value("image") {
s.variant().str()
} else {
None
}
}
fn status(&self) -> Option<&str> {
if let Some(s) = self.get_tag_value("status") {
s.variant().str()
} else {
None
}
}
fn viewers(&self) -> Option<u32> {
if let Some(s) = self.get_tag_value("current_participants") {
s.variant().str()
.map_or(None, |v| Some(v.parse::<u32>().unwrap_or(0)))
} else {
None
}
}
}

View File

@ -1,5 +1,7 @@
use egui::Color32;
pub const FONT_SIZE: f32 = 13.0;
pub const PRIMARY: Color32 = Color32::from_rgb(248, 56, 217);
pub const NEUTRAL_500: Color32 = Color32::from_rgb(115, 115, 115);
pub const NEUTRAL_800: Color32 = Color32::from_rgb(38, 38, 38);
pub const NEUTRAL_900: Color32 = Color32::from_rgb(23, 23, 23);

View File

@ -27,7 +27,7 @@ impl<'a> Avatar<'a> {
}
}
pub fn from_profile(p: Option<NdbProfile<'a>>, svc: &'a ImageCache) -> Self {
pub fn from_profile(p: &'a Option<NdbProfile<'a>>, svc: &'a ImageCache) -> Self {
let img = p
.map_or(None, |f| f.picture().map(|f| svc.load(f)));
Self {

32
src/widgets/button.rs Normal file
View File

@ -0,0 +1,32 @@
use crate::theme::NEUTRAL_800;
use egui::{Color32, CursorIcon, Frame, Margin, Response, Sense, Ui};
pub struct Button {
color: Color32,
}
impl Button {
pub fn new() -> Self {
Self {
color: NEUTRAL_800
}
}
pub fn show<F>(self, ui: &mut Ui, add_contents: F) -> Response
where
F: FnOnce(&mut Ui) -> Response,
{
let r = Frame::none()
.inner_margin(Margin::symmetric(12., 8.))
.fill(self.color)
.rounding(12.)
.show(ui, add_contents);
let id = r.response.id;
ui.interact(
r.response.on_hover_and_drag_cursor(CursorIcon::PointingHand).rect,
id,
Sense::click(),
)
}
}

View File

@ -25,6 +25,8 @@ impl<'a> Widget for ChatMessage<'a> {
fn ui(self, ui: &mut Ui) -> Response {
ui.horizontal_wrapped(|ui| {
let mut job = LayoutJob::default();
// TODO: avoid this somehow
job.wrap.break_anywhere = true;
let is_host = self.stream.host().eq(self.ev.pubkey());
let profile = self.services.ndb.get_profile_by_pubkey(self.services.tx, self.ev.pubkey())
@ -48,7 +50,7 @@ impl<'a> Widget for ChatMessage<'a> {
format.color = Color32::WHITE;
job.append(self.ev.content(), 5.0, format.clone());
ui.add(Avatar::from_profile(profile ,self.services.img_cache).size(24.));
ui.add(Avatar::from_profile(&profile ,self.services.img_cache).size(24.));
ui.add(Label::new(job)
.wrap_mode(TextWrapMode::Wrap)
);

View File

@ -1,9 +1,9 @@
use crate::route::{RouteServices, Routes};
use crate::widgets::avatar::Avatar;
use crate::widgets::NostrWidget;
use crate::widgets::{Button, NostrWidget};
use eframe::emath::Align;
use eframe::epaint::Vec2;
use egui::{Frame, Image, Layout, Margin, Response, Sense, Ui, Widget};
use egui::{CursorIcon, Frame, Image, Layout, Margin, Response, Sense, Ui, Widget};
pub struct Header;
@ -28,13 +28,24 @@ impl NostrWidget for Header {
.max_height(24.)
.sense(Sense::click())
.ui(ui)
.on_hover_and_drag_cursor(CursorIcon::PointingHand)
.clicked()
{
services.navigate(Routes::HomePage);
}
if let Some(pk) = services.login {
//ui.add(Avatar::pubkey(pk, services));
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if let Some(pk) = services.login {
ui.add(Avatar::pubkey(pk, services));
} else {
if Button::new()
.show(ui, |ui| {
ui.label("Login")
}).clicked() {
services.navigate(Routes::LoginPage);
}
}
});
},
)
})

View File

@ -9,6 +9,8 @@ mod stream_player;
mod video_placeholder;
mod stream_title;
mod write_chat;
mod username;
mod button;
use crate::route::RouteServices;
use egui::{Response, Ui};
@ -26,3 +28,5 @@ pub use self::stream_player::StreamPlayer;
pub use self::video_placeholder::VideoPlaceholder;
pub use self::stream_title::StreamTitle;
pub use self::write_chat::WriteChat;
pub use self::username::Username;
pub use self::button::Button;

View File

@ -1,9 +1,10 @@
use crate::route::RouteServices;
use crate::services::image_cache::ImageCache;
use crate::services::ndb_wrapper::SubWrapper;
use crate::widgets::Avatar;
use egui::{Color32, Label, Response, RichText, TextWrapMode, Ui, Widget};
use crate::widgets::{Avatar, Username};
use egui::{Response, Ui, Widget};
use nostrdb::NdbProfile;
use crate::theme::FONT_SIZE;
pub struct Profile<'a> {
size: f32,
@ -36,13 +37,8 @@ impl<'a> Widget for Profile<'a> {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.;
ui.add(Avatar::from_profile(self.profile, self.img_cache).size(self.size));
let name = self
.profile
.map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f));
let name = RichText::new(name).size(13.).color(Color32::WHITE);
ui.add(Label::new(name).wrap_mode(TextWrapMode::Truncate));
ui.add(Avatar::from_profile(&self.profile, self.img_cache).size(self.size));
ui.add(Username::new(&self.profile, FONT_SIZE))
}).response
}
}

View File

@ -1,15 +1,17 @@
use crate::note_store::NoteStore;
use crate::route::RouteServices;
use crate::stream_info::StreamInfo;
use crate::widgets::stream_tile::StreamEvent;
use egui::{Frame, Margin, Response, Ui, Widget};
use nostrdb::Note;
use itertools::Itertools;
pub struct StreamList<'a> {
streams: &'a Vec<Note<'a>>,
streams: &'a NoteStore<'a>,
services: &'a RouteServices<'a>,
}
impl<'a> StreamList<'a> {
pub fn new(streams: &'a Vec<Note<'a>>, services: &'a RouteServices) -> Self {
pub fn new(streams: &'a NoteStore<'a>, services: &'a RouteServices) -> Self {
Self { streams, services }
}
}
@ -21,7 +23,10 @@ impl Widget for StreamList<'_> {
.show(ui, |ui| {
ui.vertical(|ui| {
ui.style_mut().spacing.item_spacing = egui::vec2(0., 20.0);
for event in self.streams {
for event in self.streams.iter()
.sorted_by(|a, b| {
a.starts().cmp(&b.starts())
}) {
ui.add(StreamEvent::new(event, self.services));
}
})

View File

@ -1,32 +1,24 @@
use crate::link::NostrLink;
use crate::note_util::NoteUtil;
use crate::route::{RouteServices, Routes};
use crate::stream_info::StreamInfo;
use crate::theme::{NEUTRAL_500, NEUTRAL_900, PRIMARY};
use crate::widgets::avatar::Avatar;
use crate::widgets::VideoPlaceholder;
use eframe::epaint::Vec2;
use egui::{Color32, Image, Label, Response, RichText, Rounding, Sense, TextWrapMode, Ui, Widget};
use nostrdb::{NdbProfile, Note};
use eframe::epaint::{Rounding, Vec2};
use egui::epaint::RectShape;
use egui::load::TexturePoll;
use egui::{vec2, Color32, CursorIcon, FontId, Label, Pos2, Rect, Response, RichText, Sense, TextWrapMode, Ui, Widget};
use image::Pixel;
use nostrdb::Note;
pub struct StreamEvent<'a> {
event: &'a Note<'a>,
picture: Option<Image<'a>>,
services: &'a RouteServices<'a>,
}
impl<'a> StreamEvent<'a> {
pub fn new(event: &'a Note<'a>, services: &'a RouteServices) -> Self {
let image = event.get_tag_value("image");
let cover = match image {
Some(i) => match i.variant().str() {
Some(i) => Some(services.img_cache.load(i)),
None => None,
},
None => None,
};
Self {
event,
picture: cover,
services,
}
}
@ -41,24 +33,65 @@ impl Widget for StreamEvent<'_> {
let w = ui.available_width();
let h = (w / 16.0) * 9.0;
let img_size = Vec2::new(w, h);
let cover = self.event.image()
.map(|p| self.services.img_cache.load(p));
let img = match self.picture {
Some(picture) => picture
.fit_to_exact_size(img_size)
.rounding(Rounding::same(12.))
.sense(Sense::click())
.ui(ui),
None => VideoPlaceholder.ui(ui),
let (response, painter) = ui.allocate_painter(Vec2::new(w, h), Sense::click());
if let Some(cover) = cover.map(|c|
c.rounding(Rounding::same(12.))
.load_for_size(painter.ctx(), Vec2::new(w, h))) {
match cover {
Ok(TexturePoll::Ready { texture }) => {
painter.add(RectShape {
rect: response.rect,
rounding: Rounding::same(12.),
fill: Color32::WHITE,
stroke: Default::default(),
blur_width: 0.0,
fill_texture_id: texture.id,
uv: Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(1.0, 1.0)),
});
}
_ => {
painter.rect_filled(response.rect, 12., NEUTRAL_500);
}
}
} else {
painter.rect_filled(response.rect, 12., NEUTRAL_500);
}
let overlay_label_pad = Vec2::new(5., 5.);
let live_label_text = self.event.status().unwrap_or("live").to_string().to_uppercase();
let live_label_color = if live_label_text == "LIVE" {
PRIMARY
} else {
NEUTRAL_900
};
if img.clicked() {
self.services.navigate(Routes::Event {
let live_label = painter.layout_no_wrap(live_label_text, FontId::default(), Color32::WHITE);
let overlay_react = response.rect.shrink(8.0);
let live_label_pos = overlay_react.min + vec2(overlay_react.width() - live_label.rect.width() - (overlay_label_pad.x * 2.), 0.0);
let live_label_background = Rect::from_two_pos(live_label_pos, live_label_pos + live_label.size() + (overlay_label_pad * 2.));
painter.rect_filled(live_label_background, 8., live_label_color);
painter.galley(live_label_pos + overlay_label_pad, live_label, Color32::PLACEHOLDER);
if let Some(viewers) = self.event.viewers() {
let viewers_label = painter.layout_no_wrap(format!("{} viewers", viewers), FontId::default(), Color32::WHITE);
let rect_start = overlay_react.max - viewers_label.size() - (overlay_label_pad * 2.0);
let pos = Rect::from_two_pos(rect_start, overlay_react.max);
painter.rect_filled(pos, 8., NEUTRAL_900);
painter.galley(rect_start + overlay_label_pad, viewers_label, Color32::PLACEHOLDER);
}
let response = response.on_hover_and_drag_cursor(CursorIcon::PointingHand);
if response.clicked() {
self.services.navigate(Routes::EventPage {
link: NostrLink::from_note(&self.event),
event: None,
});
}
ui.horizontal(|ui| {
ui.add(Avatar::from_profile(host_profile, self.services.img_cache).size(40.));
ui.add(Avatar::from_profile(&host_profile, self.services.img_cache).size(40.));
let title = RichText::new(self.event.title().unwrap_or("Untitled"))
.size(16.)
.color(Color32::WHITE);

23
src/widgets/username.rs Normal file
View File

@ -0,0 +1,23 @@
use egui::{Color32, Label, Response, RichText, TextWrapMode, Ui, Widget};
use nostrdb::NdbProfile;
pub struct Username<'a> {
profile: &'a Option<NdbProfile<'a>>,
size: f32,
}
impl<'a> Username<'a> {
pub fn new(profile: &'a Option<NdbProfile<'a>>, size: f32) -> Self {
Self { profile, size }
}
}
impl<'a> Widget for Username<'a> {
fn ui(self, ui: &mut Ui) -> Response {
let name = self
.profile
.map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f));
let name = RichText::new(name).size(self.size).color(Color32::WHITE);
ui.add(Label::new(name).wrap_mode(TextWrapMode::Truncate))
}
}

View File

@ -1,4 +1,4 @@
use egui::{Color32, Rect, Response, Rounding, Sense, Ui, Vec2, Widget};
use egui::{Color32, Response, Rounding, Sense, Ui, Vec2, Widget};
pub struct VideoPlaceholder;
@ -10,7 +10,7 @@ impl Widget for VideoPlaceholder {
let (response, painter) = ui.allocate_painter(img_size, Sense::click());
painter.rect_filled(
Rect::EVERYTHING,
response.rect,
Rounding::same(12.),
Color32::from_rgb(200, 200, 200),
);