Added TodoMVC example (Rust mock version) (#5396)

* Added TodoMVC example (Rust mock version)

* TodoMVC: use visible-width instead of width for selection items

and format

* TodoMVC: layout fix for qt checkbox

* TdodoMVC: fix license issues in the example

* Update examples/todo_mvc/ui/views/task_list_view.slint

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* TdodoMVC: fix license issues in the example

* TodoMVC: code review changes

* TodoMVC: code review changes

* Update .reuse/dep5

Co-authored-by: Simon Hausmann <simon.hausmann@slint.dev>

* Update examples/todo_mvc/rust/src/adapters/navigation_adapter.rs

Co-authored-by: Simon Hausmann <simon.hausmann@slint.dev>

* Update examples/todo_mvc/rust/src/adapters/navigation_adapter.rs

Co-authored-by: Simon Hausmann <simon.hausmann@slint.dev>

* TodoMVC: refactor task list model (code review feedback)

* TodoMVC: code review feedback

* Update examples/todo-mvc/rust/src/mvc/controllers/task_list_controller.rs

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* TodoMVC: add missing link in dep5

* dep5 fix

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Simon Hausmann <simon.hausmann@slint.dev>
This commit is contained in:
Florian Blasius 2024-06-13 11:05:44 +00:00 committed by GitHub
parent a2e10f8c78
commit 0870585c32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1651 additions and 1 deletions

View file

@ -48,6 +48,11 @@ jobs:
sed -i "s/#wasm# //" Cargo.toml sed -i "s/#wasm# //" Cargo.toml
wasm-pack build --release --target web wasm-pack build --release --target web
working-directory: examples/todo/rust working-directory: examples/todo/rust
- name: Todo mvc demo WASM build
run: |
sed -i "s/#wasm# //" Cargo.toml
wasm-pack build --release --target web
working-directory: examples/todo-mvc/rust
- name: Carousel demo WASM build - name: Carousel demo WASM build
run: | run: |
sed -i "s/#wasm# //" Cargo.toml sed -i "s/#wasm# //" Cargo.toml

View file

@ -104,6 +104,10 @@ Files: internal/compiler/widgets/qt/_*.svg
Copyright: Material Icons <https://github.com/material-icons/material-icons/blob/master/LICENSE> Copyright: Material Icons <https://github.com/material-icons/material-icons/blob/master/LICENSE>
License: Apache-2.0 License: Apache-2.0
Files: examples/todo-mvc/assets/*.svg
Copyright: Material Icons <https://github.com/material-icons/material-icons/blob/master/LICENSE>
License: Apache-2.0
Files: internal/compiler/widgets/cosmic-base/_*.svg Files: internal/compiler/widgets/cosmic-base/_*.svg
Copyright: "Cosmic Icons" by System76 <https://github.com/pop-os/cosmic-icons> Copyright: "Cosmic Icons" by System76 <https://github.com/pop-os/cosmic-icons>
License: CC-BY-SA-4.0 License: CC-BY-SA-4.0

View file

@ -26,6 +26,7 @@ members = [
'examples/printerdemo_mcu', 'examples/printerdemo_mcu',
'examples/slide_puzzle', 'examples/slide_puzzle',
'examples/todo/rust', 'examples/todo/rust',
'examples/todo-mvc/rust',
'examples/virtual_keyboard/rust', 'examples/virtual_keyboard/rust',
'examples/carousel/rust', 'examples/carousel/rust',
'examples/energy-monitor', 'examples/energy-monitor',

View file

@ -36,7 +36,7 @@ A fictional user interface of a device that monitors energy consumption in a bui
### [`todo`](./todo) ### [`todo`](./todo)
A simple todo mvc application A simple todo application
| `.slint` Design | Rust Source | C++ Source | NodeJS | Online wasm Preview | Open in SlintPad | | `.slint` Design | Rust Source | C++ Source | NodeJS | Online wasm Preview | Open in SlintPad |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
@ -44,6 +44,14 @@ A simple todo mvc application
![Screenshot of the Todo Demo](https://slint.dev/resources/todo_screenshot.png "Todo Demo") ![Screenshot of the Todo Demo](https://slint.dev/resources/todo_screenshot.png "Todo Demo")
### [`todo-mvc`](./todo-mvc)
A simple todo application based on the [Model View Controller](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller) pattern.
| `.slint` Design | Rust Source | Online wasm Preview | Open in SlintPad |
| --- | --- | --- | --- | --- | --- |
| [`todo.slint`](./todo/ui/todo.slint) | [`main.rs`](./todo/rust/main.rs) | [Online simulation](https://slint.dev/snapshots/master/demos/todo-mvc/) | [Preview in Online Code Editor](https://slint.dev/snapshots/master/editor?load_url=https://raw.githubusercontent.com/slint-ui/slint/master/examples/todo-mvc/ui/index.slint) |
### [`carousel`](./carousel) ### [`carousel`](./carousel)
A custom carousel widget that can be controlled by touch, mouse and keyboard A custom carousel widget that can be controlled by touch, mouse and keyboard

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed">
<path d="M440-440H200v-80h240v-240h80v240h240v80H520v240h-80v-240Z"/>
</svg>

After

Width:  |  Height:  |  Size: 187 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed">
<path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/>
</svg>

After

Width:  |  Height:  |  Size: 227 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368">
<path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/>
</svg>

After

Width:  |  Height:  |  Size: 324 B

View file

@ -0,0 +1,36 @@
# Copyright © SixtyFPS GmbH <info@slint.dev>
# SPDX-License-Identifier: MIT
[package]
name = "todo-mvc"
version = "1.7.0"
authors = ["Slint Developers <info@slint.dev>"]
edition = "2021"
build = "build.rs"
publish = false
license = "MIT"
[lib]
crate-type = ["lib", "cdylib"]
path = "src/lib.rs"
name = "todo_lib_mvc"
[[bin]]
path = "src/main.rs"
name = "todo-mvc"
[dependencies]
slint = { path = "../../../api/rs/slint", features = ["serde", "backend-android-activity-06"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4" }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = { version = "0.2" }
console_error_panic_hook = "0.1.5"
[build-dependencies]
slint-build = { path = "../../../api/rs/build" }
[dev-dependencies]
i-slint-backend-testing = { workspace = true }

View file

@ -0,0 +1,6 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
fn main() {
slint_build::compile("../ui/index.slint").unwrap();
}

View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<!-- Copyright © SixtyFPS GmbH <info@slint.dev> -->
<!-- SPDX-License-Identifier: MIT -->
<html>
<!--
This is a static html file used to display the wasm build.
In order to generate the build
- Run `wasm-pack build --release --target web` in this directory.
-->
<head>
<meta charset="UTF-8">
<title>Slint Todo MVC Demo (Web Assembly version)</title>
<link rel="stylesheet" href="https://slint.dev/css/demos-v1.css">
</head>
<body>
<p>This is the <a href="https://slint.dev">Slint</a> Todo Demo compiled to WebAssembly.</p>
<div id="spinner" style="position: relative;">
<div class="spinner">Loading...</div>
</div>
<canvas id="canvas" unselectable="on" data-slint-auto-resize-to-preferred="true"></canvas>
<p class="links">
<a href="https://github.com/slint-ui/slint/blob/master/examples/todo-mvc/">
View Source Code on GitHub</a> -
<a href="https://slint.dev/editor?load_demo=examples/todo-mvc/ui/index.slint">
Open in SlintPad
</a>
</p>
<script type="module">
import init from './pkg/todo_lib_mvc.js';
init().finally(() => {
document.getElementById("spinner").remove();
});
</script>
</body>
</html>

View file

@ -0,0 +1,46 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use std::cell::Cell;
type CallbackWrapper<Arguments, Result = ()> =
Cell<Option<Box<dyn FnMut(&Arguments, &mut Result)>>>;
pub struct Callback<Arguments: ?Sized, Result = ()> {
callback: CallbackWrapper<Arguments, Result>,
}
impl<Arguments: ?Sized, Res> Default for Callback<Arguments, Res> {
fn default() -> Self {
Self { callback: Default::default() }
}
}
impl<Arguments: ?Sized, Result: Default> Callback<Arguments, Result> {
pub fn on(&self, mut f: impl FnMut(&Arguments) -> Result + 'static) {
self.callback.set(Some(Box::new(move |a: &Arguments, r: &mut Result| *r = f(a))));
}
pub fn invoke(&self, a: &Arguments) -> Result {
let mut result = Result::default();
if let Some(mut callback) = self.callback.take() {
callback(a, &mut result);
self.callback.set(Some(callback));
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_invoke() {
let callback: Callback<(i32, i32), i32> = Callback::default();
callback.on(|(a, b)| a + b);
assert_eq!(callback.invoke(&(3, 2)), 5);
}
}

View file

@ -0,0 +1,39 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;
pub mod mvc;
pub mod ui;
mod callback;
pub use callback::*;
pub use slint::*;
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(start))]
pub fn main() {
let main_window = init();
main_window.run().unwrap();
}
fn init() -> ui::MainWindow {
let view_handle = ui::MainWindow::new().unwrap();
let task_list_controller = mvc::TaskListController::new(mvc::task_repo());
ui::task_list_adapter::connect(&view_handle, task_list_controller.clone());
ui::navigation_adapter::connect_task_list_controller(
&view_handle,
task_list_controller.clone(),
);
let create_task_controller = mvc::CreateTaskController::new(mvc::date_time_repo());
ui::create_task_adapter::connect(&view_handle, create_task_controller.clone());
ui::navigation_adapter::connect_create_task_controller(&view_handle, create_task_controller);
ui::create_task_adapter::connect_task_list_controller(&view_handle, task_list_controller);
view_handle
}
// FIXME: android example

View file

@ -0,0 +1,6 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
fn main() {
todo_lib_mvc::main();
}

View file

@ -0,0 +1,11 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
mod controllers;
pub use controllers::*;
mod models;
pub use models::*;
mod repositories;
pub use repositories::*;

View file

@ -0,0 +1,8 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
mod create_task_controller;
pub use create_task_controller::*;
mod task_list_controller;
pub use task_list_controller::*;

View file

@ -0,0 +1,120 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use std::rc::Rc;
use crate::mvc::{traits::DateTimeRepository, DateModel, TimeModel};
use crate::{mvc, Callback};
#[derive(Clone)]
pub struct CreateTaskController {
repo: Rc<dyn mvc::traits::DateTimeRepository>,
back_callback: Rc<Callback<(), ()>>,
}
impl CreateTaskController {
pub fn new(repo: impl DateTimeRepository + 'static) -> Self {
Self { repo: Rc::new(repo), back_callback: Rc::new(Callback::default()) }
}
pub fn current_date(&self) -> DateModel {
self.repo.current_date()
}
pub fn current_time(&self) -> TimeModel {
self.repo.current_time()
}
pub fn date_string(&self, date_model: DateModel) -> String {
self.repo.date_to_string(date_model)
}
pub fn time_string(&self, time_model: TimeModel) -> String {
self.repo.time_to_string(time_model)
}
pub fn back(&self) {
self.back_callback.invoke(&());
}
pub fn on_back(&self, mut callback: impl FnMut() + 'static) {
self.back_callback.on(move |()| {
callback();
});
}
pub fn time_stamp(&self, date_model: DateModel, time_model: TimeModel) -> i32 {
self.repo.time_stamp(date_model, time_model)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mvc::MockDateTimeRepository;
use std::cell::Cell;
fn test_controller() -> CreateTaskController {
CreateTaskController::new(MockDateTimeRepository::new(
DateModel { year: 2024, month: 6, day: 12 },
TimeModel { hour: 13, minute: 30, second: 29 },
15,
))
}
#[test]
fn test_current_date() {
let controller = test_controller();
assert_eq!(controller.current_date(), DateModel { year: 2024, month: 6, day: 12 });
}
#[test]
fn test_current_time() {
let controller = test_controller();
assert_eq!(controller.current_time(), TimeModel { hour: 13, minute: 30, second: 29 });
}
#[test]
fn test_date_string() {
let controller = test_controller();
assert_eq!(
controller.date_string(DateModel { year: 2020, month: 10, day: 5 }).as_str(),
"2020/10/5"
);
}
#[test]
fn test_time_string() {
let controller = test_controller();
assert_eq!(
controller.time_string(TimeModel { hour: 10, minute: 12, second: 55 }).as_str(),
"10:12"
);
}
#[test]
fn test_back() {
let controller = test_controller();
let callback_invoked = Rc::new(Cell::new(false));
controller.on_back({
let callback_invoked = callback_invoked.clone();
move || {
callback_invoked.set(true);
}
});
controller.back();
assert!(callback_invoked.get());
}
#[test]
fn test_time_stamp() {
let controller = test_controller();
assert_eq!(controller.time_stamp(DateModel::default(), TimeModel::default()), 15);
}
}

View file

@ -0,0 +1,198 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use std::rc::Rc;
use slint::Model;
use slint::ModelNotify;
use slint::ModelRc;
use slint::ModelTracker;
use crate::mvc;
use crate::Callback;
#[derive(Clone)]
pub struct TaskListController {
task_model: TaskModel,
show_create_task_callback: Rc<Callback<(), ()>>,
}
impl TaskListController {
pub fn new(repo: impl mvc::traits::TaskRepository + 'static) -> Self {
Self {
task_model: TaskModel::new(repo),
show_create_task_callback: Rc::new(Callback::default()),
}
}
pub fn task_model(&self) -> ModelRc<mvc::TaskModel> {
ModelRc::new(self.task_model.clone())
}
pub fn toggle_done(&self, index: usize) {
self.task_model.toggle_done(index)
}
pub fn remove_task(&self, index: usize) {
self.task_model.remove_task(index)
}
pub fn create_task(&self, title: &str, due_date: i64) {
self.task_model.push_task(mvc::TaskModel {
title: title.into(),
due_date,
..Default::default()
})
}
pub fn show_create_task(&self) {
self.show_create_task_callback.invoke(&());
}
pub fn on_show_create_task(&self, mut callback: impl FnMut() + 'static) {
self.show_create_task_callback.on(move |()| {
callback();
});
}
}
#[derive(Clone)]
struct TaskModel {
repo: Rc<dyn mvc::traits::TaskRepository>,
notify: Rc<ModelNotify>,
}
impl TaskModel {
fn new(repo: impl mvc::traits::TaskRepository + 'static) -> Self {
Self { repo: Rc::new(repo), notify: Rc::new(Default::default()) }
}
fn toggle_done(&self, index: usize) {
if !self.repo.toggle_done(index) {
return;
}
self.notify.row_changed(index)
}
fn remove_task(&self, index: usize) {
if !self.repo.remove_task(index) {
return;
}
self.notify.row_removed(index, 1)
}
fn push_task(&self, task: mvc::TaskModel) {
if !self.repo.push_task(task) {
return;
}
self.notify.row_added(self.row_count() - 1, 1);
}
}
impl Model for TaskModel {
type Data = mvc::TaskModel;
fn row_count(&self) -> usize {
self.repo.task_count()
}
fn row_data(&self, row: usize) -> Option<Self::Data> {
self.repo.get_task(row)
}
fn model_tracker(&self) -> &dyn ModelTracker {
self.notify.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mvc;
use std::cell::Cell;
fn test_controller() -> TaskListController {
TaskListController::new(mvc::MockTaskRepository::new(vec![
mvc::TaskModel { title: "Item 1".into(), due_date: 1, done: true },
mvc::TaskModel { title: "Item 2".into(), due_date: 1, done: false },
]))
}
#[test]
fn test_tasks() {
let controller = test_controller();
let task_model = controller.task_model();
assert_eq!(task_model.row_count(), 2);
assert_eq!(
task_model.row_data(0),
Some(mvc::TaskModel { title: "Item 1".into(), due_date: 1, done: true },)
);
assert_eq!(
task_model.row_data(1),
Some(mvc::TaskModel { title: "Item 2".into(), due_date: 1, done: false },)
);
}
#[test]
fn test_toggle_task_checked() {
let controller = test_controller();
let task_model = controller.task_model();
assert!(task_model.row_data(0).unwrap().done);
controller.toggle_done(0);
assert!(!task_model.row_data(0).unwrap().done);
}
#[test]
fn test_remove_task() {
let controller = test_controller();
let task_model = controller.task_model();
assert_eq!(task_model.row_count(), 2);
controller.remove_task(0);
assert_eq!(task_model.row_count(), 1);
assert_eq!(
task_model.row_data(0),
Some(mvc::TaskModel { title: "Item 2".into(), due_date: 1, done: false },)
);
}
#[test]
fn test_show_create_task() {
let controller = test_controller();
let callback_invoked = Rc::new(Cell::new(false));
controller.on_show_create_task({
let callback_invoked = callback_invoked.clone();
move || {
callback_invoked.set(true);
}
});
controller.show_create_task();
assert!(callback_invoked.get());
}
#[test]
fn test_add_task() {
let controller = test_controller();
let task_model = controller.task_model();
assert_eq!(task_model.row_count(), 2);
controller.create_task("Item 3", 3);
assert_eq!(task_model.row_count(), 3);
assert_eq!(
task_model.row_data(2),
Some(mvc::TaskModel { title: "Item 3".into(), due_date: 3, done: false },)
);
}
}

View file

@ -0,0 +1,11 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
mod date_model;
pub use date_model::*;
mod task_model;
pub use task_model::*;
mod time_model;
pub use time_model::*;

View file

@ -0,0 +1,9 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
#[derive(Copy, Clone, Default, Debug, PartialEq)]
pub struct DateModel {
pub year: i32,
pub month: u32,
pub day: u32,
}

View file

@ -0,0 +1,11 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
#[derive(Clone, Default, Debug, PartialEq)]
pub struct TaskModel {
pub title: String,
// due date in milliseconds
pub due_date: i64,
pub done: bool,
}

View file

@ -0,0 +1,9 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
#[derive(Copy, Clone, Default, Debug, PartialEq)]
pub struct TimeModel {
pub hour: u32,
pub minute: u32,
pub second: u32,
}

View file

@ -0,0 +1,32 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
mod mock_date_time_repository;
pub use mock_date_time_repository::*;
mod mock_task_repository;
pub use mock_task_repository::*;
use crate::mvc::models::{DateModel, TaskModel, TimeModel};
pub mod traits;
pub fn date_time_repo() -> impl traits::DateTimeRepository + Clone {
MockDateTimeRepository::new(
DateModel { year: 2024, month: 6, day: 11 },
TimeModel { hour: 16, minute: 43, second: 0 },
1718183634,
)
}
pub fn task_repo() -> impl traits::TaskRepository + Clone {
MockTaskRepository::new(vec![
TaskModel { title: "Learn Rust".into(), done: true, due_date: 1717686537151 },
TaskModel { title: "Learn Slint".into(), done: true, due_date: 1717686537151 },
TaskModel {
title: "Create project with Rust and Slint".into(),
done: true,
due_date: 1717686537151,
},
])
}

View file

@ -0,0 +1,45 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use crate::mvc;
use super::traits;
#[derive(Clone)]
pub struct MockDateTimeRepository {
current_date: mvc::DateModel,
current_time: mvc::TimeModel,
time_stamp: i32,
}
impl MockDateTimeRepository {
pub fn new(
current_date: mvc::DateModel,
current_time: mvc::TimeModel,
time_stamp: i32,
) -> Self {
Self { current_date, current_time, time_stamp }
}
}
impl traits::DateTimeRepository for MockDateTimeRepository {
fn current_date(&self) -> mvc::DateModel {
self.current_date
}
fn current_time(&self) -> mvc::TimeModel {
self.current_time
}
fn date_to_string(&self, date: mvc::DateModel) -> String {
format!("{}/{}/{}", date.year, date.month, date.day)
}
fn time_to_string(&self, time: mvc::TimeModel) -> String {
format!("{}:{}", time.hour, time.minute)
}
fn time_stamp(&self, _date: mvc::DateModel, _time: mvc::TimeModel) -> i32 {
self.time_stamp
}
}

View file

@ -0,0 +1,51 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use std::{cell::RefCell, rc::Rc};
use super::traits;
use crate::mvc;
#[derive(Clone)]
pub struct MockTaskRepository {
tasks: Rc<RefCell<Vec<mvc::TaskModel>>>,
}
impl MockTaskRepository {
pub fn new(tasks: Vec<mvc::TaskModel>) -> Self {
Self { tasks: Rc::new(RefCell::new(tasks)) }
}
}
impl traits::TaskRepository for MockTaskRepository {
fn task_count(&self) -> usize {
self.tasks.borrow().len()
}
fn get_task(&self, index: usize) -> Option<mvc::TaskModel> {
self.tasks.borrow().get(index).cloned()
}
fn toggle_done(&self, index: usize) -> bool {
if let Some(task) = self.tasks.borrow_mut().get_mut(index) {
task.done = !task.done;
return true;
}
false
}
fn remove_task(&self, index: usize) -> bool {
if index < self.tasks.borrow().len() {
self.tasks.borrow_mut().remove(index);
return true;
}
false
}
fn push_task(&self, task: mvc::TaskModel) -> bool {
self.tasks.borrow_mut().push(task);
true
}
}

View file

@ -0,0 +1,8 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
mod date_time_repository;
pub use date_time_repository::*;
mod task_repository;
pub use task_repository::*;

View file

@ -0,0 +1,12 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use crate::mvc;
pub trait DateTimeRepository {
fn current_date(&self) -> mvc::DateModel;
fn current_time(&self) -> mvc::TimeModel;
fn date_to_string(&self, date: mvc::DateModel) -> String;
fn time_to_string(&self, time: mvc::TimeModel) -> String;
fn time_stamp(&self, date: mvc::DateModel, time: mvc::TimeModel) -> i32;
}

View file

@ -0,0 +1,12 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use crate::mvc;
pub trait TaskRepository {
fn task_count(&self) -> usize;
fn get_task(&self, index: usize) -> Option<mvc::TaskModel>;
fn toggle_done(&self, index: usize) -> bool;
fn remove_task(&self, index: usize) -> bool;
fn push_task(&self, task: mvc::TaskModel) -> bool;
}

View file

@ -0,0 +1,8 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
slint::include_modules!();
pub mod create_task_adapter;
pub mod navigation_adapter;
pub mod task_list_adapter;

View file

@ -0,0 +1,108 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use slint::*;
use crate::{
mvc::{
{CreateTaskController, TaskListController}, {DateModel, TimeModel},
},
ui,
};
// a helper function to make adapter and controller connection a little bit easier
fn connect_with_controller(
view_handle: &ui::MainWindow,
controller: &CreateTaskController,
connect_adapter_controller: impl FnOnce(ui::CreateTaskAdapter, CreateTaskController) + 'static,
) {
connect_adapter_controller(view_handle.global::<ui::CreateTaskAdapter>(), controller.clone());
}
// a helper function to make adapter and controller connection a little bit easier
fn connect_with_task_list_controller(
view_handle: &ui::MainWindow,
controller: &TaskListController,
connect_adapter_controller: impl FnOnce(ui::CreateTaskAdapter, TaskListController) + 'static,
) {
connect_adapter_controller(view_handle.global::<ui::CreateTaskAdapter>(), controller.clone());
}
// one place to implement connection between adapter (view) and controller
pub fn connect(view_handle: &ui::MainWindow, controller: CreateTaskController) {
connect_with_controller(view_handle, &controller, {
move |adapter, controller| {
adapter.on_back(move || {
controller.back();
})
}
});
connect_with_controller(view_handle, &controller, {
move |adapter, controller| {
adapter.on_current_date(move || map_date_model_to_date(controller.current_date()))
}
});
connect_with_controller(view_handle, &controller, {
move |adapter, controller| {
adapter.on_current_time(move || map_time_model_to_time(controller.current_time()))
}
});
connect_with_controller(view_handle, &controller, {
move |adapter, controller| {
adapter.on_date_string(move |date| {
controller.date_string(map_date_to_date_model(date)).into()
})
}
});
connect_with_controller(view_handle, &controller, {
move |adapter, controller| {
adapter.on_time_string(move |time| {
controller.time_string(map_time_to_time_model(time)).into()
})
}
});
connect_with_controller(view_handle, &controller, {
move |adapter, controller| {
adapter.on_time_stamp(move |date, time| {
controller
.time_stamp(map_date_to_date_model(date), map_time_to_time_model(time))
.into()
})
}
});
}
pub fn connect_task_list_controller(view_handle: &ui::MainWindow, controller: TaskListController) {
connect_with_task_list_controller(view_handle, &controller, {
move |adapter, controller| {
adapter.on_create(move |title, time_stamp| {
controller.create_task(title.as_str(), time_stamp as i64)
})
}
});
}
fn map_time_model_to_time(time_model: TimeModel) -> ui::Time {
ui::Time {
hour: time_model.hour as i32,
minute: time_model.minute as i32,
second: time_model.second as i32,
}
}
fn map_time_to_time_model(time: ui::Time) -> TimeModel {
TimeModel { hour: time.hour as u32, minute: time.minute as u32, second: time.second as u32 }
}
fn map_date_model_to_date(date_model: DateModel) -> ui::Date {
ui::Date { year: date_model.year, month: date_model.month as i32, day: date_model.day as i32 }
}
fn map_date_to_date_model(date: ui::Date) -> DateModel {
DateModel { year: date.year, month: date.month as u32, day: date.day as u32 }
}

View file

@ -0,0 +1,34 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use slint::*;
use crate::{
mvc::{CreateTaskController, TaskListController},
ui,
};
// one place to implement connection between adapter (view) and controller
pub fn connect_create_task_controller(
view_handle: &ui::MainWindow,
controller: CreateTaskController,
) {
controller.on_back({
let view_handle = view_handle.as_weak();
move || {
view_handle.unwrap().global::<ui::NavigationAdapter>().invoke_previous_page();
}
});
}
// one place to implement connection between adapter (view) and controller
pub fn connect_task_list_controller(view_handle: &ui::MainWindow, controller: TaskListController) {
controller.on_show_create_task({
let view_handle = view_handle.as_weak();
move || {
view_handle.unwrap().global::<ui::NavigationAdapter>().invoke_next_page();
}
});
}

View file

@ -0,0 +1,66 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use chrono::DateTime;
use slint::*;
use std::rc::Rc;
use crate::{
mvc::{TaskListController, TaskModel},
ui,
};
// a helper function to make adapter and controller connection a little bit easier
pub fn connect_with_controller(
view_handle: &ui::MainWindow,
controller: &TaskListController,
connect_adapter_controller: impl FnOnce(ui::TaskListAdapter, TaskListController) + 'static,
) {
connect_adapter_controller(view_handle.global::<ui::TaskListAdapter>(), controller.clone());
}
// one place to implement connection between adapter (view) and controller
pub fn connect(view_handle: &ui::MainWindow, controller: TaskListController) {
// sets a mapped list of the task items to the ui
view_handle
.global::<ui::TaskListAdapter>()
.set_tasks(Rc::new(MapModel::new(controller.task_model(), map_task_to_item)).into());
connect_with_controller(view_handle, &controller, {
move |adapter, controller| {
adapter.on_toggle_task_checked(move |index| {
controller.toggle_done(index as usize);
})
}
});
connect_with_controller(view_handle, &controller, {
move |adapter, controller| {
adapter.on_remove_task(move |index| {
controller.remove_task(index as usize);
})
}
});
connect_with_controller(view_handle, &controller, {
move |adapter: ui::TaskListAdapter, controller| {
adapter.on_show_create_task(move || {
controller.show_create_task();
})
}
});
}
// maps a TaskModel (data) to a SelectionItem (ui)
fn map_task_to_item(task: TaskModel) -> ui::SelectionListViewItem {
ui::SelectionListViewItem {
text: task.title.into(),
checked: task.done,
description: DateTime::from_timestamp_millis(task.due_date)
.unwrap()
// example: Thu, Jun 6, 2024 16:29
.format("%a, %b %d, %Y %H:%M")
.to_string()
.into(),
}
}

View file

@ -0,0 +1,47 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { TaskListView, TaskListAdapter } from "./views/task_list_view.slint";
export { TaskListAdapter }
import { CreateTaskView, CreateTaskAdapter } from "./views/create_task_view.slint";
export { CreateTaskAdapter }
import { AnimationSettings } from "./widgets/styling.slint";
export global NavigationAdapter {
out property <int> current-page;
public function next-page() {
root.current-page += 1;
}
public function previous-page() {
root.current-page = max(0, root.current-page - 1);
}
}
export component MainWindow inherits Window {
preferred-width: 400px;
preferred-height: 600px;
title: "Slint todo mvc example";
layout := HorizontalLayout {
x: -(NavigationAdapter.current-page * root.width);
TaskListView {
width: root.width;
height: root.height;
}
CreateTaskView {
width: root.width;
height: root.height;
}
animate x {
duration: AnimationSettings.move-duration;
easing: AnimationSettings.move-easing;
}
}
}

View file

@ -0,0 +1,159 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { Date, Time, LineEdit, TimePicker, DatePicker, VerticalBox, Button } from "std-widgets.slint";
import { IconButton } from "../widgets/icon_button.slint";
import { TextButton } from "../widgets/text_button.slint";
import { Icons, FontSettings, TodoPalette, SpaceSettings } from "../widgets/styling.slint";
export global CreateTaskAdapter {
in-out property <Date> due-date: { year: 2024, month: 12, day: 24 };
in-out property <Time> due-time: { hour: 12, minute: 45, second: 0 };
callback create(/* title */ string, /* due-date-time */ int);
callback back();
pure callback date-string(Date) -> string;
pure callback time-string(Time) -> string;
pure callback current-date() -> Date;
pure callback current-time() -> Time;
pure callback time-stamp(/* date */ Date, /* time */ Time) -> int;
// dummy implementation for live preview
date-string(due-date) => {
"Sat, Jun 1, 2024"
}
// dummy implementation for live preview
time-string(due-time) => {
"09:00"
}
}
export component CreateTaskView {
VerticalBox {
HorizontalLayout {
IconButton {
icon: Icons.close;
clicked => {
root.reset();
CreateTaskAdapter.back();
}
}
// spacer
Rectangle {}
Button {
text: @tr("DONE");
enabled: title-input.text != "";
primary: true;
clicked => {
root.done();
}
}
}
VerticalLayout {
spacing: SpaceSettings.default-spacing;
Text {
text: @tr("Task name");
color: TodoPalette.foreground;
font-size: FontSettings.body-strong.font-size;
font-weight: FontSettings.body-strong.font-weight;
horizontal-alignment: left;
overflow: elide;
}
title-input := LineEdit {
placeholder-text: @tr("Describe your task");
}
}
Text {
text: @tr("Due date");
color: TodoPalette.foreground;
font-size: FontSettings.body-strong.font-size;
font-weight: FontSettings.body-strong.font-weight;
horizontal-alignment: left;
overflow: elide;
}
HorizontalLayout {
spacing: SpaceSettings.default-spacing;
TextButton {
text: CreateTaskAdapter.date-string(CreateTaskAdapter.due-date);
clicked => {
date-picker.show();
}
}
TextButton {
text: CreateTaskAdapter.time-string(CreateTaskAdapter.due-time);
horizontal-stretch: 0;
clicked => {
time-picker.show();
}
}
}
Rectangle {}
}
date-picker := PopupWindow {
x: (root.width - 360px) / 2;
y: (root.height - 524px) / 2;
width: 360px;
height: 524px;
close-on-click: false;
DatePicker {
canceled => {
date-picker.close();
}
accepted(date) => {
CreateTaskAdapter.due-date = date;
date-picker.close();
}
}
}
time-picker := PopupWindow {
x: (root.width - 340px) / 2;
y: (root.height - 500px) / 2;
width: 340px;
height: 500px;
close-on-click: false;
TimePicker {
canceled => {
time-picker.close();
}
accepted(time) => {
CreateTaskAdapter.due-time = time;
time-picker.close();
}
}
}
function reset() {
title-input.text = "";
CreateTaskAdapter.due-date = CreateTaskAdapter.current-date();
CreateTaskAdapter.due-time = CreateTaskAdapter.current-time();
}
function done() {
CreateTaskAdapter.back();
CreateTaskAdapter.create(title-input.text, CreateTaskAdapter.time-stamp(CreateTaskAdapter.due-date, CreateTaskAdapter.due-time));
root.reset();
}
}

