use alloc::string::String;
use libm::{cosf, sinf};
use rlvgl_core::draw::draw_widget_bg;
use rlvgl_core::event::Event;
use rlvgl_core::font::{FontMetrics, WidgetFont};
use rlvgl_core::renderer::Renderer;
use rlvgl_core::style::Style;
use rlvgl_core::widget::{Color, Rect, Widget};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArcLabelDir {
Clockwise,
CounterClockwise,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArcLabelAlign {
Leading,
Center,
Trailing,
}
pub struct ArcLabel {
bounds: Rect,
pub style: Style,
pub text_color: Color,
text: String,
radius: i32,
angle_start: f32,
angle_size: f32,
dir: ArcLabelDir,
align: ArcLabelAlign,
font: WidgetFont,
}
impl ArcLabel {
pub fn new(bounds: Rect) -> Self {
Self {
bounds,
style: Style::default(),
text_color: Color(0, 0, 0, 255),
text: String::new(),
radius: 0,
angle_start: 0.0,
angle_size: 360.0,
dir: ArcLabelDir::Clockwise,
align: ArcLabelAlign::Leading,
font: WidgetFont::new(),
}
}
pub fn set_text(&mut self, text: &str) {
self.text.clear();
self.text.push_str(text);
}
pub fn text(&self) -> &str {
&self.text
}
pub fn set_radius(&mut self, r: i32) {
self.radius = r;
}
pub fn radius(&self) -> i32 {
self.radius
}
pub fn set_angle_start(&mut self, deg: f32) {
self.angle_start = deg;
}
pub fn angle_start(&self) -> f32 {
self.angle_start
}
pub fn set_angle_size(&mut self, deg: f32) {
self.angle_size = deg;
}
pub fn angle_size(&self) -> f32 {
self.angle_size
}
pub fn set_dir(&mut self, dir: ArcLabelDir) {
self.dir = dir;
}
pub fn dir(&self) -> ArcLabelDir {
self.dir
}
pub fn set_align(&mut self, align: ArcLabelAlign) {
self.align = align;
}
pub fn align(&self) -> ArcLabelAlign {
self.align
}
pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
self.font.set(font);
}
fn effective_radius(&self) -> f32 {
if self.radius > 0 {
self.radius as f32
} else {
let half_w = self.bounds.width / 2;
let half_h = self.bounds.height / 2;
half_w.min(half_h).max(1) as f32
}
}
fn center(&self) -> (f32, f32) {
(
(self.bounds.x + self.bounds.width / 2) as f32,
(self.bounds.y + self.bounds.height / 2) as f32,
)
}
fn glyph_delta_theta(&self, ch: char, radius: f32) -> f32 {
let font = self.font.resolve();
let advance_px = match font.glyph_metrics(ch) {
Some(info) => info.advance_fp16 as f32 / 16.0,
None => {
let lm = font.line_metrics();
(lm.line_height as f32 + 1.0) / 2.0
}
};
if radius <= 0.0 {
0.0
} else {
advance_px / radius
}
}
fn total_arc_radians(&self, text: &str, radius: f32) -> f32 {
text.chars()
.map(|ch| self.glyph_delta_theta(ch, radius))
.sum()
}
}
impl Widget for ArcLabel {
fn bounds(&self) -> Rect {
self.bounds
}
fn widget_font_mut(&mut self) -> Option<&mut WidgetFont> {
Some(&mut self.font)
}
fn draw(&self, renderer: &mut dyn Renderer) {
draw_widget_bg(renderer, self.bounds, &self.style);
if self.text.is_empty() {
return;
}
let font = self.font.resolve();
let radius = self.effective_radius();
let (cx, cy) = self.center();
let dir_sign: f32 = match self.dir {
ArcLabelDir::Clockwise => 1.0,
ArcLabelDir::CounterClockwise => -1.0,
};
let start_rad = self.angle_start.to_radians();
let size_rad = self.angle_size.to_radians();
let total_span = self.total_arc_radians(&self.text, radius);
let offset_rad = match self.align {
ArcLabelAlign::Leading => 0.0_f32,
ArcLabelAlign::Center => {
let available = size_rad.abs();
let span = total_span.min(available);
dir_sign * (available - span) / 2.0
}
ArcLabelAlign::Trailing => {
let available = size_rad.abs();
let span = total_span.min(available);
dir_sign * (available - span)
}
};
let available_radians = size_rad.abs();
let mut cursor_rad = start_rad + offset_rad;
let mut accumulated_span: f32 = 0.0;
for ch in self.text.chars() {
let delta = self.glyph_delta_theta(ch, radius);
if accumulated_span + delta > available_radians + 1e-4 {
break;
}
let gx = cx + radius * cosf(cursor_rad);
let gy = cy + radius * sinf(cursor_rad);
renderer.draw_glyph(font, ch, (gx as i32, gy as i32), self.text_color);
cursor_rad += dir_sign * delta;
accumulated_span += delta;
}
}
fn handle_event(&mut self, _event: &Event) -> bool {
false
}
fn set_bounds(&mut self, bounds: Rect) {
self.bounds = bounds;
}
}
#[cfg(test)]
mod tests {
use super::*;
use rlvgl_core::font::{FontLineMetrics, GlyphInfo};
struct FixedFont;
impl FontMetrics for FixedFont {
fn glyph_metrics(&self, _ch: char) -> Option<GlyphInfo> {
Some(GlyphInfo {
advance_fp16: 256, bearing_x: 0,
bearing_y: 8,
width: 8,
height: 12,
})
}
fn line_metrics(&self) -> FontLineMetrics {
FontLineMetrics {
line_height: 16,
ascent: 12,
descent: 4,
}
}
}
static FIXED_FONT: FixedFont = FixedFont;
fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect {
Rect {
x,
y,
width: w,
height: h,
}
}
struct GlyphRecorder {
calls: alloc::vec::Vec<(i32, i32, char)>,
}
impl GlyphRecorder {
fn new() -> Self {
Self {
calls: alloc::vec::Vec::new(),
}
}
}
impl Renderer for GlyphRecorder {
fn fill_rect(&mut self, _r: Rect, _c: Color) {}
fn draw_text(&mut self, _pos: (i32, i32), _text: &str, _color: Color) {}
fn draw_glyph(
&mut self,
_font: &dyn FontMetrics,
ch: char,
origin: (i32, i32),
_color: Color,
) {
self.calls.push((origin.0, origin.1, ch));
}
}
#[test]
fn glyph_count_matches_text_length() {
let mut al = ArcLabel::new(rect(0, 0, 200, 200));
al.set_text("ABC");
al.set_font(&FIXED_FONT);
al.set_radius(50);
al.set_angle_size(360.0);
let mut r = GlyphRecorder::new();
al.draw(&mut r);
assert_eq!(r.calls.len(), 3, "one draw_text call per glyph (3 chars)");
}
#[test]
fn angular_spacing_uses_shared_font_metric() {
let radius = 100.0_f32;
let al = ArcLabel::new(rect(0, 0, 300, 300));
let delta = al.glyph_delta_theta('A', radius);
assert!(
(delta - 14.0 / 100.0).abs() < 1e-4,
"default-font Δθ = FONT_6X10 advance/r"
);
let mut al2 = ArcLabel::new(rect(0, 0, 300, 300));
al2.set_font(&FIXED_FONT);
let delta2 = al2.glyph_delta_theta('A', radius);
assert!(
(delta2 - 16.0 / 100.0).abs() < 1e-4,
"font-metric Δθ = advance_px/r"
);
}
#[test]
fn clockwise_moves_right_from_zero_degrees() {
let mut al = ArcLabel::new(rect(0, 0, 200, 200));
al.set_text("AB");
al.set_font(&FIXED_FONT);
al.set_radius(80);
al.set_angle_start(0.0);
al.set_dir(ArcLabelDir::Clockwise);
let mut r = GlyphRecorder::new();
al.draw(&mut r);
assert_eq!(r.calls.len(), 2);
let (x0, _, _) = r.calls[0];
let (x1, _, _) = r.calls[1];
let (_, y0, _) = r.calls[0];
let (_, y1, _) = r.calls[1];
assert!(
y1 >= y0,
"CW from 0°: second glyph is same or lower (y1={y1} >= y0={y0})"
);
let _ = (x0, x1); }
#[test]
fn counter_clockwise_is_opposite_to_clockwise() {
let mut al_cw = ArcLabel::new(rect(0, 0, 200, 200));
al_cw.set_text("A");
al_cw.set_font(&FIXED_FONT);
al_cw.set_radius(80);
al_cw.set_angle_start(0.0);
al_cw.set_dir(ArcLabelDir::Clockwise);
let mut r_cw = GlyphRecorder::new();
al_cw.draw(&mut r_cw);
let mut al_ccw = ArcLabel::new(rect(0, 0, 200, 200));
al_ccw.set_text("A");
al_ccw.set_font(&FIXED_FONT);
al_ccw.set_radius(80);
al_ccw.set_angle_start(0.0);
al_ccw.set_dir(ArcLabelDir::CounterClockwise);
let mut r_ccw = GlyphRecorder::new();
al_ccw.draw(&mut r_ccw);
assert_eq!(r_cw.calls[0].0, r_ccw.calls[0].0);
assert_eq!(r_cw.calls[0].1, r_ccw.calls[0].1);
}
#[test]
fn set_bounds_repositions() {
let mut al = ArcLabel::new(rect(0, 0, 100, 100));
al.set_bounds(rect(10, 20, 300, 200));
assert_eq!(al.bounds(), rect(10, 20, 300, 200));
}
#[test]
fn center_alignment_shifts_first_glyph_right_of_leading() {
let r = 100.0_f32;
let make = |align: ArcLabelAlign| -> i32 {
let mut al = ArcLabel::new(rect(0, 0, 300, 300));
al.set_text("ABC");
al.set_font(&FIXED_FONT);
al.set_radius(r as i32);
al.set_angle_start(0.0);
al.set_angle_size(360.0);
al.set_dir(ArcLabelDir::Clockwise);
al.set_align(align);
let mut rec = GlyphRecorder::new();
al.draw(&mut rec);
rec.calls[0].0 };
let x_lead = make(ArcLabelAlign::Leading);
let x_ctr = make(ArcLabelAlign::Center);
let x_trail = make(ArcLabelAlign::Trailing);
assert!(
x_lead != x_ctr || x_lead != x_trail,
"alignment modes produce different start angles"
);
}
#[test]
fn text_truncated_when_overflows_angle_size() {
let mut al = ArcLabel::new(rect(0, 0, 200, 200));
al.set_text("ABCDEFGH"); al.set_font(&FIXED_FONT);
al.set_radius(100);
al.set_angle_size(28.6);
al.set_dir(ArcLabelDir::Clockwise);
let mut r = GlyphRecorder::new();
al.draw(&mut r);
assert!(
r.calls.len() < 8,
"only {} glyph(s) should fit in 28.6° arc",
r.calls.len()
);
}
}