699 lines
27 KiB
Rust
699 lines
27 KiB
Rust
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||
|
||
use std::path::PathBuf;
|
||
|
||
use zng::view_process::prebuilt as view_process;
|
||
use zng::{
|
||
app, button,
|
||
checkerboard::Checkerboard,
|
||
clipboard,
|
||
color::{
|
||
filter::{drop_shadow, filter, mix_blend, Filter},
|
||
gradient::stops,
|
||
},
|
||
image::{self, img_error_fn, img_loading_fn, mask::mask_image, ImageDownscale, ImageFit, ImageLimits, ImgErrorArgs, IMAGES},
|
||
layout::{align, margin, padding, size},
|
||
mouse,
|
||
prelude::*,
|
||
scroll::ScrollMode,
|
||
task::http,
|
||
widget::{background_color, border, BorderSides},
|
||
window::{RenderMode, WindowState},
|
||
};
|
||
use zng_wgt_webrender_debug as wr;
|
||
|
||
fn main() {
|
||
examples_util::print_info();
|
||
// view_process::init();
|
||
|
||
// let rec = examples_util::record_profile("image");
|
||
|
||
view_process::run_same_process(app_main);
|
||
|
||
// app_main();
|
||
// rec.finish();
|
||
}
|
||
|
||
fn app_main() {
|
||
APP.defaults().run_window(async {
|
||
// by default all "ImageSource::Download" requests are blocked and "ImageSource::Read"
|
||
// is limited to only the exe dir. The limits can be set globally in here and overridden
|
||
// for each image with the "img_limits" property.
|
||
IMAGES.limits().modify(|l| {
|
||
let l = l.to_mut();
|
||
l.allow_uri = image::UriFilter::AllowAll;
|
||
l.allow_path = image::PathFilter::AllowAll;
|
||
});
|
||
|
||
// setup a file cache so we don't download the images every run.
|
||
http::set_default_client_init(move || {
|
||
http::Client::builder()
|
||
.cache(http::FileSystemCache::new(examples_util::temp_dir("image")).unwrap())
|
||
.cache_mode(img_cache_mode)
|
||
.build()
|
||
})
|
||
.unwrap();
|
||
|
||
ImgWindow!(
|
||
"Image Example",
|
||
Stack! {
|
||
direction = StackDirection::left_to_right();
|
||
spacing = 30;
|
||
children = ui_vec![
|
||
section(
|
||
"Sources",
|
||
ui_vec![
|
||
sub_title("File"),
|
||
Grid! {
|
||
columns = ui_vec![grid::Column!(1.lft()); 4];
|
||
auto_grow_fn = wgt_fn!(|_| grid::Row!(1.lft()));
|
||
spacing = 2;
|
||
align = Align::CENTER;
|
||
cells = {
|
||
fn img(source: &str) -> impl UiNode {
|
||
Image! {
|
||
grid::cell::at = grid::cell::AT_AUTO;
|
||
source;
|
||
}
|
||
}
|
||
ui_vec![
|
||
img("examples/res/image/Luma8.png"),
|
||
img("examples/res/image/Luma16.png"),
|
||
img("examples/res/image/LumaA8.png"),
|
||
img("examples/res/image/LumaA16.png"),
|
||
img("examples/res/image/RGB8.png"),
|
||
img("examples/res/image/RGB16.png"),
|
||
img("examples/res/image/RGBA8.png"),
|
||
img("examples/res/image/RGBA16.png"),
|
||
]
|
||
}
|
||
},
|
||
|
||
sub_title("Web"),
|
||
Image! {
|
||
source = "https://httpbin.org/image";
|
||
size = (200, 150);
|
||
},
|
||
|
||
sub_title("Web With Format"),
|
||
Image! {
|
||
source = (http::Uri::from_static("https://httpbin.org/image"), "image/png");
|
||
size = (200, 150);
|
||
},
|
||
sub_title("Render"),
|
||
Image! {
|
||
img_scale_ppi = true;
|
||
source = ImageSource::render_node(RenderMode::Software, |_| Container! {
|
||
size = (180, 120);
|
||
widget::background_gradient = layout::Line::to_bottom_left(), stops![hex!(#34753a), 40.pct(), hex!(#597d81)];
|
||
text::font_size = 24;
|
||
child_align = Align::CENTER;
|
||
child = Text!("Rendered!");
|
||
})
|
||
},
|
||
sub_title("Render Mask"),
|
||
Image! {
|
||
source = "examples/res/image/zdenek-machacek-unsplash.jpg";
|
||
size = (200, 120);
|
||
mask_image = ImageSource::render_node(RenderMode::Software, |_| Text! {
|
||
txt = "Mask";
|
||
txt_align = Align::CENTER;
|
||
font_size = 78;
|
||
font_weight = FontWeight::BOLD;
|
||
size = (200, 120);
|
||
});
|
||
}
|
||
]
|
||
),
|
||
|
||
Stack! {
|
||
direction = StackDirection::top_to_bottom();
|
||
spacing = 30;
|
||
children = ui_vec![
|
||
section(
|
||
"Fit",
|
||
ui_vec![
|
||
img_fit(ImageFit::None),
|
||
img_fit(ImageFit::Fill),
|
||
img_fit(ImageFit::Contain),
|
||
img_fit(ImageFit::Cover),
|
||
img_fit(ImageFit::ScaleDown),
|
||
]
|
||
),
|
||
section(
|
||
"Mix-Blend",
|
||
ui_vec![
|
||
Image! {
|
||
source = "examples/res/image/zdenek-machacek-unsplash.jpg";
|
||
size = (200, 100);
|
||
widget::foreground = Text! {
|
||
mix_blend = color::MixBlendMode::ColorDodge;
|
||
font_color = colors::RED;
|
||
txt = "Blend";
|
||
txt_align = Align::CENTER;
|
||
font_size = 58;
|
||
font_weight = FontWeight::BOLD;
|
||
};
|
||
}
|
||
]
|
||
)
|
||
]
|
||
},
|
||
|
||
section(
|
||
"Filter",
|
||
ui_vec![
|
||
img_filter(Filter::new_grayscale(true)),
|
||
img_filter(Filter::new_sepia(true)),
|
||
img_filter(Filter::new_opacity(50.pct())),
|
||
img_filter(Filter::new_invert(true)),
|
||
img_filter(Filter::new_hue_rotate(-(90.deg()))),
|
||
img_filter(Filter::new_color_matrix([
|
||
2.0, 1.0, 1.0, 1.0, 0.0,
|
||
0.0, 1.0, 0.0, 0.0, 0.0,
|
||
0.0, 0.0, 1.0, 0.0, 0.0,
|
||
0.0, 0.0, 0.0, 1.0, 0.0,
|
||
])),
|
||
]
|
||
),
|
||
|
||
Stack! {
|
||
direction = StackDirection::top_to_bottom();
|
||
spacing = 30;
|
||
children = ui_vec![
|
||
section(
|
||
"Errors",
|
||
|
||
ui_vec![
|
||
sub_title("File"),
|
||
Image!("404.png"),
|
||
|
||
sub_title("Web"),
|
||
Image!("https://httpbin.org/delay/5"),
|
||
]
|
||
),
|
||
section(
|
||
"Sprite",
|
||
ui_vec![sprite()]
|
||
),
|
||
section(
|
||
"Window",
|
||
ui_vec![
|
||
panorama_image(),
|
||
block_window_load_image(),
|
||
large_image(),
|
||
repeat_image(),
|
||
open_or_paste_image(),
|
||
]
|
||
)
|
||
];
|
||
}
|
||
]
|
||
},
|
||
)
|
||
})
|
||
}
|
||
|
||
fn img_fit(fit: impl IntoVar<ImageFit>) -> impl UiNode {
|
||
let fit = fit.into_var();
|
||
|
||
Stack! {
|
||
direction = StackDirection::top_to_bottom();
|
||
children_align = Align::TOP_LEFT;
|
||
spacing = 5;
|
||
|
||
children = ui_vec![
|
||
sub_title(fit.map_debug()),
|
||
Image! {
|
||
source = "examples/res/image/zdenek-machacek-unsplash.jpg";
|
||
size = (200, 100);
|
||
img_fit = fit;
|
||
border = {
|
||
widths: 1,
|
||
sides: BorderSides::dashed(colors::GRAY),
|
||
};
|
||
}
|
||
]
|
||
}
|
||
}
|
||
|
||
fn img_filter(filter: impl IntoVar<Filter>) -> impl UiNode {
|
||
let filter = filter.into_var();
|
||
|
||
Stack! {
|
||
direction = StackDirection::top_to_bottom();
|
||
children_align = Align::TOP_LEFT;
|
||
spacing = 2;
|
||
|
||
children = ui_vec![
|
||
sub_title(filter.map(|f| {
|
||
let s = format!("{f:?}");
|
||
if s.starts_with("color_matrix") {
|
||
Txt::from_static("color_matrix([...])")
|
||
} else {
|
||
Txt::from(s)
|
||
}
|
||
})),
|
||
Image! {
|
||
source = "examples/res/image/zdenek-machacek-unsplash.jpg";
|
||
size = (200, 100);
|
||
filter;
|
||
}
|
||
]
|
||
}
|
||
}
|
||
|
||
fn sprite() -> impl UiNode {
|
||
let timer = timer::TIMERS.interval((1.0 / 24.0).secs(), true);
|
||
let label = var_from("play");
|
||
|
||
Stack! {
|
||
direction = StackDirection::top_to_bottom();
|
||
align = Align::CENTER;
|
||
children = ui_vec![
|
||
Button! {
|
||
child = Text!(label.clone());
|
||
align = Align::CENTER;
|
||
padding = (2, 3);
|
||
on_click = hn!(timer, |_| {
|
||
let t = timer.get();
|
||
if t.is_paused() {
|
||
t.play(false);
|
||
} else {
|
||
t.pause();
|
||
}
|
||
label.set(if t.is_paused() { "play" } else { "pause" });
|
||
});
|
||
},
|
||
Image! {
|
||
source = "examples/res/image/player_combat_sheet-10-96x84-CC0.png";
|
||
size = (96, 84);
|
||
border = {
|
||
widths: 1,
|
||
sides: BorderSides::dashed(colors::GRAY),
|
||
};
|
||
widget::corner_radius = 4;
|
||
img_crop = timer.map(|n| {
|
||
if n.count() == 10 {
|
||
n.set_count(0);
|
||
}
|
||
let offset = n.count() as i32 * 96;
|
||
(96.px(), 84.px()).at(offset.px(), 0.px())
|
||
});
|
||
},
|
||
]
|
||
}
|
||
}
|
||
|
||
fn large_image() -> impl UiNode {
|
||
Button! {
|
||
child = Text!("Large Image (205MB download)");
|
||
on_click = hn!(|_| {
|
||
WINDOWS.open(async {
|
||
let mouse_pan = var(false);
|
||
let mode = var(ScrollMode::NONE);
|
||
ImgWindow! {
|
||
title = "Wikimedia - Starry Night - 30,000 × 23,756 pixels, file size: 205.1 MB, decoded: 2.8 GB, downscale to fit 8,000 × 8,000";
|
||
child_align = Align::FILL;
|
||
child = Scroll! {
|
||
mode = mode.clone();
|
||
mouse_pan = mouse_pan.clone();
|
||
child = Image! {
|
||
source = "https://upload.wikimedia.org/wikipedia/commons/e/ea/Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg";
|
||
img_limits = Some(ImageLimits::none().with_max_encoded_len(300.megabytes()).with_max_decoded_len(3.gigabytes()));
|
||
img_downscale = ImageDownscale::from(layout::Px(8000));
|
||
|
||
on_error = hn!(|args: &ImgErrorArgs| {
|
||
tracing::error!(target: "unexpected", "{}", args.error);
|
||
});
|
||
on_load = hn!(|_| {
|
||
mode.set(ScrollMode::ZOOM);
|
||
mouse_pan.set(true);
|
||
});
|
||
|
||
img_loading_fn = wgt_fn!(|_| {
|
||
// thumbnail
|
||
Stack! {
|
||
children = ui_vec![
|
||
Image! {
|
||
source = "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ea/Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg/757px-Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg";
|
||
},
|
||
loading(),
|
||
];
|
||
}
|
||
});
|
||
};
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
fn panorama_image() -> impl UiNode {
|
||
Button! {
|
||
child = Text!("Panorama Image (100MB download)");
|
||
on_click = hn!(|_| {
|
||
WINDOWS.open(async {
|
||
ImgWindow!(
|
||
"Wikimedia - Along the River During the Qingming Festival - 56,531 × 1,700 pixels, file size: 99.32 MB",
|
||
Scroll! {
|
||
mode = ScrollMode::HORIZONTAL;
|
||
mouse_pan = true;
|
||
child = Image! {
|
||
img_fit = ImageFit::Fill;
|
||
source = "https://upload.wikimedia.org/wikipedia/commons/2/2c/Along_the_River_During_the_Qingming_Festival_%28Qing_Court_Version%29.jpg";
|
||
img_limits = Some(ImageLimits::none().with_max_encoded_len(130.megabytes()).with_max_decoded_len(1.gigabytes()));
|
||
on_error = hn!(|args: &ImgErrorArgs| {
|
||
tracing::error!(target: "unexpected", "{}", args.error);
|
||
});
|
||
};
|
||
}
|
||
)
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
fn block_window_load_image() -> impl UiNode {
|
||
let enabled = var(true);
|
||
Button! {
|
||
child = Text!(enabled.map(|e| if *e { "Block Window Load (100MB download)" } else { "Blocking new window until image loads.." }.into()));
|
||
widget::enabled = enabled.clone();
|
||
on_click = hn!(|_| {
|
||
enabled.set(false);
|
||
WINDOWS.open(async_clmv!(enabled, {
|
||
ImgWindow! {
|
||
title = "Wikimedia - Along the River During the Qingming Festival - 56,531 × 1,700 pixels, file size: 99.32 MB";
|
||
state = WindowState::Normal;
|
||
|
||
child = Scroll! {
|
||
mouse_pan = true;
|
||
child = Image! {
|
||
// block window load until the image is ready to present or 5 minutes have elapsed.
|
||
// usually you want to set a shorter deadline, `true` converts to 1 second.
|
||
img_block_window_load = 5.minutes();
|
||
|
||
img_fit = ImageFit::Fill;
|
||
source = "https://upload.wikimedia.org/wikipedia/commons/2/2c/Along_the_River_During_the_Qingming_Festival_%28Qing_Court_Version%29.jpg";
|
||
img_limits = Some(ImageLimits::none().with_max_encoded_len(130.megabytes()).with_max_decoded_len(1.gigabytes()));
|
||
|
||
on_error = hn!(|args: &ImgErrorArgs| {
|
||
tracing::error!(target: "unexpected", "{}", args.error);
|
||
});
|
||
|
||
}
|
||
};
|
||
|
||
on_load = hn!(enabled, |_| {
|
||
enabled.set(true);
|
||
});
|
||
}
|
||
}));
|
||
});
|
||
}
|
||
}
|
||
|
||
fn repeat_image() -> impl UiNode {
|
||
Button! {
|
||
child = Text!("Repeat Image (2 MB download)");
|
||
on_click = hn!(|_| {
|
||
WINDOWS.open(async {
|
||
let show_pattern = var(false);
|
||
ImgWindow!(
|
||
"Wikimedia - Turtle seamless pattern - 1,000 × 1,000 pixels, file size: 1.49 MB",
|
||
Scroll! {
|
||
mode = ScrollMode::HORIZONTAL;
|
||
child = Image! {
|
||
img_fit = ImageFit::None;
|
||
img_repeat = true;
|
||
img_repeat_spacing = show_pattern.map(|&s| layout::Size::from(if s { 10 } else { 0 })).easing(300.ms(), easing::linear);
|
||
size = (10000, 100.pct());
|
||
source = "https://upload.wikimedia.org/wikipedia/commons/9/91/Turtle_seamless_pattern.jpg";
|
||
mouse::on_mouse_input = hn!(
|
||
show_pattern,
|
||
|args: &mouse::MouseInputArgs| {
|
||
show_pattern.set(matches!(args.state, mouse::ButtonState::Pressed));
|
||
});
|
||
on_error = hn!(|args: &ImgErrorArgs| {
|
||
tracing::error!(target: "unexpected", "{}", args.error);
|
||
});
|
||
};
|
||
}
|
||
)
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
fn open_or_paste_image() -> impl UiNode {
|
||
Button! {
|
||
child = Text!("Open or Paste Image");
|
||
on_click = hn!(|_| {
|
||
WINDOWS.open(async {
|
||
let source = var(ImageSource::flood(layout::PxSize::splat(layout::Px(1)), colors::BLACK, None));
|
||
ImgWindow! {
|
||
title = "Open or Paste Image";
|
||
|
||
app::on_open = async_hn!(source, |_| {
|
||
if let Some(img) = open_dialog().await {
|
||
source.set(img);
|
||
}
|
||
});
|
||
clipboard::on_paste = hn!(source, |_| {
|
||
if let Some(img) = clipboard::CLIPBOARD.image().ok().flatten() {
|
||
source.set(img);
|
||
}
|
||
});
|
||
|
||
child_align = Align::FILL;
|
||
child = {
|
||
use layout::PxSize;
|
||
let img_size = getter_var::<PxSize>();
|
||
let img_wgt_size = getter_var::<PxSize>();
|
||
let menu_wgt_size = getter_var::<PxSize>();
|
||
let show_menu = merge_var!(img_size.clone(), img_wgt_size.clone(), menu_wgt_size.clone(), |img, wgt, menu| {
|
||
img.height < wgt.height - menu.height
|
||
});
|
||
Stack!(ui_vec![
|
||
Image! {
|
||
img_fit = ImageFit::ScaleDown;
|
||
source;
|
||
get_img_layout_size = img_size;
|
||
layout::actual_size_px = img_wgt_size;
|
||
on_error = hn!(|args: &ImgErrorArgs| {
|
||
tracing::error!(target: "unexpected", "{}", args.error);
|
||
});
|
||
},
|
||
Stack! {
|
||
children = {
|
||
let cmd_btn = |cmd: zng::event::Command| {
|
||
let cmd = cmd.scoped(WINDOW.id());
|
||
Button! {
|
||
padding = (2, 5);
|
||
child_left = widget::node::presenter((), cmd.icon()), 0;
|
||
child = Text!(cmd.name_with_shortcut());
|
||
cmd;
|
||
}
|
||
};
|
||
ui_vec![
|
||
cmd_btn(app::OPEN_CMD.scoped(WINDOW.id())),
|
||
cmd_btn(clipboard::PASTE_CMD.scoped(WINDOW.id())),
|
||
]
|
||
};
|
||
|
||
layout::actual_size_px = menu_wgt_size;
|
||
|
||
align = Align::TOP;
|
||
direction = StackDirection::left_to_right();
|
||
spacing = 5;
|
||
margin = 5;
|
||
|
||
#[easing(200.ms())]
|
||
color::filter::opacity = 10.pct();
|
||
when *#gesture::is_hovered || *#{show_menu} {
|
||
color::filter::opacity = 100.pct();
|
||
}
|
||
}
|
||
])
|
||
};
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
async fn open_dialog() -> Option<PathBuf> {
|
||
use window::native_dialog::*;
|
||
|
||
let mut dlg = FileDialog {
|
||
title: "Open Image".into(),
|
||
kind: FileDialogKind::OpenFile,
|
||
..Default::default()
|
||
};
|
||
dlg.push_filter("Image Files", &IMAGES.available_decoders())
|
||
.push_filter("All Files", &["*"]);
|
||
|
||
let r = WINDOWS.native_file_dialog(WINDOW.id(), dlg).wait_rsp().await;
|
||
match r {
|
||
FileDialogResponse::Selected(mut s) => s.pop(),
|
||
FileDialogResponse::Cancel => None,
|
||
FileDialogResponse::Error(e) => {
|
||
tracing::error!("{e:?}");
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
fn img_cache_mode(req: &task::http::Request) -> http::CacheMode {
|
||
if let Some(a) = req.uri().authority() {
|
||
if a.host().contains("wikimedia.org") {
|
||
// Wikimedia not configured for caching.
|
||
return http::CacheMode::Permanent;
|
||
}
|
||
}
|
||
http::CacheMode::default()
|
||
}
|
||
|
||
fn center_viewport(msg: impl UiNode) -> impl UiNode {
|
||
Container! {
|
||
// center the message on the scroll viewport:
|
||
//
|
||
// the large images can take a moment to decode in debug builds, but the size
|
||
// is already known after read, so the "loading.." message ends-up off-screen
|
||
// because it is centered on the image.
|
||
layout::x = merge_var!(SCROLL.horizontal_offset(), SCROLL.zoom_scale(), |&h, &s| h.0.fct_l() - 1.vw() / s * h);
|
||
layout::y = merge_var!(SCROLL.vertical_offset(), SCROLL.zoom_scale(), |&v, &s| v.0.fct_l() - 1.vh() / s * v);
|
||
layout::scale = SCROLL.zoom_scale().map(|&fct| 1.fct() / fct);
|
||
layout::transform_origin = 0;
|
||
widget::auto_hide = false;
|
||
layout::max_size = (1.vw(), 1.vh());
|
||
|
||
child_align = Align::CENTER;
|
||
child = msg;
|
||
}
|
||
}
|
||
|
||
#[zng::prelude_wgt::widget($crate::ImgWindow {
|
||
($title:expr, $child:expr $(,)?) => {
|
||
title = $title;
|
||
child = $child;
|
||
}
|
||
})]
|
||
pub struct ImgWindow(Window);
|
||
impl ImgWindow {
|
||
fn widget_intrinsic(&mut self) {
|
||
zng::prelude_wgt::widget_set! {
|
||
self;
|
||
wr::renderer_debug = {
|
||
// wr::DebugFlags::TEXTURE_CACHE_DBG | wr::DebugFlags::TEXTURE_CACHE_DBG_CLEAR_EVICTED
|
||
wr::RendererDebug::disabled()
|
||
};
|
||
|
||
// render_mode = RenderMode::Software;
|
||
|
||
child_align = Align::CENTER;
|
||
|
||
|
||
state = WindowState::Maximized;
|
||
size = (1140, 770);// restore size
|
||
|
||
icon = "examples/res/image/zng-logo.png";
|
||
widget::background = Checkerboard!();
|
||
|
||
color_scheme = color::ColorScheme::Dark;
|
||
|
||
// content shown by all images when loading.
|
||
img_loading_fn = wgt_fn!(|_| loading());
|
||
|
||
// content shown by all images that failed to load.
|
||
img_error_fn = wgt_fn!(|args: ImgErrorArgs| {
|
||
center_viewport(Text! {
|
||
txt = args.error;
|
||
margin = 8;
|
||
align = Align::CENTER;
|
||
font_color = error_color();
|
||
drop_shadow = {
|
||
offset: (0, 0),
|
||
blur_radius: 4,
|
||
color: error_color().darken(5.pct()),
|
||
};
|
||
})
|
||
});
|
||
|
||
// button color
|
||
button::base_colors = (rgb(0, 0, 40), rgb(0, 0, 255 - 40));
|
||
}
|
||
}
|
||
}
|
||
fn loading_color() -> color::Rgba {
|
||
web_colors::LIGHT_GRAY
|
||
}
|
||
|
||
fn error_color() -> color::Rgba {
|
||
colors::RED
|
||
}
|
||
|
||
pub fn loading() -> impl UiNode {
|
||
let mut dots_count = 3;
|
||
let msg = timer::TIMERS.interval(300.ms(), false).map(move |_| {
|
||
dots_count += 1;
|
||
if dots_count == 8 {
|
||
dots_count = 0;
|
||
}
|
||
formatx!("loading{:.^dots_count$}", "")
|
||
});
|
||
|
||
center_viewport(Text! {
|
||
txt = msg;
|
||
font_color = loading_color();
|
||
margin = 8;
|
||
layout::width = 80;
|
||
font_style = FontStyle::Italic;
|
||
drop_shadow = {
|
||
offset: (0, 0),
|
||
blur_radius: 4,
|
||
color: loading_color().darken(5.pct()),
|
||
};
|
||
})
|
||
}
|
||
|
||
fn section(title: impl IntoVar<Txt>, children: impl UiNodeList) -> impl UiNode {
|
||
Stack! {
|
||
direction = StackDirection::top_to_bottom();
|
||
spacing = 5;
|
||
children_align = Align::TOP_LEFT;
|
||
|
||
children = ui_vec![
|
||
self::title(title),
|
||
Stack! {
|
||
direction = StackDirection::top_to_bottom();
|
||
spacing = 5;
|
||
children_align = Align::TOP_LEFT;
|
||
|
||
children;
|
||
}
|
||
]
|
||
}
|
||
}
|
||
|
||
fn title(txt: impl IntoVar<Txt>) -> impl UiNode {
|
||
Text! {
|
||
txt;
|
||
font_size = 20;
|
||
background_color = colors::BLACK;
|
||
padding = (5, 10);
|
||
}
|
||
}
|
||
|
||
fn sub_title(txt: impl IntoVar<Txt>) -> impl UiNode {
|
||
Text! {
|
||
txt;
|
||
|
||
font_size = 14;
|
||
|
||
background_color = colors::BLACK;
|
||
padding = (2, 5);
|
||
}
|
||
}
|