View file

@ -0,0 +1,71 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { VerticalBox } from "std-widgets.slint";
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { SelectionListView, SelectionListViewItem } from "../widgets/selection_list_view.slint";
export { SelectionListViewItem }
import { ActionButton } from "../widgets/action_button.slint";
import { Icons } from "../widgets/styling.slint";
export global TaskListAdapter {
in-out property <[SelectionListViewItem]> tasks: [
// this is only dummy data for the preview
{ text: "Contribute to Slint", description: "2024/11/11 13:13" },
{ text: "Open a discussion on GitHub", description: "2024/11/11 13:13" },
{ text: "Write some documentation", description: "2024/11/11 13:13" }
];
callback toggle-task-checked(/* index */ int);
callback remove-task(/* index */ int);
callback show-create-task();
// this is only a dummy implementation for the preview
toggle-task-checked(index) => {
root.tasks[index] = {
text: root.item(index).text,
checked: !root.item(index).checked,
description: root.item(index).description
};
}
function item(index: int) -> SelectionListViewItem {
root.tasks[index]
}
}
export component TaskListView {
VerticalBox {
padding-top: 0;
padding-left: 0;
padding-right: 0;
SelectionListView {
width: 100%;
model: TaskListAdapter.tasks;
toggle(index) => {
TaskListAdapter.toggle-task-checked(index);
}
remove(index) => {
TaskListAdapter.remove-task(index);
}
}
HorizontalLayout {
alignment: center;
ActionButton {
icon: Icons.add;
clicked => {
TaskListAdapter.show-create-task();
}
}
}
}
}

