Skip to content

Commit 38f36bf

Browse files
committed
feat: add Scale, Table with 2D GridNav, Spinbox, 2D GridLayout, and Bézier curves with interactive showcase
1 parent 93fb708 commit 38f36bf

12 files changed

Lines changed: 1938 additions & 34 deletions

File tree

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,8 @@ name = "graphics_pipeline_showcase"
142142
path = "examples/basics/graphics_pipeline_showcase.rs"
143143
required-features = ["std"]
144144

145+
[[example]]
146+
name = "lvgl_parity_showcase"
147+
path = "examples/basics/lvgl_parity_showcase.rs"
148+
required-features = ["std"]
149+
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
//! Showcase: LVGL Feature Parity Suite (Scale, Table, Spinbox, GridLayout, Bézier Curves)
2+
//!
3+
//! Demonstrates:
4+
//! 1. `ScaleWidget` - Radial speedometer & linear graduated scales with ticks, labels, and needle.
5+
//! 2. `TableWidget` - 2D Data grid with headers and active cell navigation (`GridNav`).
6+
//! 3. `SpinboxWidget` - Digit-by-digit decimal numerical parameter editor.
7+
//! 4. `GridLayout` - 2D responsive CSS-style grid layout engine with fractional `fr` and fixed `px` tracks.
8+
//! 5. `VectorPath` & Bézier Curves - Quadratic and cubic Bézier curve strokes with styling.
9+
//!
10+
//! ### Interactive Controls (when desktop window is available):
11+
//! - **Arrow Keys**: Move 2D Table selection / Spinbox digit cursor
12+
//! - **+ / - / Space**: Increment / Decrement Spinbox or animate Scale needle
13+
//! - **Esc / Q**: Exit
14+
15+
use embedded_graphics_core::{
16+
draw_target::DrawTarget,
17+
geometry::{Point, Size},
18+
pixelcolor::{Rgb565, RgbColor, WebColors},
19+
primitives::Rectangle,
20+
};
21+
use embedded_graphics_simulator::{
22+
OutputSettingsBuilder, SimulatorDisplay, SimulatorEvent, Window, sdl2::Keycode,
23+
};
24+
use embedded_gui::{
25+
EdgeInsets, Framebuffer, GridLayout, GridPlacement, GridTrack, Rect, RenderCtx, ScaleWidget,
26+
SpinboxWidget, StrokeStyle, Style, TableWidget, VectorPath,
27+
};
28+
29+
const W: u32 = 320;
30+
const H: u32 = 240;
31+
const FB_SIZE: usize = (W * H) as usize;
32+
33+
fn render_showcase<D: DrawTarget<Color = Rgb565> + embedded_gui::PixelRead>(
34+
target: &mut D,
35+
frame: u32,
36+
table: &TableWidget<'_>,
37+
spinbox: &SpinboxWidget,
38+
) -> Result<(), D::Error> {
39+
// 1. Fast Background
40+
let bg_rect = Rectangle::new(Point::zero(), Size::new(W, H));
41+
target.fill_solid(&bg_rect, Rgb565::new(2, 3, 6))?;
42+
43+
let viewport = Rect::new(0, 0, W, H);
44+
let mut ctx = RenderCtx::compositing(target, viewport);
45+
46+
// 2. 2D GridLayout partitioning the 320x240 display:
47+
// Row 0: Header banner (fixed 24px)
48+
// Row 1: Main content area (1fr)
49+
// Row 2: Bottom vector curves (fixed 48px)
50+
// Col 0: Left controls (140px)
51+
// Col 1: Right data table & scale (1fr)
52+
let grid = GridLayout::<2, 3>::new(
53+
[GridTrack::Px(140), GridTrack::Fr(1)],
54+
[GridTrack::Px(24), GridTrack::Fr(1), GridTrack::Px(52)],
55+
)
56+
.with_gap(6)
57+
.with_padding(EdgeInsets::all(6));
58+
59+
let placements = [
60+
GridPlacement::span(0, 0, 2, 1), // Top Header span
61+
GridPlacement::cell(0, 1), // Left Controls (Spinbox & Linear Scale)
62+
GridPlacement::cell(1, 1), // Right Table & Radial Scale
63+
GridPlacement::span(0, 2, 2, 1), // Bottom Bézier Vector Curves span
64+
];
65+
let mut cells = [Rect::empty(); 4];
66+
grid.arrange_cells(viewport, &placements, &mut cells);
67+
68+
// Top Header Banner
69+
ctx.fill_rounded_rect(cells[0], 4, Rgb565::new(5, 10, 20))?;
70+
ctx.stroke_rounded_rect(
71+
cells[0],
72+
4,
73+
embedded_gui::Border::one(Rgb565::new(0, 30, 25)),
74+
)?;
75+
ctx.draw_text_in(
76+
cells[0].inset(EdgeInsets::symmetric(6, 4)),
77+
"LVGL Parity: Scale, Table, Spinbox, Grid, Curves",
78+
embedded_gui::TextStyle::new(Rgb565::WHITE),
79+
)?;
80+
81+
// Left Column: Spinbox (Top) + Linear Scale (Bottom)
82+
let left_area = cells[1];
83+
let spinbox_rect = Rect::new(left_area.x, left_area.y, left_area.w, 40);
84+
spinbox.render(
85+
&mut ctx,
86+
spinbox_rect,
87+
Style::panel().into(),
88+
embedded_gui::VisualState::Normal,
89+
)?;
90+
91+
let lin_scale_rect = Rect::new(left_area.x, left_area.y + 46, left_area.w, 54);
92+
let needle_val = 20.0 + ((frame as f32 * 0.5).sin() * 20.0);
93+
let lin_scale = ScaleWidget::linear_horizontal(0.0, 50.0, needle_val)
94+
.with_ticks(4, 2)
95+
.with_needle(true, Rgb565::CSS_CYAN);
96+
lin_scale.render(
97+
&mut ctx,
98+
lin_scale_rect,
99+
Style::panel().into(),
100+
embedded_gui::VisualState::Normal,
101+
)?;
102+
103+
// Right Column: Table (Top) + Radial Speedometer (Bottom)
104+
let right_area = cells[2];
105+
let table_rect = Rect::new(right_area.x, right_area.y, right_area.w, 60);
106+
table.render(
107+
&mut ctx,
108+
table_rect,
109+
Style::panel().into(),
110+
embedded_gui::VisualState::Normal,
111+
)?;
112+
113+
let radial_rect = Rect::new(right_area.x + 30, right_area.y + 64, 90, 44);
114+
let speed_val = 60.0 + ((frame as f32 * 0.1).sin() * 40.0);
115+
let radial_scale = ScaleWidget::new(0.0, 120.0, speed_val)
116+
.with_ticks(6, 2)
117+
.with_angles(180, 0)
118+
.with_needle(true, Rgb565::CSS_YELLOW);
119+
radial_scale.render(
120+
&mut ctx,
121+
radial_rect,
122+
Style::panel().into(),
123+
embedded_gui::VisualState::Normal,
124+
)?;
125+
126+
// Bottom Area: Vector Bézier Paths
127+
let bot_area = cells[3];
128+
ctx.fill_rounded_rect(bot_area, 4, Rgb565::new(3, 6, 12))?;
129+
ctx.stroke_rounded_rect(
130+
bot_area,
131+
4,
132+
embedded_gui::Border::one(Rgb565::new(0, 20, 30)),
133+
)?;
134+
135+
// Multi-segment Bézier Vector Wave Path
136+
let mut wave = VectorPath::<16>::new();
137+
let bx = bot_area.x + 10;
138+
let by = bot_area.y + 26;
139+
let wave_offset = ((frame as f32 * 0.1).sin() * 12.0) as i32;
140+
141+
wave.move_to(Point::new(bx, by))
142+
.quad_to(
143+
Point::new(bx + 40, by - 16 + wave_offset),
144+
Point::new(bx + 80, by),
145+
)
146+
.cubic_to(
147+
Point::new(bx + 120, by + 16 - wave_offset),
148+
Point::new(bx + 160, by - 16 + wave_offset),
149+
Point::new(bx + 200, by),
150+
)
151+
.quad_to(
152+
Point::new(bx + 240, by + 16 - wave_offset),
153+
Point::new(bx + 280, by),
154+
);
155+
156+
ctx.draw_vector_path(&wave, StrokeStyle::new(Rgb565::CSS_LIME).with_width(2))?;
157+
ctx.draw_text(
158+
bot_area.x + 8,
159+
bot_area.y + 6,
160+
"Vector Bézier Path (Quad & Cubic Segments)",
161+
Rgb565::CSS_LIGHT_GRAY,
162+
)?;
163+
164+
Ok(())
165+
}
166+
167+
fn main() {
168+
println!("=== embedded-gui: LVGL Parity Showcase ===");
169+
170+
let res = std::panic::catch_unwind(|| {
171+
run_interactive();
172+
});
173+
174+
if res.is_err() {
175+
println!("\n[Notice: Desktop display window not available in current environment]");
176+
println!("[Running headless performance & parity verification...]\n");
177+
run_headless();
178+
}
179+
}
180+
181+
fn run_interactive() {
182+
let mut fb = Framebuffer::<FB_SIZE>::new(W, H);
183+
let mut display = SimulatorDisplay::<Rgb565>::new(Size::new(W, H));
184+
let settings = OutputSettingsBuilder::new().scale(2).build();
185+
let mut window = Window::new(
186+
"LVGL Parity: Scale, Table, Spinbox, Grid, Béziers",
187+
&settings,
188+
);
189+
190+
let data: &[&[&str]] = &[
191+
&["Sensor 1", "24.5 C", "OK"],
192+
&["Sensor 2", "58.2 %", "HIGH"],
193+
];
194+
let headers: &[&str] = &["Device", "Value", "State"];
195+
let mut table = TableWidget::new(data)
196+
.with_headers(headers)
197+
.with_selection(0, 0);
198+
199+
let mut spinbox = SpinboxWidget::new(0, 9999, 2500)
200+
.with_digits(4)
201+
.with_decimals(2);
202+
203+
let mut frame = 0u32;
204+
let mut paused = false;
205+
206+
'main_loop: loop {
207+
if !paused {
208+
frame = frame.wrapping_add(1);
209+
}
210+
211+
render_showcase(&mut fb, frame, &table, &spinbox).unwrap();
212+
213+
let full_area = Rectangle::new(Point::zero(), Size::new(W, H));
214+
display
215+
.fill_contiguous(&full_area, fb.pixels().iter().copied())
216+
.unwrap();
217+
window.update(&display);
218+
219+
for event in window.events() {
220+
match event {
221+
SimulatorEvent::Quit => break 'main_loop,
222+
SimulatorEvent::KeyDown { keycode, .. } => match keycode {
223+
Keycode::Escape | Keycode::Q => break 'main_loop,
224+
Keycode::Space => paused = !paused,
225+
Keycode::Left => {
226+
table.move_cursor(0, -1);
227+
spinbox.prev_digit();
228+
}
229+
Keycode::Right => {
230+
table.move_cursor(0, 1);
231+
spinbox.next_digit();
232+
}
233+
Keycode::Up => {
234+
table.move_cursor(-1, 0);
235+
spinbox.increment();
236+
}
237+
Keycode::Down => {
238+
table.move_cursor(1, 0);
239+
spinbox.decrement();
240+
}
241+
_ => {}
242+
},
243+
_ => {}
244+
}
245+
}
246+
std::thread::sleep(std::time::Duration::from_millis(16));
247+
}
248+
}
249+
250+
fn run_headless() {
251+
let mut fb = Framebuffer::<FB_SIZE>::new(W, H);
252+
let data: &[&[&str]] = &[&["A", "1"], &["B", "2"]];
253+
let headers: &[&str] = &["K", "V"];
254+
let table = TableWidget::new(data).with_headers(headers);
255+
let spinbox = SpinboxWidget::new(0, 999, 100);
256+
257+
println!("Running 60 frames of full LVGL parity showcase scene in headless mode...");
258+
let t0 = std::time::Instant::now();
259+
for f in 0..60 {
260+
render_showcase(&mut fb, f, &table, &spinbox).unwrap();
261+
}
262+
println!("-> 60 frames rendered in: {:?}", t0.elapsed());
263+
println!("LVGL Parity verification complete!");
264+
}

