1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2025-07-30 18:38:27 +00:00

feat(eckhart): introduce gradient module

[no changelog]
This commit is contained in:
obrusvit 2025-07-11 14:53:24 +02:00 committed by Vít Obrusník
parent 0c393b3dcd
commit b55bf90564
18 changed files with 425 additions and 199 deletions

View File

@ -9,13 +9,12 @@ use crate::{
display::{toif::Icon, Color, Font},
event::TouchEvent,
geometry::{Alignment, Alignment2D, Insets, Offset, Point, Rect},
lerp::Lerp,
shape::{self, Renderer},
util::split_two_lines,
},
};
use super::super::theme;
use super::super::theme::{self, Gradient};
pub enum ButtonMsg {
Pressed,
@ -26,7 +25,7 @@ pub enum ButtonMsg {
enum RadiusOrGradient {
Radius(u8),
Gradient,
Gradient(Gradient),
None,
}
@ -170,11 +169,15 @@ impl Button {
self
}
pub fn with_gradient(mut self) -> Self {
self.radius_or_gradient = RadiusOrGradient::Gradient;
pub fn with_gradient(mut self, gradient: Gradient) -> Self {
self.radius_or_gradient = RadiusOrGradient::Gradient(gradient);
self
}
pub fn has_gradient(&self) -> bool {
matches!(self.radius_or_gradient, RadiusOrGradient::Gradient(_))
}
pub fn enable_if(&mut self, ctx: &mut EventCtx, enabled: bool) {
if enabled {
self.enable(ctx);
@ -322,44 +325,6 @@ impl Button {
}
}
fn render_gradient_bar<'s>(&self, target: &mut impl Renderer<'s>, style: &ButtonStyle) {
let height = self.area.height();
let half_width = (self.area.width() / 2) as f32;
let x_mid = self.area.center().x;
// Layer 1: Horizontal Gradient (Overall intensity: 100%)
// Stops: 21%, 100%
// Opacity: 100%, 20%
for y in self.area.y0..self.area.y1 {
let factor = (y - self.area.y0) as f32 / height as f32;
let slice = Rect::new(Point::new(self.area.x0, y), Point::new(self.area.x1, y + 1));
let factor_grad = ((factor - 0.21) / (1.00 - 0.21)).clamp(0.0, 1.0);
let alpha = u8::lerp(u8::MAX, 51, factor_grad);
shape::Bar::new(slice)
.with_bg(style.button_color)
.with_alpha(alpha)
.render(target);
}
// Layer 2: Vertical Gradient (Overall intensity: 100%)
// distance from mid
for x in self.area.x0..self.area.x1 {
let slice = Rect::new(Point::new(x, self.area.y0), Point::new(x + 1, self.area.y1));
let dist_from_mid = (x - x_mid).abs() as f32 / half_width;
let alpha = u8::lerp(u8::MIN, u8::MAX, dist_from_mid);
shape::Bar::new(slice)
.with_bg(theme::BG)
.with_alpha(alpha)
.render(target);
}
// Layer 3: Black overlay (Overall intensity: 20%)
shape::Bar::new(self.area)
.with_bg(theme::BG)
.with_alpha(51)
.render(target);
}
pub fn render_background<'s>(
&self,
target: &mut impl Renderer<'s>,
@ -377,8 +342,8 @@ impl Button {
.render(target);
}
// Gradient bar is rendered only in `normal` state, not `active` or `disabled`
RadiusOrGradient::Gradient if self.state.is_normal() => {
self.render_gradient_bar(target, style);
RadiusOrGradient::Gradient(gradient) if self.state.is_normal() => {
gradient.render(target, self.area, 1);
}
_ => {
shape::Bar::new(self.area)

View File

@ -72,12 +72,13 @@ impl ActionBar {
/// component automatically shows navigation up/down buttons for
/// paginated content.
pub fn new_single(button: Button) -> Self {
let mut right_button = button
.with_expanded_touch_area(Self::BUTTON_EXPAND_TOUCH)
.with_gradient();
let mut right_button = button.with_expanded_touch_area(Self::BUTTON_EXPAND_TOUCH);
if right_button.stylesheet() == &theme::button_default() {
right_button = right_button.styled(theme::firmware::button_actionbar_right_default());
};
if !right_button.has_gradient() {
right_button = right_button.with_gradient(theme::Gradient::DefaultGrey);
}
Self::new(Mode::Single, None, Some(right_button), None)
}
@ -104,8 +105,10 @@ impl ActionBar {
.with_content_offset(Self::BUTTON_CONTENT_OFFSET);
let mut right_button = right
.with_expanded_touch_area(Self::BUTTON_EXPAND_TOUCH)
.with_content_offset(Self::BUTTON_CONTENT_OFFSET.neg())
.with_gradient();
.with_content_offset(Self::BUTTON_CONTENT_OFFSET.neg());
if !right_button.has_gradient() {
right_button = right_button.with_gradient(theme::Gradient::DefaultGrey);
}
if right_button.stylesheet() == &theme::button_default() {
right_button = right_button.styled(theme::firmware::button_actionbar_right_default());
};

View File

@ -10,9 +10,8 @@ use crate::{
ui::{
component::{text::TextStyle, Component, Event, EventCtx, Label, Never},
display::{image::ImageInfo, Color},
geometry::{Alignment, Alignment2D, Insets, Offset, Point, Rect},
geometry::{Alignment, Alignment2D, Insets, Offset, Rect},
layout::util::get_user_custom_image,
lerp::Lerp,
shape::{self, Renderer},
util::animation_disabled,
},
@ -24,7 +23,7 @@ use super::{
fonts,
},
constant::{HEIGHT, SCREEN, WIDTH},
theme::{self, firmware::button_homebar_style, TILES_GRID},
theme::{self, firmware::button_homebar_style, Gradient, TILES_GRID},
ActionBar, ActionBarMsg, Hint,
};
@ -89,13 +88,16 @@ impl Homescreen {
None => (None, None),
};
// Homebar
let (style_sheet, gradient) = button_homebar_style(notification_level);
let btn = Button::new(Self::homebar_content(bootscreen, locked))
.styled(style_sheet)
.with_gradient(gradient);
Ok(Self {
label: HomeLabel::new(label, shadow),
hint,
action_bar: ActionBar::new_single(
Button::new(Self::homebar_content(bootscreen, locked))
.styled(button_homebar_style(notification_level)),
),
action_bar: ActionBar::new_single(btn),
image,
led_color,
lockable,
@ -158,6 +160,37 @@ impl Homescreen {
}
false
}
fn render_default_hs<'s>(&self, target: &mut impl Renderer<'s>) {
// Layer 1: Base Solid Colour
shape::Bar::new(SCREEN)
.with_bg(theme::GREY_EXTRA_DARK)
.render(target);
// Layer 2: Base Gradient overlay
Gradient::HomescreenBase.render(target, SCREEN, 1);
// Layer 3: (Optional) LED lightning simulation
if let Some(led_color) = self.led_color {
let gradient_area = SCREEN.inset(Insets::bottom(theme::ACTION_BAR_HEIGHT));
Gradient::HomescreenLEDSim(led_color).render(target, gradient_area, 1);
}
// Layer 4: Tile pattern
// TODO: improve frame rate
for idx in 0..TILES_GRID.cell_count() {
let tile_area = TILES_GRID.cell(idx);
let icon = if theme::TILES_SLASH_INDICES.contains(&idx) {
theme::ICON_TILE_STRIPES_SLASH.toif
} else {
theme::ICON_TILE_STRIPES_BACKSLASH.toif
};
shape::ToifImage::new(tile_area.top_left(), icon)
.with_align(Alignment2D::TOP_LEFT)
.with_fg(theme::BLACK)
.render(target);
}
}
}
impl Drop for Homescreen {
@ -219,7 +252,7 @@ impl Component for Homescreen {
shape::JpegImage::new_image(SCREEN.top_left(), image).render(target);
}
} else {
render_default_hs(target, self.led_color);
self.render_default_hs(target);
}
self.label.render(target);
self.hint.render(target);
@ -296,91 +329,6 @@ pub fn check_homescreen_format(image: BinaryData) -> bool {
}
}
fn render_default_hs<'a>(target: &mut impl Renderer<'a>, led_color: Option<Color>) {
// Layer 1: Base Solid Colour
shape::Bar::new(SCREEN)
.with_bg(theme::GREY_EXTRA_DARK)
.render(target);
// Layer 2: Base Gradient overlay
for y in SCREEN.y0..SCREEN.y1 {
let slice = Rect::new(Point::new(SCREEN.x0, y), Point::new(SCREEN.x1, y + 1));
let factor = (y - SCREEN.y0) as f32 / SCREEN.height() as f32;
shape::Bar::new(slice)
.with_bg(theme::BG)
.with_alpha(u8::lerp(u8::MIN, u8::MAX, factor))
.render(target);
}
// Layer 3: (Optional) LED lightning simulation
if let Some(color) = led_color {
render_led_simulation(color, target);
}
// Layer 4: Tile pattern
// TODO: improve frame rate
for idx in 0..TILES_GRID.cell_count() {
let tile_area = TILES_GRID.cell(idx);
let icon = if theme::TILES_SLASH_INDICES.contains(&idx) {
theme::ICON_TILE_STRIPES_SLASH.toif
} else {
theme::ICON_TILE_STRIPES_BACKSLASH.toif
};
shape::ToifImage::new(tile_area.top_left(), icon)
.with_align(Alignment2D::TOP_LEFT)
.with_fg(theme::BLACK)
.render(target);
}
}
fn render_led_simulation<'a>(color: Color, target: &mut impl Renderer<'a>) {
const Y_MAX: i16 = SCREEN.y1 - theme::ACTION_BAR_HEIGHT;
const Y_RANGE: i16 = Y_MAX - SCREEN.y0;
const X_MID: i16 = SCREEN.x0 + SCREEN.width() / 2;
const X_HALF_WIDTH: f32 = (SCREEN.width() / 2) as f32;
// Vertical gradient (color intensity fading from bottom to top)
#[allow(clippy::reversed_empty_ranges)] // clippy fails here for T3B1 which has smaller screen
for y in SCREEN.y0..Y_MAX {
let factor = (y - SCREEN.y0) as f32 / Y_RANGE as f32;
let slice = Rect::new(Point::new(SCREEN.x0, y), Point::new(SCREEN.x1, y + 1));
// Gradient 1 (Overall intensity: 35%)
// Stops: 0%, 40%
// Opacity: 100%, 20%
let factor_grad_1 = (factor / 0.4).clamp(0.2, 1.0);
shape::Bar::new(slice)
.with_bg(color)
.with_alpha(u8::lerp(89, u8::MIN, factor_grad_1))
.render(target);
// Gradient 2 (Overall intensity: 70%)
// Stops: 2%, 63%
// Opacity: 100%, 0%
let factor_grad_2 = ((factor - 0.02) / (0.63 - 0.02)).clamp(0.0, 1.0);
let alpha = u8::lerp(179, u8::MIN, factor_grad_2);
shape::Bar::new(slice)
.with_bg(color)
.with_alpha(alpha)
.render(target);
}
// Horizontal gradient (transparency increasing toward center)
for x in SCREEN.x0..SCREEN.x1 {
const WIDTH: i16 = SCREEN.width();
let slice = Rect::new(Point::new(x, SCREEN.y0), Point::new(x + 1, Y_MAX));
// Gradient 3
// Calculate distance from center as a normalized factor (0 at center, 1 at
// edges)
let dist_from_mid = (x - X_MID).abs() as f32 / X_HALF_WIDTH;
shape::Bar::new(slice)
.with_bg(theme::BG)
.with_alpha(u8::lerp(u8::MIN, u8::MAX, dist_from_mid))
.render(target);
}
}
fn get_homescreen_image() -> Option<BinaryData<'static>> {
if let Ok(image) = get_user_custom_image() {
if check_homescreen_format(image) {

View File

@ -22,7 +22,7 @@ use super::super::{
ActionBar, FidoCredential, Header, LongMenuGc, ShortMenuVec, TextScreen, TextScreenMsg,
VerticalMenu, VerticalMenuScreen, VerticalMenuScreenMsg,
},
theme,
theme::{self, gradient::Gradient},
};
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
@ -146,7 +146,9 @@ pub fn new_confirm_fido(
ActionBar::new_cancel_confirm()
} else {
ActionBar::new_single(
Button::with_text(TR::words__authenticate.into()).styled(theme::button_confirm()),
Button::with_text(TR::words__authenticate.into())
.styled(theme::button_confirm())
.with_gradient(Gradient::SignGreen),
)
};

View File

@ -26,7 +26,7 @@ use super::super::{
ActionBar, Header, ShortMenuVec, TextScreen, TextScreenMsg, VerticalMenu,
VerticalMenuScreen, VerticalMenuScreenMsg,
},
theme,
theme::{self, gradient::Gradient},
};
const MENU_ITEM_CANCEL: usize = 0;
@ -200,7 +200,9 @@ fn content_cancel(
.with_header(Header::new(TR::words__send.into()))
.with_action_bar(ActionBar::new_double(
Button::with_icon(theme::ICON_CHEVRON_LEFT),
Button::with_text(TR::buttons__cancel.into()).styled(theme::button_actionbar_danger()),
Button::with_text(TR::buttons__cancel.into())
.styled(theme::button_actionbar_danger())
.with_gradient(Gradient::Alert),
))
.map(|msg| match msg {
TextScreenMsg::Confirmed => Some(FlowMsg::Confirmed),
@ -409,7 +411,8 @@ pub fn new_confirm_output(
Button::with_icon(theme::ICON_CHEVRON_UP),
Button::with_text(TR::instructions__hold_to_sign.into())
.with_long_press(theme::CONFIRM_HOLD_DURATION)
.styled(theme::button_confirm()),
.styled(theme::button_confirm())
.with_gradient(Gradient::SignGreen),
))
.map(|msg| match msg {
TextScreenMsg::Confirmed => Some(FlowMsg::Confirmed),

View File

@ -18,7 +18,8 @@ use super::super::{
ActionBar, Header, HeaderMsg, Hint, TextScreen, TextScreenMsg, VerticalMenu,
VerticalMenuScreen, VerticalMenuScreenMsg,
},
fonts, theme,
fonts,
theme::{self, gradient::Gradient},
};
#[derive(Copy, Clone, PartialEq, Eq)]
@ -70,7 +71,8 @@ pub fn new_confirm_reset(recovery: bool) -> Result<SwipeFlow, error::Error> {
.with_action_bar(ActionBar::new_single(
Button::with_text(TR::instructions__hold_to_continue.into())
.with_long_press(theme::CONFIRM_HOLD_DURATION)
.styled(theme::button_confirm()),
.styled(theme::button_confirm())
.with_gradient(Gradient::SignGreen),
))
.with_hint(Hint::new_instruction(
TR::reset__tos_link,

View File

@ -21,7 +21,7 @@ use crate::{
use super::super::{
component::Button,
firmware::{ActionBar, Header, TextScreen},
theme,
theme::{self, gradient::Gradient},
};
#[derive(Copy, Clone, PartialEq, Eq)]
@ -97,8 +97,9 @@ pub fn new_set_new_pin(
)
.with_action_bar(ActionBar::new_double(
Button::with_icon(theme::ICON_CHEVRON_LEFT),
Button::with_text(TR::buttons__continue.into())
.styled(theme::button_actionbar_danger()),
Button::with_text(TR::buttons__cancel.into())
.styled(theme::button_actionbar_danger())
.with_gradient(Gradient::Alert),
))
.map(|msg| match msg {
TextScreenMsg::Cancelled => Some(FlowMsg::Cancelled),

View File

@ -23,7 +23,7 @@ use super::super::{
ActionBar, Header, Hint, ShortMenuVec, TextScreen, TextScreenMsg, VerticalMenu,
VerticalMenuScreen, VerticalMenuScreenMsg,
},
theme,
theme::{self, gradient::Gradient},
};
const MENU_ITEM_CANCEL: usize = 0;
@ -121,7 +121,8 @@ pub fn new_confirm_summary(
.with_action_bar(ActionBar::new_single(
Button::with_text(TR::instructions__hold_to_sign.into())
.with_long_press(theme::CONFIRM_HOLD_DURATION)
.styled(theme::button_confirm()),
.styled(theme::button_confirm())
.with_gradient(Gradient::SignGreen),
))
.with_hint(Hint::new_instruction(
TR::send__send_in_the_app,
@ -190,7 +191,9 @@ pub fn new_confirm_summary(
.with_header(Header::new(TR::words__send.into()))
.with_action_bar(ActionBar::new_double(
Button::with_icon(theme::ICON_CHEVRON_LEFT),
Button::with_text(TR::buttons__cancel.into()).styled(theme::button_actionbar_danger()),
Button::with_text(TR::buttons__cancel.into())
.styled(theme::button_actionbar_danger())
.with_gradient(Gradient::Alert),
))
.map(|msg| match msg {
TextScreenMsg::Confirmed => Some(FlowMsg::Confirmed),

View File

@ -19,7 +19,7 @@ use super::super::{
ActionBar, AllowedTextContent, Header, Hint, ShortMenuVec, TextScreen, TextScreenMsg,
VerticalMenu, VerticalMenuScreen, VerticalMenuScreenMsg,
},
theme,
theme::{self, gradient::Gradient},
};
const TIMEOUT_MS: u32 = 2000;
@ -71,10 +71,13 @@ pub fn new_confirm_with_menu<T: AllowedTextContent + MaybeTrace + 'static>(
Button::with_text(verb)
.with_long_press(theme::CONFIRM_HOLD_DURATION)
.styled(theme::firmware::button_confirm())
.with_gradient(Gradient::SignGreen)
} else if let Some(verb) = verb {
Button::with_text(verb)
} else {
Button::with_text(TR::buttons__confirm.into()).styled(theme::firmware::button_confirm())
Button::with_text(TR::buttons__confirm.into())
.styled(theme::firmware::button_confirm())
.with_gradient(Gradient::SignGreen)
};
let mut value_screen = TextScreen::new(content)

View File

@ -27,7 +27,7 @@ use super::super::{
ActionBar, Header, ShortMenuVec, TextScreen, TextScreenMsg, VerticalMenu,
VerticalMenuScreen, VerticalMenuScreenMsg,
},
theme,
theme::{self, gradient::Gradient},
};
#[derive(Copy, Clone, PartialEq, Eq)]
@ -214,7 +214,9 @@ pub fn new_continue_recovery_homepage(
.with_header(Header::new(cancel_title.into()))
.with_action_bar(ActionBar::new_double(
Button::with_icon(theme::ICON_CHEVRON_LEFT),
Button::with_text(TR::buttons__cancel.into()).styled(theme::button_actionbar_danger()),
Button::with_text(TR::buttons__cancel.into())
.styled(theme::button_actionbar_danger())
.with_gradient(Gradient::Alert),
))
.map(|msg| match msg {
TextScreenMsg::Confirmed => Some(FlowMsg::Confirmed),

View File

@ -21,7 +21,7 @@ use super::super::{
ActionBar, Header, HeaderMsg, Hint, ShortMenuVec, TextScreen, TextScreenMsg, VerticalMenu,
VerticalMenuScreen, VerticalMenuScreenMsg,
},
theme,
theme::{self, gradient::Gradient},
};
#[derive(Copy, Clone, PartialEq, Eq)]
@ -103,7 +103,9 @@ pub fn new_prompt_backup() -> Result<SwipeFlow, error::Error> {
)
.with_action_bar(ActionBar::new_double(
Button::with_icon(theme::ICON_CHEVRON_LEFT),
Button::with_text(TR::buttons__skip.into()).styled(theme::button_cancel()),
Button::with_text(TR::buttons__skip.into())
.styled(theme::button_actionbar_danger())
.with_gradient(Gradient::Alert),
))
.with_hint(Hint::new_instruction(TR::backup__not_recommend, None))
.map(|msg| match msg {

View File

@ -27,7 +27,7 @@ use super::super::{
ActionBar, Header, HeaderMsg, Hint, QrScreen, ShortMenuVec, TextScreen, TextScreenMsg,
VerticalMenu, VerticalMenuScreen, VerticalMenuScreenMsg,
},
theme,
theme::{self, gradient::Gradient},
};
const ITEM_PADDING: i16 = 16;
@ -115,9 +115,13 @@ pub fn new_receive(
paragraphs.add(Paragraph::new(text_style, content));
let button = if hint.is_some() {
Button::with_text(TR::buttons__confirm.into()).styled(theme::button_actionbar_danger())
Button::with_text(TR::buttons__confirm.into())
.styled(theme::button_actionbar_danger())
.with_gradient(Gradient::Alert)
} else {
Button::with_text(TR::buttons__confirm.into()).styled(theme::button_confirm())
Button::with_text(TR::buttons__confirm.into())
.styled(theme::button_confirm())
.with_gradient(Gradient::SignGreen)
};
let mut address_screen = TextScreen::new(
@ -229,7 +233,9 @@ pub fn new_receive(
.with_header(Header::new(title))
.with_action_bar(ActionBar::new_double(
Button::with_icon(theme::ICON_CHEVRON_LEFT),
Button::with_text(TR::buttons__cancel.into()).styled(theme::button_actionbar_danger()),
Button::with_text(TR::buttons__cancel.into())
.styled(theme::button_actionbar_danger())
.with_gradient(theme::Gradient::Alert),
));
if let Some(hint) = cancel_hint {
screen_cancel_info = screen_cancel_info.with_hint(hint);

View File

@ -70,7 +70,7 @@ pub fn new_request_passphrase() -> Result<SwipeFlow, error::Error> {
.with_header(Header::new(TR::passphrase__title_enter.into()))
.with_action_bar(ActionBar::new_double(
Button::with_icon(theme::ICON_CHEVRON_LEFT),
Button::with_text(TR::buttons__confirm.into()).styled(theme::button_default()),
Button::with_text(TR::buttons__confirm.into()),
))
.map(|msg| match msg {
TextScreenMsg::Cancelled => Some(FlowMsg::Cancelled),

View File

@ -24,7 +24,7 @@ use super::super::{
ActionBar, Header, ShareWordsScreen, ShareWordsScreenMsg, ShortMenuVec, TextScreen,
TextScreenMsg, VerticalMenu, VerticalMenuScreen, VerticalMenuScreenMsg,
},
theme,
theme::{self, gradient::Gradient},
};
#[derive(Copy, Clone, PartialEq, Eq)]
@ -110,6 +110,7 @@ pub fn new_show_share_words_flow(
Button::with_icon(theme::ICON_CHEVRON_UP),
Button::with_text(TR::buttons__hold_to_confirm.into())
.styled(theme::button_confirm())
.with_gradient(Gradient::SignGreen)
.with_long_press(theme::CONFIRM_HOLD_DURATION),
))
.map(|msg| match msg {

View File

@ -328,12 +328,12 @@ pub const fn menu_item_title_red() -> ButtonStyleSheet {
}
macro_rules! button_homebar_style {
($button_color:expr, $icon_color:expr) => {
($icon_color:expr) => {
ButtonStyleSheet {
normal: &ButtonStyle {
font: fonts::FONT_SATOSHI_MEDIUM_26,
text_color: GREY_LIGHT,
button_color: $button_color,
button_color: GREY_SUPER_DARK,
icon_color: $icon_color,
},
active: &ButtonStyle {
@ -352,14 +352,14 @@ macro_rules! button_homebar_style {
}
};
}
pub const fn button_homebar_style(notification_level: u8) -> ButtonStyleSheet {
pub const fn button_homebar_style(notification_level: u8) -> (ButtonStyleSheet, Gradient) {
// NOTE: 0 is the highest severity.
match notification_level {
0 => button_homebar_style!(ORANGE_SUPER_DARK, RED),
1 => button_homebar_style!(YELLOW_DARK, GREY_LIGHT),
2 => button_homebar_style!(GREY_SUPER_DARK, GREY_LIGHT),
3 => button_homebar_style!(GREEN_DARK, GREY_LIGHT),
_ => button_homebar_style!(GREY_EXTRA_DARK, GREY_LIGHT),
0 => (button_homebar_style!(RED), Gradient::Alert),
1 => (button_homebar_style!(GREY_LIGHT), Gradient::Warning),
2 => (button_homebar_style!(GREY_LIGHT), Gradient::DefaultGrey),
3 => (button_homebar_style!(GREY_LIGHT), Gradient::SignGreen),
_ => (button_homebar_style!(GREY_LIGHT), Gradient::DefaultGrey),
}
}

View File

@ -0,0 +1,273 @@
use crate::ui::{
display::Color,
geometry::{Axis, Point, Rect},
lerp::Lerp,
shape::{self, Renderer},
};
use super::super::theme;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Gradient {
DefaultGrey,
#[cfg(feature = "micropython")]
SignGreen,
#[cfg(feature = "micropython")]
Warning,
#[cfg(feature = "micropython")]
Alert,
#[cfg(feature = "micropython")]
HomescreenBase,
#[cfg(feature = "micropython")]
HomescreenLEDSim(Color),
}
impl Gradient {
/// Renders a gradient within the given rectangle
///
/// # Arguments
/// * `target` - The renderer to draw to
/// * `area` - The rectangle to fill with gradient
/// * `step_size` - Step size for speed tuning (1 = full quality, higher =
/// faster but lower quality)
pub fn render<'s>(&self, target: &mut impl Renderer<'s>, area: Rect, step_size: u16) {
render_gradient(target, area, *self, step_size);
}
}
fn render_gradient<'s>(
target: &mut impl Renderer<'s>,
area: Rect,
gradient_type: Gradient,
step_size: u16,
) {
match gradient_type {
Gradient::DefaultGrey => render_default_grey(target, area, step_size),
#[cfg(feature = "micropython")]
Gradient::SignGreen => render_sign_green(target, area, step_size),
#[cfg(feature = "micropython")]
Gradient::Warning => render_warning(target, area, step_size),
#[cfg(feature = "micropython")]
Gradient::Alert => render_alert(target, area, step_size),
#[cfg(feature = "micropython")]
Gradient::HomescreenBase => render_homescreen_base(target, area, step_size),
#[cfg(feature = "micropython")]
Gradient::HomescreenLEDSim(color) => render_led_simulation(target, area, step_size, color),
}
}
fn render_default_grey<'s>(target: &mut impl Renderer<'s>, area: Rect, step_size: u16) {
// Layer 1: Vertical Gradient (Overall intensity: 100%)
// Stops: 21%, 100%
// Opacity: 100%, 20%
// Color: color1, color2
let color1 = theme::GREY_EXTRA_DARK;
let color2 = theme::GREEN_DARK;
for (slice, factor) in iter_slices(area, Axis::Vertical, step_size) {
let factor = normalize_factor(factor, 0.21, 1.00);
let color = Color::lerp(color1, color2, factor);
let alpha = u8::lerp(u8::MAX, 51, factor);
shape::Bar::new(slice)
.with_bg(color)
.with_alpha(alpha)
.render(target);
}
render_edge_fade(target, area, theme::BLACK, step_size);
render_black_overlay(target, area);
}
#[cfg(feature = "micropython")]
fn render_sign_green<'s>(target: &mut impl Renderer<'s>, area: Rect, step_size: u16) {
// Layer 1: Vertical Gradient (Overall intensity: 100%)
// Stops: 21%, 100%
// Opacity: 100%, 20%
for (slice, factor) in iter_slices(area, Axis::Vertical, step_size) {
let factor = normalize_factor(factor, 0.21, 1.00);
let alpha = u8::lerp(u8::MAX, 51, factor);
shape::Bar::new(slice)
.with_bg(theme::GREEN_DARK)
.with_alpha(alpha)
.render(target);
}
render_edge_fade(target, area, theme::BLACK, step_size);
render_black_overlay(target, area);
}
#[cfg(feature = "micropython")]
fn render_warning<'s>(target: &mut impl Renderer<'s>, area: Rect, step_size: u16) {
// Layer 1: Vertical Gradient (Overall intensity: 100%)
// Stops: 21%, 100%
// Opacity: 100%, 20%
for (slice, factor) in iter_slices(area, Axis::Vertical, step_size) {
let factor = normalize_factor(factor, 0.21, 1.00);
let alpha = u8::lerp(u8::MAX, 51, factor);
shape::Bar::new(slice)
.with_bg(theme::YELLOW_DARK)
.with_alpha(alpha)
.render(target);
}
render_edge_fade(target, area, theme::YELLOW_DARK, step_size);
render_black_overlay(target, area);
}
#[cfg(feature = "micropython")]
fn render_alert<'s>(target: &mut impl Renderer<'s>, area: Rect, step_size: u16) {
render_alert_horizontal(target, area, theme::ORANGE_SUPER_DARK, step_size);
// Layer 2 Vertical Gradient (Overall intensity: 100%)
// Stops: 85%, 100%
// Opacity: 20%, 100%
let color1 = theme::ORANGE_SUPER_DARK;
let color2 = theme::BLACK;
for (slice, factor) in iter_slices(area, Axis::Vertical, step_size) {
let factor = normalize_factor(factor, 0.80, 1.00);
let alpha = u8::lerp(51, u8::MAX, factor);
let color = Color::lerp(color1, color2, factor);
shape::Bar::new(slice)
.with_bg(color)
.with_alpha(alpha)
.render(target);
}
render_black_overlay(target, area);
}
#[cfg(feature = "micropython")]
fn render_homescreen_base<'s>(target: &mut impl Renderer<'s>, area: Rect, step_size: u16) {
for (slice, factor) in iter_slices(area, Axis::Vertical, step_size) {
shape::Bar::new(slice)
.with_bg(theme::BG)
.with_alpha(u8::lerp(u8::MIN, u8::MAX, factor))
.render(target);
}
}
#[cfg(feature = "micropython")]
fn render_led_simulation<'a>(
target: &mut impl Renderer<'a>,
area: Rect,
step_size: u16,
color: Color,
) {
// Vertical gradient (color intensity fading from bottom to top)
for (slice, factor) in iter_slices(area, Axis::Vertical, step_size) {
// Gradient 1 (Overall intensity: 35%)
// Stops: 0%, 40%
// Opacity: 100%, 20%
let factor_grad_1 = (factor / 0.4).clamp(0.2, 1.0);
shape::Bar::new(slice)
.with_bg(color)
.with_alpha(u8::lerp(89, u8::MIN, factor_grad_1))
.render(target);
// Gradient 2 (Overall intensity: 70%)
// Stops: 2%, 63%
// Opacity: 100%, 0%
let factor_grad_2 = normalize_factor(factor, 0.02, 0.63);
let alpha = u8::lerp(179, u8::MIN, factor_grad_2);
shape::Bar::new(slice)
.with_bg(color)
.with_alpha(alpha)
.render(target);
}
// Horizontal gradient (transparency increasing toward center)
for (slice, _) in iter_slices(area, Axis::Horizontal, step_size) {
// Calculate distance from center as a normalized factor (0 at center, 1 at
// edges)
let x_mid = area.center().x;
let x_half_width = (area.width() / 2) as f32;
let dist_from_mid = (slice.x0 - x_mid).abs() as f32 / x_half_width;
shape::Bar::new(slice)
.with_bg(theme::BG)
.with_alpha(u8::lerp(u8::MIN, u8::MAX, dist_from_mid))
.render(target);
}
}
fn render_edge_fade<'s>(
target: &mut impl Renderer<'s>,
area: Rect,
color_mid: Color,
step_size: u16,
) {
// Render horizontal distance-from-mid gradient
// Black at edges, color_mid at center with minimal opacity
let x_mid = area.center().x;
let half_width = (area.width() / 2) as f32;
for (slice, _) in iter_slices(area, Axis::Horizontal, step_size) {
let dist_from_mid = (slice.x0 - x_mid).abs() as f32 / half_width;
let alpha = u8::lerp(u8::MIN, u8::MAX, dist_from_mid);
let color = Color::lerp(color_mid, theme::BLACK, dist_from_mid);
shape::Bar::new(slice)
.with_bg(color)
.with_alpha(alpha)
.render(target);
}
}
fn render_alert_horizontal<'s>(
target: &mut impl Renderer<'s>,
area: Rect,
color_mid: Color,
step_size: u16,
) {
// Render horizontal distance-from-mid gradient
// Black at edges, color_mid at center with full opacity
let x_mid = area.center().x;
let half_width = (area.width() / 2) as f32;
for (slice, _) in iter_slices(area, Axis::Horizontal, step_size) {
let dist_from_mid = (slice.x0 - x_mid).abs() as f32 / half_width;
let color = Color::lerp(color_mid, theme::BLACK, dist_from_mid);
shape::Bar::new(slice)
.with_bg(color)
.with_alpha(u8::MAX)
.render(target);
}
}
fn render_black_overlay<'s>(target: &mut impl Renderer<'s>, area: Rect) {
// Render a black overlay (Overall intensity: 20%)
shape::Bar::new(area)
.with_bg(theme::BG)
.with_alpha(51) // 20% opacity
.render(target);
}
// Helper functions
/// Normalizes a factor to the given range and clamps it to [0.0, 1.0]
fn normalize_factor(factor: f32, start: f32, end: f32) -> f32 {
((factor - start) / (end - start)).clamp(0.0, 1.0)
}
fn iter_slices(area: Rect, axis: Axis, step_size: u16) -> impl Iterator<Item = (Rect, f32)> {
let (start, end) = match axis {
Axis::Horizontal => (area.x0, area.x1),
Axis::Vertical => (area.y0, area.y1),
};
let total_length = end - start;
(start..end).step_by(step_size as usize).map(move |pos| {
let remaining = end - pos;
let slice_size = (step_size as i16).min(remaining);
let slice = match axis {
Axis::Horizontal => Rect::new(
Point::new(pos, area.y0),
Point::new(pos + slice_size, area.y1),
),
Axis::Vertical => Rect::new(
Point::new(area.x0, pos),
Point::new(area.x1, pos + slice_size),
),
};
// Calculate factor based on the center of the slice for better visual accuracy
let slice_center = pos + slice_size / 2;
let factor = (slice_center - start) as f32 / total_length as f32;
(slice, factor)
})
}

View File

@ -3,6 +3,7 @@ pub mod backlight;
pub mod bootloader;
#[cfg(feature = "micropython")]
pub mod firmware;
pub mod gradient;
#[cfg(feature = "micropython")]
pub use firmware::*;
@ -18,6 +19,8 @@ use super::{
fonts,
};
pub use gradient::Gradient;
// Color palette.
pub const WHITE: Color = Color::rgb(0xFF, 0xFF, 0xFF);
pub const BLACK: Color = Color::rgb(0, 0, 0);
@ -40,16 +43,14 @@ pub const ORANGE: Color = Color::rgb(0xFF, 0x63, 0x30);
pub const ORANGE_DIMMED: Color = Color::rgb(0x9E, 0x57, 0x42);
pub const ORANGE_DARK: Color = Color::rgb(0x18, 0x0C, 0x0A);
pub const ORANGE_EXTRA_DARK: Color = Color::rgb(0x12, 0x07, 0x04);
pub const ORANGE_SUPER_DARK: Color = Color::rgb(0x2A, 0x0A, 0x00); // Homescreen gradient
pub const ORANGE_SUPER_DARK: Color = Color::rgb(0x2A, 0x0A, 0x00); // used in gradient
pub const YELLOW: Color = Color::rgb(0xFF, 0xE4, 0x58);
pub const YELLOW_DARK: Color = Color::rgb(0x21, 0x1E, 0x0C); // Homescreen gradient
pub const YELLOW_DARK: Color = Color::rgb(0x21, 0x1E, 0x0C); // used in gradient
pub const BLUE: Color = Color::rgb(0x00, 0x46, 0xFF);
pub const RED: Color = Color::rgb(0xFF, 0x30, 0x30);
pub const FATAL_ERROR_COLOR: Color = Color::rgb(0xE7, 0x0E, 0x0E);
pub const FATAL_ERROR_HIGHLIGHT_COLOR: Color = Color::rgb(0xFF, 0x41, 0x41);
// Common constants
pub const PADDING: i16 = 24; // [px]

View File

@ -39,7 +39,13 @@ use super::{
SelectWordCountScreen, SelectWordScreen, SetBrightnessScreen, Slip39Input, TextScreen,
ValueInputScreen,
},
flow, fonts, theme, UIEckhart,
flow, fonts,
theme::{
self,
firmware::{button_actionbar_danger, button_confirm},
gradient::Gradient,
},
UIEckhart,
};
use heapless::Vec;
@ -77,21 +83,24 @@ impl FirmwareUI for UIEckhart {
)
};
let right_button = if hold {
let verb = verb.unwrap_or(TR::buttons__hold_to_confirm.into());
let style = if hold_danger {
theme::firmware::button_actionbar_danger()
} else {
theme::firmware::button_confirm()
};
Button::with_text(verb)
.with_long_press(theme::CONFIRM_HOLD_DURATION)
.with_long_press_danger(hold_danger)
.styled(style)
} else if let Some(verb) = verb {
Button::with_text(verb)
} else {
Button::with_text(TR::buttons__confirm.into()).styled(theme::firmware::button_confirm())
let right_button = match (hold, verb) {
(true, verb) => {
let verb = verb.unwrap_or(TR::buttons__hold_to_confirm.into());
let (style, gradient) = if hold_danger {
(button_actionbar_danger(), theme::Gradient::Alert)
} else {
(button_confirm(), theme::Gradient::SignGreen)
};
Button::with_text(verb)
.with_long_press(theme::CONFIRM_HOLD_DURATION)
.with_long_press_danger(hold_danger)
.with_gradient(gradient)
.styled(style)
}
(false, Some(verb)) => Button::with_text(verb),
(false, None) => {
Button::with_text(TR::buttons__confirm.into()).styled(button_confirm())
}
};
let mut screen = TextScreen::new(paragraphs)
@ -444,14 +453,16 @@ impl FirmwareUI for UIEckhart {
let verb = verb.unwrap_or(TR::buttons__hold_to_confirm.into());
Button::with_text(verb)
.with_long_press(theme::CONFIRM_HOLD_DURATION)
.styled(theme::firmware::button_confirm())
.styled(button_confirm())
} else if let Some(verb) = verb {
Button::with_text(verb)
} else {
Button::with_text(TR::buttons__confirm.into()).styled(theme::firmware::button_confirm())
Button::with_text(TR::buttons__confirm.into()).styled(button_confirm())
};
if warning_footer.is_some() {
right_button = right_button.styled(theme::button_actionbar_danger());
right_button = right_button
.styled(theme::button_actionbar_danger())
.with_gradient(Gradient::Alert);
}
let header = if info {
Header::new(title)