View file

@ -0,0 +1,54 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { SizeSettings, TodoPalette } from "styling.slint";
import { FocusTouchArea } from "focus_touch_area.slint";
import { StateLayer } from "./state_layer.slint";
export component ActionButton {
callback clicked;
in property <image> icon;
horizontal-stretch: 0;
vertical-stretch: 0;
forward-focus: touch-area;
width: self.height;
height: SizeSettings.control-big-height;
touch-area := FocusTouchArea {
width: 100%;
height: 100%;
clicked => {
root.clicked();
}
}
background-layer := Rectangle {
width: 100%;
height: 100%;
background: TodoPalette.accent-background;
border-radius: self.height / 2;
}
StateLayer {
width: 100%;
height: 100%;
border-radius: background-layer.border-radius;
pressed: touch-area.pressed || touch-area.enter-pressed;
has-focus: touch-area.has-focus;
has-hover: touch-area.has-hover;
}
content-layer := HorizontalLayout {
alignment: center;
icon-image := Image {
source: root.icon;
height: SizeSettings.control-icon-big-height;
y: (parent.height - self.height) / 2;
colorize: TodoPalette.accent-foreground;
}
}
}

View file

@ -0,0 +1,53 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
export component FocusTouchArea {
in property <bool> enabled: true;
out property <bool> has-focus <=> focus-scope.has-focus;
out property <bool> pressed <=> touch-area.pressed;
out property <bool> has-hover <=> touch-area.has-hover;
out property <bool> enter-pressed;
in property <MouseCursor> mouse-cursor <=> touch-area.mouse-cursor;
callback clicked <=> touch-area.clicked;
forward-focus: focus-scope;
focus-scope := FocusScope {
x: 0;
width: 0px;
enabled: root.enabled;
key-pressed(event) => {
if !root.enabled {
return reject;
}
if (event.text == " " || event.text == "\n") && !root.enter-pressed {
root.enter-pressed = true;
touch-area.clicked();
return accept;
}
reject
}
key-released(event) => {
if !root.enabled {
return reject;
}
if (event.text == " " || event.text == "\n") && root.enter-pressed {
root.enter-pressed = false;
return accept;
}
reject
}
}
touch-area := TouchArea {
enabled: root.enabled;
}
}