src/context/builders.rs

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ use crate::{
55
render::TextAlign,
66
style::{Style, WidgetStyle},
77
widget::{FocusGroupId, StyleClassId, Widget, WidgetId},
8-
widgets::{ChartMode, KeyboardLayout, NotificationLevel, SurfaceState, WidgetKind},
8+
widgets::{ChartMode, KeyboardLayout, NotificationLevel, ScaleMode, SurfaceState, WidgetKind},
99
};
10-
use embedded_graphics_core::pixelcolor::Rgb565;
10+
use embedded_graphics_core::pixelcolor::{Rgb565, WebColors};
1111

1212
use super::*;
1313

@@ -935,6 +935,96 @@ impl<'a, const NODES: usize, const EVENTS: usize, const DIRTY: usize>
935935
}
936936
}
937937

938+
#[cfg(feature = "rich-widgets")]
939+
pub fn add_scale<S>(
940+
&mut self,
941+
rect: Rect,
942+
mode: ScaleMode,
943+
min: f32,
944+
max: f32,
945+
value: f32,
946+
style: S,
947+
) -> Result<WidgetId, GuiError>
948+
where
949+
S: Into<WidgetStyle>,
950+
{
951+
self.add_widget(
952+
rect,
953+
WidgetKind::Scale {
954+
mode,
955+
value: value.clamp(min, max),
956+
min,
957+
max,
958+
major_ticks: 5,
959+
minor_ticks: 3,
960+
start_angle: 135,
961+
end_angle: 45,
962+
show_labels: true,
963+
show_needle: true,
964+
tick_color: Rgb565::CSS_GRAY,
965+
needle_color: Rgb565::CSS_RED,
966+
},
967+
style,
968+
)
969+
}
970+
971+
#[cfg(feature = "rich-widgets")]
972+
pub fn add_radial_scale<S>(
973+
&mut self,
974+
rect: Rect,
975+
min: f32,
976+
max: f32,
977+
value: f32,
978+
style: S,
979+
) -> Result<WidgetId, GuiError>
980+
where
981+
S: Into<WidgetStyle>,
982+
{
983+
self.add_scale(rect, ScaleMode::Radial, min, max, value, style)
984+
}
985+
986+
#[cfg(feature = "rich-widgets")]
987+
pub fn add_linear_scale<S>(
988+
&mut self,
989+
rect: Rect,
990+
min: f32,
991+
max: f32,
992+
value: f32,
993+
style: S,
994+
) -> Result<WidgetId, GuiError>
995+
where
996+
S: Into<WidgetStyle>,
997+
{
998+
self.add_scale(rect, ScaleMode::LinearHorizontal, min, max, value, style)
999+
}
1000+
1001+
#[cfg(feature = "rich-widgets")]
1002+
pub fn add_spinbox<S>(
1003+
&mut self,
1004+
rect: Rect,
1005+
min: i32,
1006+
max: i32,
1007+
value: i32,
1008+
style: S,
1009+
) -> Result<WidgetId, GuiError>
1010+
where
1011+
S: Into<WidgetStyle>,
1012+
{
1013+
self.add_widget(
1014+
rect,
1015+
WidgetKind::Spinbox {
1016+
value: value.clamp(min, max),
1017+
min,
1018+
max,
1019+
step: 1,
1020+
digits: 4,
1021+
decimals: 0,
1022+
focused_digit: 0,
1023+
},
1024+
style,
1025+
)
1026+
}
1027+
9381028
#[cfg(feature = "rich-widgets")]
9391029
pub fn add_textarea<S>(
9401030
&mut self,

0 commit comments

Comments
 (0)