1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2025-06-25 09:22:33 +00:00

feat(eckhart): fuel gauge UI

- fuel gauge (battery state) with icons for charging/discharging state
and soc percentage
- on attach on Homescreen ActionBar
- permanently on the first DeviceMenu subscreen
- temporarily in the Header on charging state update

[no changelog]
This commit is contained in:
obrusvit 2025-06-03 10:48:18 +02:00 committed by Vít Obrusník
parent 17d6dd4a55
commit f0865e3b10
26 changed files with 363 additions and 57 deletions

View File

@ -179,7 +179,7 @@ test: ## run unit tests
test_rust: ## run rs unit tests
export BUILD_DIR=$(abspath $(UNIX_BUILD_DIR)) ; \
cd embed/rust ; cargo test $(TESTOPTS) --target=$(RUST_TARGET) \
--no-default-features --features $(LAYOUT_FEATURE),test \
--no-default-features --features $(LAYOUT_FEATURE),power_manager,test \
-- --test-threads=1 --nocapture
test_emu: ## run selected device tests from python-trezor

View File

@ -3,6 +3,7 @@ use super::ffi;
#[cfg(feature = "ui")]
use crate::ui::event::PMEvent;
#[derive(PartialEq, Eq, Copy, Clone)]
pub enum ChargingState {
Discharging,
Charging,

View File

@ -424,7 +424,7 @@ impl Timer {
self.token().0 == Timer::INVALID_TOKEN_VALUE
}
const fn is_running(&self) -> bool {
pub const fn is_running(&self) -> bool {
self.0 & Timer::IS_RUNNING_BITMASK != 0
}

View File

@ -15,11 +15,11 @@ use crate::{
},
};
use super::super::theme;
#[cfg(feature = "bootloader")]
use super::super::fonts;
use super::super::theme;
pub enum ButtonMsg {
Pressed,
Released,
@ -236,9 +236,7 @@ impl Button {
}
pub fn set_content(&mut self, content: ButtonContent) {
if self.content != content {
self.content = content
}
self.content = content
}
pub fn set_expanded_touch_area(&mut self, expand: Insets) {
@ -253,6 +251,10 @@ impl Button {
&self.content
}
pub fn content_mut(&mut self) -> &mut ButtonContent {
&mut self.content
}
pub fn content_offset(&self) -> Offset {
self.content_offset
}
@ -301,7 +303,7 @@ impl Button {
text.map(|t| self.text_height(t, false, width) + self.baseline_subtext_height())
}
#[cfg(feature = "micropython")]
ButtonContent::HomeBar(_) => theme::ACTION_BAR_HEIGHT,
ButtonContent::HomeBar(..) => theme::ACTION_BAR_HEIGHT,
}
}
@ -407,7 +409,7 @@ impl Button {
}
fn render_content<'s>(
&self,
&'s self,
target: &mut impl Renderer<'s>,
stylesheet: &ButtonStyle,
alpha: u8,
@ -558,7 +560,7 @@ impl Button {
}
}
pub fn render_with_alpha<'s>(&self, target: &mut impl Renderer<'s>, alpha: u8) {
pub fn render_with_alpha<'s>(&'s self, target: &mut impl Renderer<'s>, alpha: u8) {
let style = self.style();
self.render_background(target, style, alpha);
self.render_content(target, style, alpha);
@ -690,7 +692,9 @@ impl crate::trace::Trace for Button {
t.string("text", *text);
}
#[cfg(feature = "micropython")]
ButtonContent::HomeBar(text) => t.string("text", text.unwrap_or(TString::empty())),
ButtonContent::HomeBar(text) => {
t.string("text", text.unwrap_or(TString::empty()));
}
}
}
}
@ -711,7 +715,7 @@ impl State {
}
}
#[derive(PartialEq, Eq, Clone)]
#[derive(Clone)]
pub enum ButtonContent {
Empty,
Text {

View File

@ -177,6 +177,21 @@ impl ActionBar {
}
}
pub fn left_button(&self) -> Option<&Button> {
self.left_button.as_ref()
}
pub fn left_button_mut(&mut self) -> Option<&mut Button> {
self.left_button.as_mut()
}
pub fn right_button(&self) -> Option<&Button> {
self.right_button.as_ref()
}
pub fn right_button_mut(&mut self) -> Option<&mut Button> {
self.right_button.as_mut()
}
fn new(
mode: Mode,
left_button: Option<Button>,

View File

@ -14,19 +14,21 @@ use crate::{
Component, Event, EventCtx,
},
geometry::Rect,
layout_eckhart::{
component::{Button, ButtonStyleSheet},
constant::SCREEN,
firmware::{
Header, HeaderMsg, TextScreen, TextScreenMsg, VerticalMenu, VerticalMenuScreen,
VerticalMenuScreenMsg, SHORT_MENU_ITEMS,
},
},
shape::Renderer,
},
};
use super::{theme, ShortMenuVec};
use super::{
super::{
component::{Button, ButtonStyleSheet},
constant::SCREEN,
firmware::{
FuelGauge, Header, HeaderMsg, TextScreen, TextScreenMsg, VerticalMenu,
VerticalMenuScreen, VerticalMenuScreenMsg, SHORT_MENU_ITEMS,
},
},
theme, ShortMenuVec,
};
use heapless::Vec;
const MAX_DEPTH: usize = 3;
@ -37,10 +39,9 @@ const DISCONNECT_DEVICE_MENU_INDEX: usize = 1;
#[derive(Clone)]
enum Action {
// Go to another registered subscreen
/// Go to another registered subscreen
GoTo(usize),
// Return a DeviceMenuMsg to the caller
/// Return a DeviceMenuMsg to the caller
Return(DeviceMenuMsg),
}
@ -381,7 +382,7 @@ impl<'a> DeviceMenuScreen<'a> {
}
let mut header = Header::new(submenu.header_text).with_close_button();
if submenu.show_battery {
// TODO: add battery
header = header.with_fuel_gauge(Some(FuelGauge::always()));
} else {
header = header.with_left_button(
Button::with_icon(theme::ICON_CHEVRON_LEFT),

View File

@ -0,0 +1,220 @@
use crate::{
trezorhal::power_manager::{self, ChargingState},
ui::{
component::{Component, Event, EventCtx, Never, Timer},
display::{Color, Font, Icon},
geometry::{Alignment, Alignment2D, Offset, Point, Rect},
shape::{self, Renderer},
util::animation_disabled,
},
};
use super::super::{
fonts,
theme::{
firmware::FUEL_GAUGE_DURATION, GREY_LIGHT, ICON_BATTERY_EMPTY, ICON_BATTERY_FULL,
ICON_BATTERY_LOW, ICON_BATTERY_MID, ICON_BATTERY_ZAP, RED, YELLOW,
},
};
/// Component for showing a small fuel gauge (battery status) consisting of:
/// - icon indicating charging or discharging state
/// - percentage
#[derive(Clone)]
pub struct FuelGauge {
/// Area where the fuel gauge is rendered
area: Rect,
/// Alignment of the fuel gauge within its area
alignment: Alignment,
/// Mode of the fuel gauge (Always or OnChrgStatusChange)
mode: FuelGaugeMode,
/// State of battery charging
charging_state: ChargingState,
/// State of charge (0-100) [%]
soc: Option<u8>,
/// Timer to track temporary battery status showcase
timer: Timer,
/// Font used for the soc percentage
font: Font,
}
#[derive(Clone, PartialEq)]
pub enum FuelGaugeMode {
/// Always show the fuel gauge
Always,
/// Show the fuel gauge only when charging state changes
OnChargingChange,
/// Show the fuel gauge when charging state changes or when attached
OnChargingChangeOrAttach,
}
impl FuelGauge {
pub const fn always() -> Self {
Self::new(FuelGaugeMode::Always)
}
pub const fn on_charging_change() -> Self {
Self::new(FuelGaugeMode::OnChargingChange)
}
pub const fn on_charging_change_or_attach() -> Self {
Self::new(FuelGaugeMode::OnChargingChangeOrAttach)
}
pub const fn with_alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
pub const fn with_font(mut self, font: Font) -> Self {
self.font = font;
self
}
pub fn update_pm_state(&mut self) {
self.soc = Some(power_manager::soc());
self.charging_state = power_manager::charging_state();
}
pub fn should_be_shown(&self) -> bool {
match self.mode {
FuelGaugeMode::Always => true,
FuelGaugeMode::OnChargingChange | FuelGaugeMode::OnChargingChangeOrAttach => {
self.timer.is_running()
}
}
}
const fn new(mode: FuelGaugeMode) -> Self {
Self {
area: Rect::zero(),
alignment: Alignment::Start,
mode,
charging_state: ChargingState::Idle,
soc: None,
timer: Timer::new(),
font: fonts::FONT_SATOSHI_REGULAR_22,
}
}
/// Returns the icon, color for the icon, and color for the text based on
/// the charging state and state of charge (soc).
fn battery_indication(&self, charging_state: ChargingState, soc: u8) -> (Icon, Color, Color) {
const SOC_THRESHOLD_FULL: u8 = 80;
const SOC_THRESHOLD_MID: u8 = 25;
const SOC_THRESHOLD_LOW: u8 = 9;
match charging_state {
ChargingState::Charging => (ICON_BATTERY_ZAP, YELLOW, GREY_LIGHT),
ChargingState::Discharging | ChargingState::Idle => {
if soc > SOC_THRESHOLD_FULL {
(ICON_BATTERY_FULL, GREY_LIGHT, GREY_LIGHT)
} else if soc > SOC_THRESHOLD_MID {
(ICON_BATTERY_MID, GREY_LIGHT, GREY_LIGHT)
} else if soc > SOC_THRESHOLD_LOW {
(ICON_BATTERY_LOW, YELLOW, GREY_LIGHT)
} else {
(ICON_BATTERY_EMPTY, RED, RED)
}
}
}
}
}
impl Component for FuelGauge {
type Msg = Never;
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
self.area
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
match event {
Event::Attach(_) => {
if self.soc.is_none() {
self.update_pm_state();
}
if !animation_disabled() && self.mode == FuelGaugeMode::OnChargingChangeOrAttach {
self.timer.start(ctx, FUEL_GAUGE_DURATION.into());
}
ctx.request_paint();
}
Event::PM(e) => {
self.update_pm_state();
match self.mode {
FuelGaugeMode::Always => {
ctx.request_paint();
}
FuelGaugeMode::OnChargingChange | FuelGaugeMode::OnChargingChangeOrAttach => {
if e.charging_status_changed {
self.timer.start(ctx, FUEL_GAUGE_DURATION.into());
ctx.request_paint();
}
}
}
}
Event::Timer(_) => {
if self.timer.expire(event) {
ctx.request_paint();
}
}
_ => {}
}
None
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
const ICON_PERCENT_GAP: i16 = 16;
let soc = self.soc.unwrap_or(0);
let (icon, color_icon, color_text) = self.battery_indication(self.charging_state, soc);
let soc_percent_fmt = if self.soc.is_none() {
uformat!("--")
} else {
uformat!("{} %", soc)
};
let text_width = self.font.text_width(&soc_percent_fmt);
let text_height = self.font.text_height();
let icon_width = icon.toif.width();
let icon_height = icon.toif.height();
let (point, alignment) = match self.alignment {
Alignment::Start => (self.area.left_center(), Alignment2D::CENTER_LEFT),
Alignment::End => (self.area.right_center(), Alignment2D::CENTER_RIGHT),
Alignment::Center => (self.area.center(), Alignment2D::CENTER),
};
let area = Rect::snap(
point,
Offset::new(
icon_width + ICON_PERCENT_GAP + text_width,
text_height.max(icon_height),
),
alignment,
);
let text_y_coord = self.font.vert_center(area.y0, area.y1, &soc_percent_fmt);
shape::ToifImage::new(area.left_center(), icon.toif)
.with_fg(color_icon)
.with_align(Alignment2D::CENTER_LEFT)
.render(target);
shape::Text::new(
Point::new(area.x1, text_y_coord),
&soc_percent_fmt,
self.font,
)
.with_fg(color_text)
.with_align(Alignment::End)
.render(target);
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for FuelGauge {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("FuelGauge");
t.int("soc", self.soc.unwrap_or(0) as i64);
}
}

View File

@ -9,7 +9,10 @@ use crate::{
};
use super::{
super::component::{Button, ButtonContent, ButtonMsg},
super::{
component::{Button, ButtonContent, ButtonMsg},
firmware::FuelGauge,
},
constant, theme,
};
@ -31,6 +34,8 @@ pub struct Header {
/// icon in the top-left corner (used instead of left button)
icon: Option<Icon>,
icon_color: Option<Color>,
/// Battery status indicator
fuel_gauge: Option<FuelGauge>,
}
#[derive(Copy, Clone)]
@ -56,6 +61,7 @@ impl Header {
left_button_msg: HeaderMsg::Cancelled,
icon: None,
icon_color: None,
fuel_gauge: Some(FuelGauge::on_charging_change()),
}
}
@ -115,6 +121,11 @@ impl Header {
}
}
#[inline(never)]
pub fn with_fuel_gauge(self, fuel_gauge: Option<FuelGauge>) -> Self {
Self { fuel_gauge, ..self }
}
#[inline(never)]
pub fn update_title(&mut self, ctx: &mut EventCtx, title: TString<'static>) {
self.title.set_text(title);
@ -148,24 +159,31 @@ impl Component for Header {
let bounds = bounds.inset(Self::HEADER_INSETS);
let rest = if let Some(b) = &mut self.right_button {
let (rest, button_area) = bounds.split_right(Self::HEADER_BUTTON_WIDTH);
b.place(button_area);
let (rest, right_button_area) = bounds.split_right(Self::HEADER_BUTTON_WIDTH);
b.place(right_button_area);
rest
} else {
bounds
};
let icon_width = self.left_icon_width();
let (rest, title_area) = rest.split_left(icon_width);
let (left_button_area, title_area) = rest.split_left(icon_width);
self.left_button.place(rest);
self.left_button.place(left_button_area);
self.title.place(title_area);
self.fuel_gauge.place(title_area.union(left_button_area));
if let Some(fuel_gauge) = &mut self.fuel_gauge {
// Force update the fuel gauge state, so it is up-to-date
// necessary e.g. for the first DeviceMenu Submenu re-entry
fuel_gauge.update_pm_state();
}
self.area = bounds;
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.title.event(ctx, event);
self.fuel_gauge.event(ctx, event);
if let Some(ButtonMsg::Clicked) = self.left_button.event(ctx, event) {
return Some(self.left_button_msg);
@ -179,14 +197,22 @@ impl Component for Header {
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.right_button.render(target);
self.left_button.render(target);
if let Some(icon) = self.icon {
shape::ToifImage::new(self.area.left_center(), icon.toif)
.with_fg(self.icon_color.unwrap_or(theme::GREY_LIGHT))
.with_align(Alignment2D::CENTER_LEFT)
.render(target);
if self
.fuel_gauge
.as_ref()
.is_some_and(|fg| fg.should_be_shown())
{
self.fuel_gauge.render(target);
} else {
self.left_button.render(target);
if let Some(icon) = self.icon {
shape::ToifImage::new(self.area.left_center(), icon.toif)
.with_fg(self.icon_color.unwrap_or(theme::GREY_LIGHT))
.with_align(Alignment2D::CENTER_LEFT)
.render(target);
}
self.title.render(target);
}
self.title.render(target);
}
}

View File

@ -7,21 +7,22 @@ use crate::{
ui::{
component::{text::TextStyle, Component, Event, EventCtx, Label, Never},
display::{image::ImageInfo, Color},
geometry::{Alignment2D, Insets, Offset, Point, Rect},
geometry::{Alignment, Alignment2D, Insets, Offset, Point, Rect},
layout::util::get_user_custom_image,
lerp::Lerp,
shape::{self, Renderer},
util::animation_disabled,
},
};
use super::{
super::{
component::{Button, ButtonMsg},
component::{Button, ButtonContent, ButtonMsg},
fonts,
},
constant::{HEIGHT, SCREEN, WIDTH},
theme::{self, firmware::button_homebar_style, TILES_GRID},
ActionBar, ActionBarMsg, Hint,
ActionBar, ActionBarMsg, FuelGauge, Hint,
};
const LOCK_HOLD_DURATION: ShortDuration = ShortDuration::from_millis(3000);
@ -42,8 +43,12 @@ pub struct Homescreen {
lockable: bool,
/// Whether the homescreen is locked
locked: bool,
/// Whether the homescreen is a boot screen
bootscreen: bool,
/// Hold to lock button placed everywhere except the `action_bar`
virtual_locking_button: Button,
/// Fuel gauge (battery status indicator) rendered in the `action_bar` area
fuel_gauge: FuelGauge,
}
pub enum HomescreenMsg {
@ -91,31 +96,53 @@ impl Homescreen {
led_color = None;
}
// ActionBar button
let button_style = button_homebar_style(notification_level);
let button = if bootscreen {
Button::with_homebar_content(Some(TR::lockscreen__tap_to_connect.into()))
.styled(button_style)
} else if locked {
Button::with_homebar_content(Some(TR::lockscreen__tap_to_unlock.into()))
.styled(button_style)
} else {
// TODO: Battery/Connectivity button content
Button::with_homebar_content(None).styled(button_style)
};
Ok(Self {
label: HomeLabel::new(label, shadow),
hint,
action_bar: ActionBar::new_single(button),
action_bar: ActionBar::new_single(
Button::new(Self::homebar_content(bootscreen, locked))
.styled(button_homebar_style(notification_level)),
),
image,
led_color,
lockable,
locked,
bootscreen,
virtual_locking_button: Button::empty().with_long_press(LOCK_HOLD_DURATION),
fuel_gauge: FuelGauge::on_charging_change_or_attach()
.with_alignment(Alignment::Center)
.with_font(fonts::FONT_SATOSHI_MEDIUM_26),
})
}
fn homebar_content(bootscreen: bool, locked: bool) -> ButtonContent {
let text = if bootscreen {
Some(TR::lockscreen__tap_to_connect.into())
} else if locked {
Some(TR::lockscreen__tap_to_unlock.into())
} else {
None
};
ButtonContent::HomeBar(text)
}
fn event_fuel_gauge(&mut self, ctx: &mut EventCtx, event: Event) {
if animation_disabled() {
return;
}
self.fuel_gauge.event(ctx, event);
let bar_content = if self.fuel_gauge.should_be_shown() {
ButtonContent::Empty
} else {
Self::homebar_content(self.bootscreen, self.locked)
};
if let Some(b) = self.action_bar.right_button_mut() {
b.set_content(bar_content)
}
}
fn event_hold(&mut self, ctx: &mut EventCtx, event: Event) -> bool {
if let Some(ButtonMsg::LongPressed) = self.virtual_locking_button.event(ctx, event) {
return true;
@ -146,6 +173,7 @@ impl Component for Homescreen {
self.label.place(label_area);
self.action_bar.place(bar_area);
self.fuel_gauge.place(bar_area);
// Locking button is placed everywhere except the action bar
let locking_area = bounds.inset(Insets::bottom(self.action_bar.touch_area().height()));
self.virtual_locking_button.place(locking_area);
@ -153,6 +181,7 @@ impl Component for Homescreen {
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.event_fuel_gauge(ctx, event);
if let Some(ActionBarMsg::Confirmed) = self.action_bar.event(ctx, event) {
if self.locked {
return Some(HomescreenMsg::Dismissed);
@ -178,6 +207,9 @@ impl Component for Homescreen {
self.label.render(target);
self.hint.render(target);
self.action_bar.render(target);
if self.fuel_gauge.should_be_shown() {
self.fuel_gauge.render(target);
}
}
}

View File

@ -5,6 +5,7 @@ mod device_menu_screen;
mod fido;
#[rustfmt::skip]
mod fido_icons;
mod fuel_gauge;
mod header;
mod hint;
mod hold_to_confirm;
@ -25,6 +26,7 @@ pub use brightness_screen::SetBrightnessScreen;
pub use confirm_homescreen::{ConfirmHomescreen, ConfirmHomescreenMsg};
pub use device_menu_screen::{DeviceMenuMsg, DeviceMenuScreen};
pub use fido::{FidoAccountName, FidoCredential};
pub use fuel_gauge::FuelGauge;
pub use header::{Header, HeaderMsg};
pub use hint::Hint;
pub use hold_to_confirm::HoldToConfirmAnim;

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 B

View File

@ -16,6 +16,8 @@ use super::{
pub const CONFIRM_HOLD_DURATION: ShortDuration = ShortDuration::from_millis(1500);
pub const ERASE_HOLD_DURATION: ShortDuration = ShortDuration::from_millis(1500);
/// Duration for the battery status showcase on the ActionBar or Header
pub const FUEL_GAUGE_DURATION: ShortDuration = ShortDuration::from_millis(2000);
// Text styles
/// Alias for use with copied code, might be deleted later

View File

@ -116,8 +116,11 @@ include_icon!(ICON_LOGO, "layout_eckhart/res/lock_full.toif");
include_icon!(ICON_WARNING40, "layout_eckhart/res/warning40.toif");
// Battery icons
include_icon!(ICON_BATTERY_BAR, "layout_eckhart/res/battery_bar.toif");
include_icon!(ICON_BATTERY_ZAP, "layout_eckhart/res/battery_zap.toif");
include_icon!(ICON_BATTERY_ZAP, "layout_eckhart/res/battery/zap.toif");
include_icon!(ICON_BATTERY_FULL, "layout_eckhart/res/battery/full.toif");
include_icon!(ICON_BATTERY_MID, "layout_eckhart/res/battery/mid.toif");
include_icon!(ICON_BATTERY_LOW, "layout_eckhart/res/battery/low.toif");
include_icon!(ICON_BATTERY_EMPTY, "layout_eckhart/res/battery/empty.toif");
// Border overlay icons for bootloader screens and hold to confirm animation
include_icon!(ICON_BORDER_BL, "layout_eckhart/res/border/BL.toif");