View file

@ -0,0 +1,47 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { SizeSettings, TodoPalette } from "styling.slint";
import { FocusTouchArea } from "focus_touch_area.slint";
import { StateLayer } from "./state_layer.slint";
export component IconButton {
callback clicked;
in property <image> icon;
horizontal-stretch: 0;
vertical-stretch: 0;
forward-focus: touch-area;
width: self.height;
height: SizeSettings.control-height;
touch-area := FocusTouchArea {
width: 100%;
height: 100%;
clicked => {
root.clicked();
}
}
StateLayer {
width: 100%;
height: 100%;
border-radius: self.height / 2;
pressed: touch-area.pressed || touch-area.enter-pressed;
has-focus: touch-area.has-focus;
has-hover: touch-area.has-hover;
}
content-layer := HorizontalLayout {
alignment: center;
icon-image := Image {
source: root.icon;
height: SizeSettings.control-icon-height;
y: (parent.height - self.height) / 2;
colorize: TodoPalette.foreground;
}
}
}

View file

@ -0,0 +1,108 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { CheckBox, ListView, HorizontalBox } from "std-widgets.slint";
import { StateLayer } from "./state_layer.slint";
import { FocusTouchArea } from "./focus_touch_area.slint";
import { SizeSettings, TodoPalette, FontSettings, Icons } from "styling.slint";
import { IconButton } from "icon_button.slint";
@rust-attr(derive(serde::Serialize, serde::Deserialize))
export struct SelectionListViewItem {
text: string,
checked: bool,
description: string,
}
export component SelectionListViewItemDelegate {
callback toggle;
callback remove;
in property <string> text <=> text-label.text;
in property <string> description <=> description-label.text;
in-out property <bool> checked <=> check-box.checked;
min-width: content-layer.min-width;
min-height: max(SizeSettings.control-height, content-layer.min-height);
forward-focus: touch-area;
touch-area := FocusTouchArea {
width: 100%;
height: 100%;
clicked => {
root.toggle();
}
}
StateLayer {
width: 100%;
height: 100%;
focus-padding: -1px;
pressed: touch-area.pressed || touch-area.enter-pressed;
has-focus: touch-area.has-focus;
has-hover: touch-area.has-hover;
}
content-layer := HorizontalBox {
check-box := CheckBox {
horizontal-stretch: 0;
y: (parent.height - self.height) / 2;
toggled => {
root.toggle();
}
}
VerticalLayout {
alignment: center;
text-label := Text {
horizontal-alignment: left;
color: TodoPalette.foreground;
font-size: FontSettings.body-strong.font-size;
font-weight: FontSettings.body-strong.font-weight;
overflow: elide;
}
description-label := Text {
color: TodoPalette.foreground;
font-size: FontSettings.body.font-size;
font-weight: FontSettings.body.font-weight;
overflow: elide;
}
}
IconButton {
y: (parent.height - self.height) / 2;
icon: Icons.remove;
clicked => {
root.remove();
}
}
}
}
export component SelectionListView inherits ListView {
in property <[SelectionListViewItem]> model;
callback toggle(/* index */ int);
callback remove(/* index */ int);
for item[index] in root.model : SelectionListViewItemDelegate {
width: root.visible-width;
text: item.text;
description: item.description;
checked: item.checked;
toggle => {
root.toggle(index);
}
remove => {
root.remove(index);
}
}
}

