Last active
November 9, 2024 00:01
-
-
Save shock/67bcb28fbff5f6a6a5eda0add4311efc to your computer and use it in GitHub Desktop.
A Slint / Rust demonstration showing some surprising (to me) behavior
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
use std::{cell::RefCell, rc::Rc, time::Duration}; | |
use slint::{Image, Rgba8Pixel, SharedPixelBuffer, Timer, TimerMode, SharedString}; | |
slint::slint! { | |
import { LineEdit, HorizontalBox, Slider, VerticalBox, CheckBox, SpinBox, TextEdit } from "std-widgets.slint"; | |
export global Properties { // trying separate global structs just in case: | |
in-out property <int> counter: 0; // https://github.com/slint-ui/slint/discussions/3948 | |
in-out property <float> red: 0.65; | |
} | |
export global Backend { | |
pure callback build_image(length, length) -> image; | |
in-out property <bool> checked; | |
in-out property <string> some-text; // this causes an image redraw for test case: shared-text | |
in-out property <float> cursor_pos: 0.0; // this does not cause an image redraw - updated by backend only | |
} | |
enum TestType { Text, TextInput, LineEdit, TextEdit, shared-text } | |
export component MainWindow inherits Window { | |
title: "redraw"; | |
background: #222222; | |
preferred_width: 500px; | |
preferred_height: 400px; | |
// NOTE: TextEdit is the only component that behaves like I expect. I expect the others to behave like it. | |
property <TestType> test_case: | |
// Uncomment one of the following to test different components: | |
TestType.TextEdit; // does not cause an image redraw, even when global property is involved. hooray. | |
// TestType.Text; // causes an image redraw when a global property is involved | |
// TestType.TextInput; // causes an image redraw even when NO property is involved | |
// TestType.LineEdit; // causes an image redraw even when property is NOT involved - edit to see | |
// TestType.shared-text; // causes an image redraw any time the backend modifies the text | |
VerticalBox { | |
HorizontalBox { | |
max-height: 40px; | |
// TextEdit is the only "Text" component that doesn't cause a redraw when the | |
// counter property changes or when user modifies the text directly. | |
if (test_case == TestType.TextEdit) : TextEdit { | |
text: "C: " + Properties.counter; | |
min-width: 100px; min-height: 30px; | |
} | |
// TextInput causes a redraw when text is changed whether or not a property is involved. | |
if (test_case == TestType.TextInput) : TextInput { | |
text: "click me and edit"; | |
min-width: 100px; min-height: 30px; | |
vertical-alignment: center; | |
} | |
// Text causes a redraw when the counter property changes. | |
if (test_case == TestType.Text) : Text { | |
text: "C: " + Properties.counter; | |
min-width: 100px; min-height: 30px; | |
vertical-alignment: center; | |
} | |
// LineEdit causes a redraw with or without the property change. | |
if (test_case == TestType.LineEdit) : LineEdit { | |
min-width: 100px; min-height: 30px; | |
text: "C: " + Properties.counter; | |
} | |
// Text causes a redraw every time the backend modifies the text. | |
if (test_case == TestType.shared-text) : Text { | |
text: Backend.some-text; | |
vertical-alignment: center; | |
width: 100px; | |
} | |
// SpinBox does not cause a redraw whether the user or the backend modifies the value. | |
SpinBox { | |
minimum: 0; | |
value <=> Properties.counter; | |
accessible-role: AccessibleRole.tab-list; | |
maximum: 10000; | |
} | |
Slider { | |
width: 100%; | |
minimum: 0.0; | |
maximum: 120.0; | |
value: Properties.counter; // one-way does not cause a redraw | |
} | |
} | |
HorizontalBox { | |
Text { text: "Red"; vertical-alignment: center; width: 30px; } | |
Slider { | |
min-width: 100px; | |
minimum: 0.0; | |
maximum: 1.0; | |
value <=> Properties.red; // two-way binding causes a redraw when the slider is moved. | |
// one-way binding does not. | |
} | |
Text { text: "Checked"; vertical-alignment: center; width: 50px; } | |
CheckBox { | |
checked <=> Backend.checked; // this causes two redraws when checked, but just one when unchecked. | |
// I don't expect it to cause any. | |
} | |
} | |
Rectangle { | |
pure function build_image(width: length, height: length) -> image { | |
debug("build image: " + width/1px + "x" + height/1px); | |
return Backend.build_image(width, height); | |
} | |
ri := Rectangle{ | |
preferred-width: 400px; | |
preferred-height: 300px; | |
min-width: 0px; min-height: 0px; | |
// In my real app, the image is very expensive to render, so I need to only render it | |
// when it has changed. I've reduced my code to the simplest example that demonstrates | |
// my problem. My actual code is more elaborate in how it manages state and when | |
// to manually trigger an image redraw based on all the factors. | |
// In this simplest example I use to illustrate, the image source is a pure backend | |
// callback and only depends on the width and height of the image, so it should only be | |
// called when the size changes, right? Indeed, it does get called when the size changes, | |
// but it *also* gets called when the Text, TextInput, and LineEdit components are changed. | |
// Why it gets triggered by the Text, TextInput, and LineEdit components, but | |
// not the SpinBox or TextEdit is the mystery. | |
// It also gets triggered by the Properties.red slider change, but only if it has a | |
// two-way binding. Same with the Backend.checked and the checkbox. I don't understand | |
// that either. | |
Image { | |
x: 0; y: 0; | |
width: 100%; height: 100%; | |
source: build_image(self.width, self.height); | |
} | |
Rectangle { // the would-be playback cursor for my audio application, this works. Updating its | |
// position Backend.cursor_pos moves the curosor without triggering an image redraw. | |
// That's the way I expect all property changes affect image redraws, unless | |
// I manually wire them up to to force a redraw. | |
x: Backend.cursor_pos * ri.width; | |
width: 10px; | |
height: ri.height; | |
background: #ff0000aa; | |
} | |
} | |
Text { text: "RESIZING THE WINDOW\nREDRAWS THE IMAGE\nAS DESIRED"; vertical-alignment: center; horizontal-alignment: center; } | |
} | |
Slider { | |
width: 100%; | |
minimum: 0.0; maximum: 1.0; | |
value: Backend.cursor_pos; | |
changed => { Backend.cursor_pos = self.value; } | |
} | |
} | |
} | |
} | |
struct AppState { | |
image_calls: u32, | |
inverted: bool, | |
} | |
fn build_resizable_image(width: u32, height: u32, red: u8, mut green: u8, mut blue: u8, inverted: bool) -> Image { | |
let mut pixel_buffer = SharedPixelBuffer::<Rgba8Pixel>::new(width, height); | |
let bytes = pixel_buffer.make_mut_bytes(); | |
if inverted { green = 255 - green; blue = 255 - blue; } | |
for x in 0..width { | |
for y in 0..height { | |
let sx = x as f32 / 50.0; let sy = y as f32 / 50.0; | |
let checkered = ((sx % 2.0).floor() + (sy % 2.0).floor()) % 2.0; | |
let index = (y * width * 4 + x * 4) as usize; | |
if checkered == 0.0 { | |
bytes[index] = red; bytes[index + 1] = blue; bytes[index + 2] = green; bytes[index + 3] = 255; | |
} else { | |
bytes[index] = 0; bytes[index + 1] = 0; bytes[index + 2] = 0; bytes[index + 3] = 255; | |
}; | |
} | |
} | |
Image::from_rgba8(pixel_buffer) | |
} | |
fn main() { | |
let app = MainWindow::new().unwrap(); | |
let backend = app.global::<Backend>(); | |
let app_state = Rc::new(RefCell::new(AppState { | |
image_calls: 0, | |
inverted: false, | |
})); | |
backend.on_build_image({ | |
let app_weak = app.as_weak(); | |
let app_state = Rc::clone(&app_state); | |
move |width, height| { | |
let app = app_weak.unwrap(); | |
let width = width as u32; | |
let height = height as u32; | |
let mut app_state = app_state.borrow_mut(); | |
app_state.image_calls += 1; | |
let image_calls = app_state.image_calls; | |
println!("\nimage_calls: {}", image_calls); | |
println!("width: {}, height: {}", width, height); | |
let props = app.global::<Properties>(); | |
let red = (props.get_red() * 255.0) as u8; | |
app_state.inverted = !app_state.inverted; | |
build_resizable_image(width, height, red, 180, 195, app_state.inverted) | |
} | |
}); | |
let timer = Timer::default(); | |
let app_weak = app.as_weak(); | |
// create events 60 times a second to trigger property changes | |
let mut frame: i32 = 0; | |
let interval = Duration::from_millis(1000 / 60); | |
timer.start(TimerMode::Repeated, interval, { | |
let app = app_weak.upgrade().unwrap(); | |
move || { | |
if frame == 60 { | |
let counter = app.global::<Properties>().get_counter() + 1; | |
frame = 0; | |
app.global::<Properties>().set_counter(counter); | |
app.global::<Backend>().set_some_text(SharedString::from(format!("{}", counter))); | |
} | |
app.global::<Backend>().set_cursor_pos(frame as f32 / 60.0); | |
frame += 1; | |
} | |
}); | |
app.run().unwrap(); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment