Files
grim/src/gui/views/views.rs
T

722 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2023 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use lazy_static::lazy_static;
use std::sync::atomic::{AtomicI32, Ordering};
use egui::emath::GuiRounding;
use egui::epaint::text::TextWrapping;
use egui::epaint::{Color32, FontId, PathShape, PathStroke, RectShape, Stroke};
use egui::load::SizedTexture;
use egui::os::OperatingSystem;
use egui::text::{LayoutJob, TextFormat};
use egui::{lerp, Button, CornerRadius, CursorIcon, Rect, Response, Rgba, RichText, Sense, SizeHint, Spinner, StrokeKind, TextureHandle, TextureOptions, UiBuilder, Widget};
use egui_extras::image::load_svg_bytes_with_size;
use crate::gui::icons::{CHECK_FAT, CHECK_SQUARE, SQUARE};
use crate::gui::views::types::LinePosition;
use crate::gui::Colors;
use crate::AppConfig;
pub struct View;
impl View {
/// Check if current platform is desktop
pub fn is_desktop() -> bool {
let os = OperatingSystem::from_target_os();
os != OperatingSystem::Android && os != OperatingSystem::IOS
}
/// Format timestamp in seconds with local UTC offset.
pub fn format_time(ts: i64) -> String {
let utc_offset = chrono::Local::now().offset().local_minus_utc();
let utc_time = ts + utc_offset as i64;
let tx_time = chrono::DateTime::from_timestamp(utc_time, 0).unwrap();
tx_time.format("%d/%m/%Y %H:%M:%S").to_string()
}
/// Get default stroke around views.
pub fn default_stroke() -> Stroke {
Stroke { width: 1.0, color: Colors::stroke() }
}
/// Get default stroke around item buttons.
pub fn item_stroke() -> Stroke {
Stroke { width: 1.0, color: Colors::item_stroke() }
}
/// Get stroke for hovered items and buttons.
pub fn hover_stroke() -> Stroke {
Stroke { width: 1.0, color: Colors::item_hover() }
}
/// Draw content with maximum width value.
pub fn max_width_ui(ui: &mut egui::Ui,
max_width: f32,
add_content: impl FnOnce(&mut egui::Ui)) {
// Setup content width.
let mut width = ui.available_width();
if width == 0.0 {
return;
}
let mut rect = ui.available_rect_before_wrap();
width = f32::min(width, max_width);
rect.set_width(width);
// Draw content.
ui.vertical_centered(|ui| {
ui.allocate_ui(rect.size(), |ui| {
(add_content)(ui);
});
});
}
/// Get width and height of app window.
pub fn window_size(ctx: &egui::Context) -> (f32, f32) {
let rect = ctx.content_rect();
(rect.width(), rect.height())
}
/// Calculate margin for far left view based on display insets (cutouts).
pub fn far_left_inset_margin(ui: &mut egui::Ui) -> f32 {
if ui.available_rect_before_wrap().min.x == 0.0 {
Self::get_left_inset()
} else {
0.0
}
}
/// Calculate margin for far left view based on display insets (cutouts).
pub fn far_right_inset_margin(ui: &mut egui::Ui) -> f32 {
let container_width = ui.available_rect_before_wrap().max.x as i32;
let window_size = Self::window_size(ui.ctx());
let display_width = window_size.0 as i32;
// Means end of the screen.
if container_width == display_width {
Self::get_right_inset()
} else {
0.0
}
}
/// Content padding for current platform.
pub fn content_padding() -> f32 {
if View::is_desktop() {
4.0
} else {
8.0
}
}
/// Cut long text with ﹍ character.
fn ellipsize(text: String, size: f32, color: Color32) -> LayoutJob {
let mut job = LayoutJob::single_section(text, TextFormat {
font_id: FontId::proportional(size), color, ..Default::default()
});
job.wrap = TextWrapping {
max_rows: 1,
break_anywhere: true,
overflow_character: Option::from('﹍'),
..Default::default()
};
job
}
/// Draw ellipsized text.
pub fn ellipsize_text(ui: &mut egui::Ui, text: String, size: f32, color: Color32) {
ui.label(Self::ellipsize(text, size, color));
}
/// Draw animated ellipsized text.
pub fn animate_text(ui: &mut egui::Ui, text: String, size: f32, color: Color32, animate: bool) {
// Setup text color animation if needed.
let (dark, bright) = (0.3, 1.0);
let color_factor = if animate {
lerp(dark..=bright, ui.input(|i| i.time).cos().abs()) as f32
} else {
bright as f32
};
// Draw subtitle text.
let sub_color_rgba = Rgba::from(color) * color_factor;
let sub_color = Color32::from(sub_color_rgba);
View::ellipsize_text(ui, text, size, sub_color);
// Repaint delay based on animation status.
if animate {
ui.ctx().request_repaint();
}
}
/// Draw horizontally centered subtitle with space below.
pub fn sub_title(ui: &mut egui::Ui, text: String) {
ui.vertical_centered_justified(|ui| {
ui.label(RichText::new(text.to_uppercase()).size(16.0).color(Colors::text(false)));
});
ui.add_space(4.0);
}
/// Draw big size title button.
pub fn title_button_big(ui: &mut egui::Ui, icon: &str, action: impl FnOnce(&mut egui::Ui)) {
Self::title_button(ui, 22.0, icon, action);
}
/// Draw small size title button.
pub fn title_button_small(ui: &mut egui::Ui, icon: &str, action: impl FnOnce(&mut egui::Ui)) {
Self::title_button(ui, 16.0, icon, action);
}
/// Draw title button with transparent background color, contains only icon.
fn title_button(ui: &mut egui::Ui, size: f32, icon: &str, action: impl FnOnce(&mut egui::Ui)) {
ui.scope(|ui| {
// Setup padding for title buttons.
if !View::is_desktop() {
ui.style_mut().spacing.button_padding = egui::vec2(20.0, 8.0);
}
// Disable strokes.
ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE;
ui.style_mut().visuals.widgets.active.bg_stroke = Stroke::NONE;
ui.style_mut().visuals.widgets.active.corner_radius = CornerRadius::default();
ui.style_mut().visuals.widgets.active.expansion = 0.0;
// Setup text.
let wt = RichText::new(icon.to_string()).size(size).color(Colors::title(true));
// Draw button.
let br = Button::new(wt)
.sense(if View::is_desktop() {
Sense::click()
} else {
Sense::click_and_drag()
})
.fill(Colors::TRANSPARENT)
.ui(ui)
.on_hover_cursor(CursorIcon::PointingHand);
br.surrender_focus();
if br.clicked() || (!View::is_desktop() && br.drag_stopped()) {
action(ui);
}
});
}
/// Padding for tab items.
pub const TAB_ITEMS_PADDING: f32 = 5.0;
/// Tab button with white background fill color, contains only icon.
pub fn tab_button(ui: &mut egui::Ui,
icon: &str,
color: Option<Color32>,
selected: Option<bool>,
action: impl FnOnce(&mut egui::Ui)) {
ui.scope(|ui| {
let text_color = if let Some(c) = color {
if selected.is_none() {
Colors::inactive_text()
} else {
c
}
} else {
if let Some(active) = selected {
match active {
true => Colors::gray(),
false => Colors::item_button_text()
}
} else {
Colors::inactive_text()
}
};
let mut button = Button::new(RichText::new(icon).size(22.0).color(text_color));
let active_not_selected = selected.is_some() && !selected.unwrap();
if active_not_selected {
// Disable expansion on click/hover.
ui.style_mut().visuals.widgets.hovered.expansion = 0.0;
ui.style_mut().visuals.widgets.active.expansion = 0.0;
// Setup fill colors.
ui.visuals_mut().widgets.inactive.weak_bg_fill = Colors::white_or_black(false);
ui.visuals_mut().widgets.hovered.weak_bg_fill = Colors::fill_lite();
ui.visuals_mut().widgets.active.weak_bg_fill = Colors::fill();
// Setup stroke colors.
ui.visuals_mut().widgets.inactive.bg_stroke = Self::default_stroke();
ui.visuals_mut().widgets.hovered.bg_stroke = Self::hover_stroke();
ui.visuals_mut().widgets.active.bg_stroke = Self::item_stroke();
} else {
button = button.fill(Colors::fill()).stroke(Stroke::NONE);
}
// Setup paddings for tab buttons.
if Self::is_desktop() {
ui.style_mut().spacing.button_padding = egui::vec2(10.0, 4.0);
} else {
ui.style_mut().spacing.button_padding = egui::vec2(14.0, 8.0);
};
// Setup pointer style.
let br = if active_not_selected {
button.ui(ui).on_hover_cursor(CursorIcon::PointingHand)
} else {
button.ui(ui)
};
br.surrender_focus();
if br.clicked() && active_not_selected {
action(ui);
}
});
}
/// Draw [`Button`] with specified background fill and text color.
fn button_resp(ui: &mut egui::Ui, text: String, text_color: Color32, bg: Color32) -> Response {
let button_text = Self::ellipsize(text.to_uppercase(), 17.0, text_color);
Button::new(button_text)
.stroke(Self::default_stroke())
.fill(bg)
.ui(ui)
.on_hover_cursor(CursorIcon::PointingHand)
}
/// Draw [`Button`] with specified background fill color and default text color.
pub fn button(ui: &mut egui::Ui, text: String, fill: Color32, action: impl FnOnce()) {
let br = Self::button_resp(ui, text, Colors::text_button(), fill);
if br.clicked() {
action();
}
}
/// Draw [`Button`] with specified background fill color and text color.
pub fn colored_text_button(ui: &mut egui::Ui,
text: String,
text_color: Color32,
fill: Color32,
action: impl FnOnce()) {
let br = Self::button_resp(ui, text, text_color, fill);
if br.clicked() {
action();
}
}
/// Draw [`Button`] with specified background fill color and text color.
pub fn colored_text_button_ui(ui: &mut egui::Ui,
text: String,
text_color: Color32,
fill: Color32,
action: impl FnOnce(&mut egui::Ui)) {
let br = Self::button_resp(ui, text, text_color, fill);
if br.clicked() {
action(ui);
}
}
/// Draw gold action [`Button`].
pub fn action_button(ui: &mut egui::Ui,
text: String, action: impl FnOnce()) {
Self::colored_text_button(ui, text, Colors::title(true), Colors::gold(), action);
}
/// Draw [`Button`] with specified background fill color and ui at callback.
pub fn button_ui(ui: &mut egui::Ui,
text: String,
fill: Color32,
action: impl FnOnce(&mut egui::Ui)) {
let button_text = Self::ellipsize(text.to_uppercase(), 17.0, Colors::text_button());
let br = Button::new(button_text)
.stroke(Self::default_stroke())
.fill(fill)
.ui(ui)
.on_hover_cursor(CursorIcon::PointingHand);
if br.clicked() {
action(ui);
}
}
/// Draw list item [`Button`] with provided rounding.
pub fn item_button(ui: &mut egui::Ui,
rounding: CornerRadius,
text: &'static str,
color: Option<Color32>,
action: impl FnOnce()) {
// Setup button size.
let mut rect = ui.available_rect_before_wrap();
rect.set_width(42.0);
let button_size = rect.size();
ui.scope(|ui| {
// Setup padding for item buttons.
let padding = if Self::is_desktop() {
14.0
} else {
18.0
};
ui.style_mut().spacing.button_padding = egui::vec2(padding, 0.0);
// Disable expansion on click/hover.
ui.style_mut().visuals.widgets.hovered.expansion = 0.0;
ui.style_mut().visuals.widgets.active.expansion = 0.0;
// Setup fill colors.
ui.visuals_mut().widgets.inactive.weak_bg_fill = Colors::white_or_black(false);
ui.visuals_mut().widgets.hovered.weak_bg_fill = Colors::fill_lite();
ui.visuals_mut().widgets.active.weak_bg_fill = Colors::fill();
// Disable strokes.
ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::NONE;
ui.visuals_mut().widgets.hovered.bg_stroke = Stroke::NONE;
ui.visuals_mut().widgets.active.bg_stroke = Stroke::NONE;
// Setup button text color.
let text_color = if let Some(c) = color { c } else { Colors::item_button_text() };
// Show button.
let br = Button::new(RichText::new(text).size(20.0).color(text_color))
.corner_radius(rounding)
.min_size(button_size)
.ui(ui)
.on_hover_cursor(CursorIcon::PointingHand);
br.surrender_focus();
if br.clicked() {
action();
}
// Draw stroke.
let r = {
let mut r = ui.available_rect_before_wrap();
r.min = br.rect.min;
r.min.x += 0.5;
r
};
Self::line(ui, LinePosition::LEFT, &r, Colors::item_stroke());
});
}
/// Calculate item background/button rounding based on item index.
pub fn item_rounding(index: usize, len: usize, is_button: bool) -> CornerRadius {
let corners = if is_button {
if len == 1 {
[false, true, true, false]
} else if index == 0 {
[false, true, false, false]
} else if index == len - 1 {
[false, false, true, false]
} else {
[false, false, false, false]
}
} else {
if len == 1 {
[true, true, true, true]
} else if index == 0 {
[true, true, false, false]
} else if index == len - 1 {
[false, false, true, true]
} else {
[false, false, false, false]
}
};
CornerRadius {
nw: if corners[0] { 8.0 as u8 } else { 0.0 as u8 },
ne: if corners[1] { 8.0 as u8 } else { 0.0 as u8 },
sw: if corners[3] { 8.0 as u8 } else { 0.0 as u8 },
se: if corners[2] { 8.0 as u8 } else { 0.0 as u8 },
}
}
/// Draw selected item check.
pub fn selected_item_check(ui: &mut egui::Ui) {
let padding = if View::is_desktop() {
14.0
} else {
18.0
};
ui.add_space(padding);
ui.label(RichText::new(CHECK_FAT).size(20.0).color(Colors::green()));
ui.add_space(padding);
}
/// Draw rounded box with some value and label in the middle,
/// where is r = (top_left, top_right, bottom_left, bottom_right).
/// | VALUE |
/// | label |
pub fn label_box(ui: &mut egui::Ui, text: String, label: String, r: [bool; 4]) {
let rect = ui.available_rect_before_wrap();
// Create background shape.
let mut bg_shape = RectShape::new(rect, CornerRadius {
nw: if r[0] { 8.0 as u8 } else { 0.0 as u8 },
ne: if r[1] { 8.0 as u8 } else { 0.0 as u8 },
sw: if r[2] { 8.0 as u8 } else { 0.0 as u8 },
se: if r[3] { 8.0 as u8 } else { 0.0 as u8 },
}, Colors::fill_lite(), Self::item_stroke(), StrokeKind::Outside);
let bg_idx = ui.painter().add(bg_shape.clone());
// Draw box content.
let content_resp = ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| {
ui.vertical_centered_justified(|ui| {
ui.add_space(4.0);
ui.scope(|ui| {
// Correct vertical spacing between items.
ui.style_mut().spacing.item_spacing.y = -3.0;
// Draw box value.
let mut job = LayoutJob::single_section(text, TextFormat {
font_id: FontId::proportional(17.0),
color: Colors::white_or_black(true),
..Default::default()
});
job.wrap = TextWrapping {
max_rows: 1,
break_anywhere: true,
overflow_character: Option::from('﹍'),
..Default::default()
};
ui.label(job);
// Draw box label.
ui.label(RichText::new(label).color(Colors::gray()).size(15.0));
});
ui.add_space(2.0);
});
}).response;
// Setup background shape size.
bg_shape.rect = content_resp.rect;
ui.painter().set(bg_idx, bg_shape);
}
/// Draw content in the center of current layout with specified width and height.
pub fn center_content(ui: &mut egui::Ui, height: f32, content: impl FnOnce(&mut egui::Ui)) {
ui.vertical_centered(|ui| {
let mut rect = ui.available_rect_before_wrap();
let side_margin = 28.0;
rect.min += egui::emath::vec2(side_margin, ui.available_height() / 2.0 - height / 2.0);
rect.max -= egui::emath::vec2(side_margin, 0.0);
ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| {
content(ui);
});
});
}
/// Draw loading spinner.
pub fn loading_spinner(ui: &mut egui::Ui, size: f32) {
Spinner::new().size(size).color(Colors::gold()).ui(ui);
}
/// Size of big loading spinner.
pub const BIG_SPINNER_SIZE: f32 = 104.0;
/// Draw big gold loading spinner.
pub fn big_loading_spinner(ui: &mut egui::Ui) {
View::loading_spinner(ui, View::BIG_SPINNER_SIZE);
}
/// Size of big loading spinner.
pub const SMALL_SPINNER_SIZE: f32 = 32.0;
/// Draw small gold loading spinner.
pub fn small_loading_spinner(ui: &mut egui::Ui) {
View::loading_spinner(ui, View::SMALL_SPINNER_SIZE);
}
/// Draw the button that looks like checkbox with callback on check.
pub fn checkbox(ui: &mut egui::Ui, checked: bool, text: String, action: impl FnOnce()) {
let (text_value, color) = match checked {
true => (format!("{} {}", CHECK_SQUARE, text), Colors::text_button()),
false => (format!("{} {}", SQUARE, text), Colors::checkbox())
};
let br = Button::new(RichText::new(text_value).size(17.0).color(color))
.frame(false)
.stroke(Stroke::NONE)
.fill(Colors::TRANSPARENT)
.ui(ui)
.on_hover_cursor(CursorIcon::PointingHand);
if br.clicked() {
action();
}
}
/// Show a [`RadioButton`]. It is selected if `*current_value == selected_value`.
/// If clicked, `selected_value` is assigned to `*current_value`.
pub fn radio_value<T: PartialEq>(ui: &mut egui::Ui, current: &mut T, value: T, text: String) {
ui.scope(|ui| {
// Setup background color.
ui.visuals_mut().widgets.inactive.bg_fill = Colors::fill_deep();
// Draw radio button.
let mut response = ui.radio(*current == value, text)
.on_hover_cursor(CursorIcon::PointingHand);
if response.clicked() && *current != value {
*current = value;
response.mark_changed();
}
});
}
/// Draw horizontal line.
pub fn horizontal_line(ui: &mut egui::Ui, color: Color32) {
let line_size = egui::Vec2::new(ui.available_width(), 1.0);
let (line_rect, _) = ui.allocate_exact_size(line_size, Sense::hover());
let painter = ui.painter();
painter.hline(line_rect.x_range(),
line_rect.center().y.round_to_pixels(painter.pixels_per_point()),
Stroke { width: 1.0, color });
}
/// Draw line for panel content.
pub fn line(ui: &mut egui::Ui, pos: LinePosition, rect: &Rect, color: Color32) {
let points = match pos {
LinePosition::RIGHT => {
vec![{
let mut r = rect.clone();
r.min.x = r.max.x;
r.min
}, rect.max]
}
LinePosition::BOTTOM => {
vec![{
let mut r = rect.clone();
r.min.y = r.max.y;
r.min
}, rect.max]
}
LinePosition::LEFT => {
vec![rect.min, {
let mut r = rect.clone();
r.max.x = r.min.x;
r.max
}]
}
LinePosition::TOP => {
vec![rect.min, {
let mut r = rect.clone();
r.max.y = r.min.y;
r.max
}]
}
};
let stroke = PathShape {
points,
closed: false,
fill: Default::default(),
stroke: PathStroke::new(1.0, color),
};
ui.painter().add(stroke);
}
/// Draw SVG image from provided data with optional provided size.
pub fn svg_image(ui: &mut egui::Ui,
name: &str,
svg: &[u8],
size: SizeHint) -> TextureHandle {
let color_img = load_svg_bytes_with_size(svg, size, &usvg::Options::default()).unwrap();
// Create image texture.
let texture_handle = ui.ctx().load_texture(name,
color_img.clone(),
TextureOptions::default());
let img_size = egui::emath::vec2(color_img.width() as f32,
color_img.height() as f32);
let sized_img = SizedTexture::new(texture_handle.id(), img_size);
// Add image to content.
ui.add(egui::Image::from_texture(sized_img)
.max_height(ui.available_width())
.fit_to_original_size(1.0));
texture_handle
}
/// Draw application logo image with name and version.
pub fn app_logo_name_version(ui: &mut egui::Ui) {
ui.add_space(-1.0);
let logo = if AppConfig::dark_theme().unwrap_or(false) {
egui::include_image!("../../../img/logo_light.png")
} else {
egui::include_image!("../../../img/logo.png")
};
// Show application logo and name.
ui.scope(|ui| {
ui.set_opacity(0.9);
egui::Image::new(logo).fit_to_exact_size(egui::vec2(182.0, 182.0)).ui(ui);
});
ui.add_space(-11.0);
ui.label(RichText::new("GRIM")
.size(24.0)
.color(Colors::white_or_black(true))
);
ui.add_space(-2.0);
ui.label(RichText::new(crate::VERSION)
.size(16.0)
.color(Colors::title(false))
);
}
/// Draw semi-transparent cover at specified area.
pub fn content_cover_ui(ui: &mut egui::Ui,
rect: Rect,
id: impl std::hash::Hash,
mut on_click: impl FnMut()) {
let resp = ui.interact(rect, egui::Id::new(id), Sense::click_and_drag());
if resp.clicked() || resp.dragged() {
on_click();
}
let shape = RectShape::filled(resp.rect,
CornerRadius::ZERO,
Colors::semi_transparent().gamma_multiply(0.7));
ui.painter().add(shape);
}
/// Get top display inset (cutout) size.
pub fn get_top_inset() -> f32 {
TOP_DISPLAY_INSET.load(Ordering::Relaxed) as f32
}
/// Get right display inset (cutout) size.
pub fn get_right_inset() -> f32 {
RIGHT_DISPLAY_INSET.load(Ordering::Relaxed) as f32
}
/// Get bottom display inset (cutout) size.
pub fn get_bottom_inset() -> f32 {
BOTTOM_DISPLAY_INSET.load(Ordering::Relaxed) as f32
}
/// Get left display inset (cutout) size.
pub fn get_left_inset() -> f32 {
LEFT_DISPLAY_INSET.load(Ordering::Relaxed) as f32
}
}
lazy_static! {
static ref TOP_DISPLAY_INSET: AtomicI32 = AtomicI32::new(0);
static ref RIGHT_DISPLAY_INSET: AtomicI32 = AtomicI32::new(0);
static ref BOTTOM_DISPLAY_INSET: AtomicI32 = AtomicI32::new(0);
static ref LEFT_DISPLAY_INSET: AtomicI32 = AtomicI32::new(0);
}
#[allow(dead_code)]
#[cfg(target_os = "android")]
#[allow(non_snake_case)]
#[no_mangle]
/// Callback from Java code to update display insets (cutouts).
pub extern "C" fn Java_mw_gri_android_MainActivity_onDisplayInsets(
_env: jni::JNIEnv,
_class: jni::objects::JObject,
cutouts: jni::sys::jarray
) {
use jni::objects::{JObject, JPrimitiveArray};
let mut array: [i32; 4] = [0; 4];
unsafe {
let j_obj = JObject::from_raw(cutouts);
let j_arr = JPrimitiveArray::from(j_obj);
_env.get_int_array_region(j_arr, 0, array.as_mut()).unwrap();
}
TOP_DISPLAY_INSET.store(array[0], Ordering::Relaxed);
RIGHT_DISPLAY_INSET.store(array[1], Ordering::Relaxed);
BOTTOM_DISPLAY_INSET.store(array[2], Ordering::Relaxed);
LEFT_DISPLAY_INSET.store(array[3], Ordering::Relaxed);
}