View file

@ -0,0 +1,54 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { TodoPalette, AnimationSettings } from "styling.slint";
export component StateLayer {
// styling
in property <length> border-radius <=> state-layer.border-radius;
// states
in property <bool> pressed;
in property <bool> has-hover;
in property <bool> has-focus;
in property <length> focus-padding: 2px;
focus-border := Rectangle {
x: (root.width - self.width) / 2;
y: (root.height - self.height) / 2;
width: root.width + 2 * root.focus-padding;
height: root.height + 2 * root.focus-padding;
border-radius: state-layer.border-radius + root.focus-padding;
border-width: 1px;
border-color: TodoPalette.focus-border;
opacity: 0;
states [
focused when root.has-focus : {
opacity: 1;
}
]
animate opacity {
duration: AnimationSettings.color-duration;
}
}
state-layer := Rectangle {
width: 100%;
height: 100%;
animate background {
duration: AnimationSettings.color-duration;
}
}
states [
pressed when root.pressed : {
state-layer.background: TodoPalette.state-pressed;
}
hoverd when root.has-hover: {
state-layer.background: TodoPalette.state-hovered;
}
]
}

View file

@ -0,0 +1,58 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { Palette } from "std-widgets.slint";
export global AnimationSettings {
out property <duration> color-duration: 200ms;
out property <duration> move-duration: 400ms;
out property <easing> move-easing: cubic-bezier(0.3, 0.0, 0.8, 0.15);
}
export global TodoPalette {
out property <brush> foreground: Palette.foreground;
out property <brush> accent-background: Palette.accent-background;
out property <brush> accent-foreground: Palette.accent-foreground;
out property <brush> focus-border: Palette.accent-background;
out property <brush> state-pressed: root.dark-color-scheme ? #ffffff.with-alpha(0.3) : #000000.with-alpha(0.3);
out property <brush> state-hovered: root.dark-color-scheme ? #ffffff.with-alpha(0.2) : #000000.with-alpha(0.2);
property <bool> dark-color-scheme: Palette.color-scheme == ColorScheme.dark;
}
export struct TextStyle {
font-size: relative-font-size,
font-weight: int,
}
export global FontSettings {
out property <int> light-font-weight: 300;
out property <int> regular-font-weight: 500;
out property <int> semi-bold-font-weight: 600;
out property <TextStyle> body: {
font-size: 14 * 0.0769rem,
font-weight: root.light-font-weight,
};
out property <TextStyle> body-strong: {
font-size: 16 * 0.0769rem,
font-weight: root.semi-bold-font-weight,
};
}
export global SizeSettings {
out property <length> control-icon-height: 16px;
out property <length> control-icon-big-height: 32px;
out property <length> control-height: 32px;
out property <length> control-big-height: 48px;
}
export global SpaceSettings {
out property <length> default-spacing: 4px;
out property <length> default-padding: 8px;
}
export global Icons {
out property <image> add: @image-url("../../assets/add.svg");
out property <image> remove: @image-url("../../assets/remove.svg");
out property <image> close: @image-url("../../assets/close.svg");
}

View file

@ -0,0 +1,45 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { SizeSettings, SpaceSettings, FontSettings, TodoPalette } from "styling.slint";
import { FocusTouchArea } from "focus_touch_area.slint";
import { StateLayer } from "./state_layer.slint";
export component TextButton {
callback clicked;
in property <string> text;
vertical-stretch: 0;
forward-focus: touch-area;
min-width: content-layer.min-width;
min-height: max(content-layer.min-height, SizeSettings.control-height);
touch-area := FocusTouchArea {
width: 100%;
height: 100%;
clicked => {
root.clicked();
}
}
StateLayer {
width: 100%;
height: 100%;
pressed: touch-area.pressed || touch-area.enter-pressed;
has-focus: touch-area.has-focus;
has-hover: touch-area.has-hover;
}
content-layer := HorizontalLayout {
Text {
text: root.text;
horizontal-alignment: left;
vertical-alignment: center;
font-size: FontSettings.body.font-size;
font-weight: FontSettings.body.font-weight;
color: TodoPalette.foreground;
}
}
}