Add a (useful) example: a todo list.

- Adds a Todo list example.
- Updates ListView to support ending the row actions visible state.
- Support setting Label text color.
This commit is contained in:
Ryan McGrath 2021-02-07 23:37:25 -08:00
parent 3a77fd8a91
commit 724b40e5a8
No known key found for this signature in database
GPG key ID: DA6CBD9233593DEA
24 changed files with 1246 additions and 1 deletions

View file

@ -42,6 +42,12 @@ fn main() {
For more thorough examples, check the `examples/` folder - for each UI control that's supported there will (ideally) be an example to accompany it. For more thorough examples, check the `examples/` folder - for each UI control that's supported there will (ideally) be an example to accompany it.
If you're interested in a more "kitchen sink" example, check out the todos_list with:
``` sh
cargo run --example todos_list
```
## Initialization ## Initialization
Due to the way that AppKit and UIKit programs typically work, you're encouraged to do the bulk Due to the way that AppKit and UIKit programs typically work, you're encouraged to do the bulk
of your work starting from the `did_finish_launching()` method of your `AppDelegate`. This of your work starting from the `did_finish_launching()` method of your `AppDelegate`. This

View file

@ -0,0 +1,21 @@
# Tasks Example
This example implements a "full featured" macOS app, completely in Rust. Notably, it showcases the following:
- Working menu(s), with dispatchable actions.
- A cached, reusable `ListView`.
- Cell configuration and styling.
- Row actions: complete a task, mark a task as incomplete.
- Basic animation support.
- Self-sizing rows based on autolayout.
- Custom widget composition (see `preferences/toggle_option_view.rs`.
- Multiple windows.
- Toolbars per-window.
- Button and Toolbar Items, with actions.
- Running a window as a modal sheet.
- Autolayout to handle UI across the board.
- Message dispatch (a bit jank, but hey, that's fine for now).
- Standard "Preferences" screens
- A general and advanced pane, with pane selection.
- Looks correct on both Big Sur, as well as Catalina and earlier.
While the Cacao API is still subject to changes and revisions, this hopefully illustrates what's possible with everything as it currently exists, and provides an entry point for outside contributors to get their feet wet.

View file

@ -0,0 +1,44 @@
//! Implements a window for adding a new Todo.
use cacao::macos::window::{Window, WindowDelegate};
use cacao::view::ViewController;
use crate::storage::{dispatch_ui, Message};
mod view;
use view::AddNewTodoContentView;
pub struct AddNewTodoWindow {
pub content: ViewController<AddNewTodoContentView>,
}
impl AddNewTodoWindow {
pub fn new() -> Self {
let content = ViewController::new(AddNewTodoContentView::default());
AddNewTodoWindow {
content: content
}
}
pub fn on_message(&self, message: Message) {
if let Some(delegate) = &self.content.view.delegate {
delegate.on_message(message);
}
}
}
impl WindowDelegate for AddNewTodoWindow {
const NAME: &'static str = "AddNewTodoWindow";
fn did_load(&mut self, window: Window) {
window.set_autosave_name("AddNewTodoWindow");
window.set_minimum_content_size(300, 100);
window.set_title("Add a New Task");
window.set_content_view_controller(&self.content);
}
fn cancel(&self) {
dispatch_ui(Message::CloseSheet);
}
}

View file

@ -0,0 +1,75 @@
//! Implements a view for adding a new task/todo. This is still a bit cumbersome as patterns are
//! worked out, but a quick explainer: when the user clicks the "add" button, we dispatch a message
//! which flows back through here, and then we grab the value and process it.
//!
//! Ownership in Rust makes it tricky to do this right, and the TextField widget may undergo more
//! changes before version 0.1. This approach is unlikely to break as an example while those
//! changes are poked and prodded at, even if it is a bit verbose and confusing.
use cacao::text::Label;
use cacao::layout::{Layout, LayoutConstraint};
use cacao::view::{View, ViewDelegate};
use cacao::button::Button;
use cacao::input::TextField;
use crate::storage::{dispatch_ui, Message};
#[derive(Debug, Default)]
pub struct AddNewTodoContentView {
pub view: Option<View>,
pub input: Option<TextField>,
pub button: Option<Button>
}
impl AddNewTodoContentView {
pub fn on_message(&self, message: Message) {
match message {
Message::ProcessNewTodo => {
if let Some(input) = &self.input {
let task = input.get_value();
if task != "" {
dispatch_ui(Message::StoreNewTodo(task));
}
}
},
_ => {}
}
}
}
impl ViewDelegate for AddNewTodoContentView {
const NAME: &'static str = "AddNewTodoContentView";
fn did_load(&mut self, view: View) {
let instructions = Label::new();
instructions.set_text("Let's be real: we both know this task isn't getting done.");
let input = TextField::new();
let mut button = Button::new("Add");
button.set_action(|| dispatch_ui(Message::ProcessNewTodo));
view.add_subview(&instructions);
view.add_subview(&input);
view.add_subview(&button);
LayoutConstraint::activate(&[
instructions.top.constraint_equal_to(&view.top).offset(16.),
instructions.leading.constraint_equal_to(&view.leading).offset(16.),
instructions.trailing.constraint_equal_to(&view.trailing).offset(-16.),
input.top.constraint_equal_to(&instructions.bottom).offset(8.),
input.leading.constraint_equal_to(&view.leading).offset(16.),
input.trailing.constraint_equal_to(&view.trailing).offset(-16.),
button.top.constraint_equal_to(&input.bottom).offset(8.),
button.trailing.constraint_equal_to(&view.trailing).offset(-16.)
]);
self.view = Some(view);
self.input = Some(input);
self.button = Some(button);
}
}

View file

@ -0,0 +1,37 @@
//! Implements the start of the App lifecycle. Handles creating the required menu and window
//! components and message dispatching.
use cacao::macos::{App, AppDelegate};
use cacao::notification_center::Dispatcher;
use crate::menu::menu;
use crate::storage::{Defaults, Message};
use crate::windows::WindowManager;
/// This handles routing lifecycle events, and maintains our `WindowManager`.
#[derive(Default)]
pub struct TodosApp {
pub window_manager: WindowManager
}
impl AppDelegate for TodosApp {
/// Sets the menu, activates the app, opens the main window and requests notification
/// permissions.
fn did_finish_launching(&self) {
Defaults::register();
App::set_menu(menu());
App::activate();
self.window_manager.open_main();
}
}
impl Dispatcher for TodosApp {
type Message = Message;
/// Handles a message that came over on the main (UI) thread.
fn on_ui_message(&self, message: Self::Message) {
self.window_manager.on_ui_message(message);
}
}

View file

@ -0,0 +1,21 @@
//! This example implements a "kitchen sink" Todo app, because I suppose that's what all GUI
//! frameworks aspire to do these days. Go figure.
//!
//! This may get extracted into a different repo some day in the future.
use cacao::macos::App;
mod add;
mod app;
mod menu;
mod preferences;
mod storage;
mod todos;
mod windows;
fn main() {
App::new(
"com.cacao.todo",
app::TodosApp::default()
).run();
}

View file

@ -0,0 +1,71 @@
//! Implements a top level menu, with some examples of how to configure and dispatch events.
//!
//! Some of this might move in to the framework at some point, but it requires a bit more thought.
//! Correctly functioning menus are a key part of what makes a macOS app feel right, though, so
//! this is here for those who might want to use this todos example as a starting point.
use cacao::macos::menu::{Menu, MenuItem};
use crate::storage::{dispatch_ui, Message};
/// Installs the menu.
pub fn menu() -> Vec<Menu> {
vec![
Menu::new("", vec![
MenuItem::about("Todos"),
MenuItem::Separator,
MenuItem::entry("Preferences").key(",").action(|| {
dispatch_ui(Message::OpenPreferencesWindow);
}),
MenuItem::Separator,
MenuItem::services(),
MenuItem::Separator,
MenuItem::hide(),
MenuItem::hide_others(),
MenuItem::show_all(),
MenuItem::Separator,
MenuItem::quit()
]),
Menu::new("File", vec![
MenuItem::entry("Open/Show Window").key("n").action(|| {
dispatch_ui(Message::OpenMainWindow);
}),
MenuItem::Separator,
MenuItem::entry("Add Todo").key("+").action(|| {
dispatch_ui(Message::OpenNewTodoSheet);
}),
MenuItem::Separator,
MenuItem::close_window(),
]),
Menu::new("Edit", vec![
MenuItem::undo(),
MenuItem::redo(),
MenuItem::Separator,
MenuItem::cut(),
MenuItem::copy(),
MenuItem::paste(),
MenuItem::Separator,
MenuItem::select_all()
]),
Menu::new("View", vec![
MenuItem::enter_full_screen()
]),
Menu::new("Window", vec![
MenuItem::minimize(),
MenuItem::zoom(),
MenuItem::Separator,
MenuItem::entry("Bring All to Front")
]),
Menu::new("Help", vec![])
]
}

View file

@ -0,0 +1,27 @@
use cacao::text::{Label, TextAlign};
use cacao::layout::{Layout, LayoutConstraint};
use cacao::view::{View, ViewDelegate};
/// A blank advanced preferences view.
#[derive(Debug, Default)]
pub struct AdvancedPreferencesContentView {
label: Label
}
impl ViewDelegate for AdvancedPreferencesContentView {
const NAME: &'static str = "AdvancedPreferencesContentView";
fn did_load(&mut self, view: View) {
self.label.set_text("And this is where advanced preferences would be... if we had any.");
self.label.set_text_alignment(TextAlign::Center);
view.add_subview(&self.label);
LayoutConstraint::activate(&[
self.label.top.constraint_equal_to(&view.top).offset(100.),
self.label.leading.constraint_equal_to(&view.leading).offset(16.),
self.label.trailing.constraint_equal_to(&view.trailing).offset(-16.),
self.label.bottom.constraint_equal_to(&view.bottom).offset(-100.)
]);
}
}

View file

@ -0,0 +1,39 @@
//! The main guts of the Preferences window. We store all our preferences in
//! `UserDefaults`, so there's not too much extra needed here - we can do most
//! event handlers inline.
use cacao::layout::{Layout, LayoutConstraint};
use cacao::view::{View, ViewDelegate};
use crate::storage::Defaults;
use super::toggle_option_view::ToggleOptionView;
/// A general preferences view.
#[derive(Debug, Default)]
pub struct GeneralPreferencesContentView {
pub example_option: ToggleOptionView,
}
impl ViewDelegate for GeneralPreferencesContentView {
const NAME: &'static str = "GeneralPreferencesContentView";
fn did_load(&mut self, view: View) {
self.example_option.configure(
"An example preference",
"This can be true, or it can be false.",
Defaults::should_whatever(), // initial value
Defaults::toggle_should_whatever
);
view.add_subview(&self.example_option.view);
LayoutConstraint::activate(&[
self.example_option.view.top.constraint_equal_to(&view.top).offset(22.),
self.example_option.view.leading.constraint_equal_to(&view.leading).offset(22.),
self.example_option.view.trailing.constraint_equal_to(&view.trailing).offset(-22.),
self.example_option.view.bottom.constraint_equal_to(&view.bottom).offset(-22.)
]);
}
}

View file

@ -0,0 +1,68 @@
//! Implements a stock-ish Preferences window.
use cacao::macos::window::{Window, WindowDelegate};
use cacao::macos::toolbar::Toolbar;
use cacao::view::ViewController;
use crate::storage::Message;
mod toolbar;
use toolbar::PreferencesToolbar;
mod general;
use general::GeneralPreferencesContentView;
mod advanced;
use advanced::AdvancedPreferencesContentView;
mod toggle_option_view;
pub struct PreferencesWindow {
pub toolbar: Toolbar<PreferencesToolbar>,
pub general: ViewController<GeneralPreferencesContentView>,
pub advanced: ViewController<AdvancedPreferencesContentView>,
window: Option<Window>
}
impl PreferencesWindow {
pub fn new() -> Self {
PreferencesWindow {
toolbar: Toolbar::new("PreferencesToolbar", PreferencesToolbar::default()),
general: ViewController::new(GeneralPreferencesContentView::default()),
advanced: ViewController::new(AdvancedPreferencesContentView::default()),
window: None
}
}
pub fn on_message(&self, message: Message) {
let window = self.window.as_ref().unwrap();
match message {
Message::SwitchPreferencesToGeneralPane => {
window.set_title("General");
window.set_content_view_controller(&self.general);
},
Message::SwitchPreferencesToAdvancedPane => {
window.set_title("Advanced");
window.set_content_view_controller(&self.advanced);
},
_ => {}
}
}
}
impl WindowDelegate for PreferencesWindow {
const NAME: &'static str = "PreferencesWindow";
fn did_load(&mut self, window: Window) {
window.set_autosave_name("TodosPreferencesWindow");
window.set_movable_by_background(true);
window.set_toolbar(&self.toolbar);
self.window = Some(window);
self.on_message(Message::SwitchPreferencesToGeneralPane);
}
}

View file

@ -0,0 +1,66 @@
use cacao::text::Label;
use cacao::layout::{Layout, LayoutConstraint};
use cacao::switch::Switch;
use cacao::view::{View};
/// A reusable widget for a toggle; this is effectively a standard checkbox/label combination for
/// toggling a boolean value.
#[derive(Debug)]
pub struct ToggleOptionView {
pub view: View,
pub switch: Switch,
pub title: Label,
pub subtitle: Label
}
impl Default for ToggleOptionView {
/// Creates and returns a stock toggle view.
fn default() -> Self {
let view = View::new();
let switch = Switch::new("");
view.add_subview(&switch);
let title = Label::new();
view.add_subview(&title);
let subtitle = Label::new();
view.add_subview(&subtitle);
LayoutConstraint::activate(&[
switch.top.constraint_equal_to(&view.top),
switch.leading.constraint_equal_to(&view.leading),
switch.width.constraint_equal_to_constant(24.),
title.top.constraint_equal_to(&view.top),
title.leading.constraint_equal_to(&switch.trailing),
title.trailing.constraint_equal_to(&view.trailing),
subtitle.top.constraint_equal_to(&title.bottom),
subtitle.leading.constraint_equal_to(&switch.trailing),
subtitle.trailing.constraint_equal_to(&view.trailing),
subtitle.bottom.constraint_equal_to(&view.bottom)
]);
ToggleOptionView {
view,
switch,
title,
subtitle
}
}
}
impl ToggleOptionView {
/// Configures the widget. The handler will be fired on each state change of the checkbox; you
/// can toggle your settings and such there.
pub fn configure<F>(&mut self, text: &str, subtitle: &str, state: bool, handler: F)
where
F: Fn() + Send + Sync + 'static
{
self.title.set_text(text);
self.subtitle.set_text(subtitle);
self.switch.set_action(handler);
self.switch.set_checked(state);
}
}

View file

@ -0,0 +1,68 @@
//! Implements an example toolbar for a Preferences app. Could be cleaner, probably worth cleaning
//! up at some point.
use cacao::macos::toolbar::{Toolbar, ToolbarDelegate, ToolbarItem};
use cacao::image::{Image, MacSystemIcon};
use crate::storage::{dispatch_ui, Message};
#[derive(Debug)]
pub struct PreferencesToolbar((ToolbarItem, ToolbarItem));
impl Default for PreferencesToolbar {
fn default() -> Self {
PreferencesToolbar(({
let mut item = ToolbarItem::new("general");
item.set_title("General");
let icon = Image::system_icon(MacSystemIcon::PreferencesGeneral, "General");
item.set_image(icon);
item.set_action(|| {
dispatch_ui(Message::SwitchPreferencesToGeneralPane);
});
item
}, {
let mut item = ToolbarItem::new("advanced");
item.set_title("Advanced");
let icon = Image::system_icon(MacSystemIcon::PreferencesAdvanced, "Advanced");
item.set_image(icon);
item.set_action(|| {
dispatch_ui(Message::SwitchPreferencesToAdvancedPane);
});
item
}))
}
}
impl ToolbarDelegate for PreferencesToolbar {
const NAME: &'static str = "PreferencesToolbar";
fn did_load(&mut self, toolbar: Toolbar) {
toolbar.set_selected("general");
}
fn allowed_item_identifiers(&self) -> Vec<&'static str> {
vec!["general", "advanced"]
}
fn default_item_identifiers(&self) -> Vec<&'static str> {
vec!["general", "advanced"]
}
fn selectable_item_identifiers(&self) -> Vec<&'static str> {
vec!["general", "advanced"]
}
fn item_for(&self, identifier: &str) -> &ToolbarItem {
match identifier {
"general" => &self.0.0,
"advanced" => &self.0.1,
_ => { unreachable!(); }
}
}
}

View file

@ -0,0 +1,75 @@
use std::collections::HashMap;
use cacao::defaults::{UserDefaults, Value};
const EXAMPLE: &'static str = "exampleSetting";
/// A very basic wrapper around UserDefaults. If I wind up implementing Serde support for
/// UserDefaults, then much of this could be removed or simplified - but I'm not sold on that yet,
/// so this exists for now.
#[derive(Debug)]
pub struct Defaults;
impl Defaults {
/// Registers the default settings for our application. Note that just because this is run at
/// application start does _not_ mean it's always the defaults; this is effectively "on first
/// run, set these defaults". Updates will persist and overwrite these accordingly.
pub fn register() {
let mut defaults = UserDefaults::standard();
defaults.register({
let mut map = HashMap::new();
map.insert(EXAMPLE, Value::Bool(true));
map
});
}
/// Toggles the example setting.
pub fn toggle_should_whatever() {
toggle_bool(EXAMPLE);
}
/// Returns whether the example setting is currently true or false.
pub fn should_whatever() -> bool {
load_bool(EXAMPLE)
}
}
/// A helper method for toggling a boolean value held at the specified key. If the value cannot
/// be pulled as a bool, then this panics.
///
/// Note that choosing to panic here is a personal design decision; this is core functionality that
/// should work, and I'd rather it crash with a meaningful message rather than `unwrap()` issues.
fn toggle_bool(key: &str) {
let mut defaults = UserDefaults::standard();
if let Some(value) = defaults.get(key) {
if let Some(value) = value.as_bool() {
defaults.insert(key, Value::Bool(!value));
return;
}
panic!("Attempting to toggle a boolean value for {}, but it's not a boolean.", key);
}
panic!("Attempting to toggle a boolean value for {}, but this key does not exist.", key);
}
/// A helper method for loading a boolean value held at the specified key. If the value cannot
/// be pulled as a bool, then this panics.
///
/// Note that choosing to panic here is a personal design decision; this is core functionality that
/// should work, and I'd rather it crash with a meaningful message rather than `unwrap()` issues.
fn load_bool(key: &str) -> bool {
let defaults = UserDefaults::standard();
if let Some(value) = defaults.get(key) {
if let Some(value) = value.as_bool() {
return value;
}
panic!("Attempting to load a boolean value for {}, but it's not a boolean.", key);
}
panic!("Attempting to load a boolean value for {}, but this key does not exist.", key);
}

View file

@ -0,0 +1,55 @@
//! Messages that we used to thread control throughout the application.
//! If you come from React/Redux, you can liken it to that world.
use cacao::macos::App;
use crate::app::TodosApp;
mod defaults;
pub use defaults::Defaults;
mod todos;
pub use todos::{Todos, Todo, TodoStatus};
/// Message passing is our primary way of instructing UI changes without needing to do
/// constant crazy referencing in Rust. Dispatch a method using either `dispatch_ui` for the main
/// thread, or `dispatch` for a background thread, and the main `TodosApp` will receive the
/// message. From there, it can filter down to components, or just handle it as necessary.
#[derive(Clone, Debug)]
pub enum Message {
/// (Re)Open the main window.
OpenMainWindow,
/// Open the Preferences window.
OpenPreferencesWindow,
/// Switch the Preferences panel to the General section.
SwitchPreferencesToGeneralPane,
/// Switch the Preferences panel to the Advanced section.
SwitchPreferencesToAdvancedPane,
/// Open a "add new todo" window, as a modal sheet.
OpenNewTodoSheet,
/// Close the current active sheet, usually the receive window.
CloseSheet,
/// Called to instruct the app to process a todo from the input box.
ProcessNewTodo,
/// Called when there's a new Todo to store.
StoreNewTodo(String),
/// Mark a todo as complete.
MarkTodoComplete(usize),
/// Mark a todo as incomplete.
MarkTodoIncomplete(usize)
}
/// Dispatch a message on a background thread.
pub fn dispatch_ui(message: Message) {
println!("Dispatching UI message: {:?}", message);
App::<TodosApp, Message>::dispatch_main(message);
}

View file

@ -0,0 +1,76 @@
//! This implements a "database" for our Todos app. It makes the assumption that we're only ever
//! doing this stuff on the main thread; in a more complicated app, you'd probably make different
//! choices.
use std::rc::Rc;
use std::cell::RefCell;
/// The status of a Todo.
#[derive(Debug)]
pub enum TodoStatus {
/// Yet to be completed.
Incomplete,
/// Completed. ;P
Complete
}
/// A Todo. Represents... something to do.
#[derive(Debug)]
pub struct Todo {
/// The title of this todo.
pub title: String,
/// The status of this todo.
pub status: TodoStatus
}
/// A single-threaded Todos "database".
#[derive(Debug, Default)]
pub struct Todos(Rc<RefCell<Vec<Todo>>>);
impl Todos {
/// Insert a new Todo.
pub fn insert(&self, title: String) {
let mut stack = self.0.borrow_mut();
let mut todos = vec![Todo {
title: title,
status: TodoStatus::Incomplete
}];
todos.append(&mut stack);
*stack = todos;
}
/// Edit a Todo at the row specified.
pub fn with_mut<F>(&self, row: usize, handler: F)
where
F: Fn(&mut Todo)
{
let mut stack = self.0.borrow_mut();
if let Some(todo) = stack.get_mut(row) {
handler(todo);
}
}
/// Run a block with the given Todo.
pub fn with<F>(&self, row: usize, mut handler: F)
where
F: FnMut(&Todo)
{
let stack = self.0.borrow();
if let Some(todo) = stack.get(row) {
handler(todo);
}
}
/// Returns the total number of Todos.
pub fn len(&self) -> usize {
let stack = self.0.borrow();
stack.len()
}
}

View file

@ -0,0 +1,47 @@
//! The root view controller for our Todos window. All we do here is attach our ListView and pass
//! messages downwards. You could theoretically remove this layer of indirection, but a more
//! complicated app probably wouldn't, and I figure it's worth having this here for those who might
//! use this example as a jumping-off point.
use cacao::layout::{Layout, LayoutConstraint};
use cacao::listview::ListView;
use cacao::view::{View, ViewDelegate};
use crate::storage::Message;
use super::list::TodosListView;
#[derive(Debug)]
pub struct TodosContentView {
pub todos_list_view: ListView<TodosListView>
}
impl Default for TodosContentView {
fn default() -> Self {
TodosContentView {
todos_list_view: ListView::with(TodosListView::default())
}
}
}
impl TodosContentView {
pub fn on_message(&self, message: Message) {
if let Some(delegate) = &self.todos_list_view.delegate {
delegate.on_message(message);
}
}
}
impl ViewDelegate for TodosContentView {
const NAME: &'static str = "TodosContentView";
fn did_load(&mut self, view: View) {
view.add_subview(&self.todos_list_view);
LayoutConstraint::activate(&[
self.todos_list_view.top.constraint_equal_to(&view.top),
self.todos_list_view.leading.constraint_equal_to(&view.leading),
self.todos_list_view.trailing.constraint_equal_to(&view.trailing),
self.todos_list_view.bottom.constraint_equal_to(&view.bottom)
]);
}
}

View file

@ -0,0 +1,113 @@
//! This implements our ListView, which displays and helps interact with the Todo list. This is a
//! mostly single-threaded example, so we can get away with cutting a few corners and keeping our
//! data store in here - but for a larger app, you'd likely do something else.
use cacao::listview::{
ListView, ListViewDelegate, ListViewRow,
RowAnimation, RowEdge, RowAction, RowActionStyle
};
use crate::storage::{dispatch_ui, Message, Todos, TodoStatus};
mod row;
use row::TodoViewRow;
/// An identifier for the cell(s) we dequeue.
const TODO_ROW: &'static str = "TodoViewRowCell";
/// The list view for todos.
#[derive(Debug, Default)]
pub struct TodosListView {
view: Option<ListView>,
todos: Todos
}
impl TodosListView {
/// This manages updates to the underlying database. You might opt to do this elsewhere, etc.
pub fn on_message(&self, message: Message) {
match message {
Message::MarkTodoComplete(row) => {
self.todos.with_mut(row, |todo| todo.status = TodoStatus::Complete);
if let Some(view) = &self.view {
view.reload_rows(&[row]);
view.set_row_actions_visible(false);
}
},
Message::MarkTodoIncomplete(row) => {
self.todos.with_mut(row, |todo| todo.status = TodoStatus::Incomplete);
if let Some(view) = &self.view {
view.reload_rows(&[row]);
view.set_row_actions_visible(false);
}
},
Message::StoreNewTodo(todo) => {
self.todos.insert(todo);
self.view.as_ref().unwrap().perform_batch_updates(|listview| {
// We know we always insert at the 0 index, so this is a simple calculation.
// You'd need to diff yourself for anything more complicated.
listview.insert_rows(0..1, RowAnimation::SlideDown);
});
},
_ => {}
}
}
}
impl ListViewDelegate for TodosListView {
const NAME: &'static str = "TodosListView";
/// Essential configuration and retaining of a `ListView` handle to do updates later on.
fn did_load(&mut self, view: ListView) {
view.register(TODO_ROW, TodoViewRow::default);
view.set_uses_alternating_backgrounds(true);
self.view = Some(view);
}
/// The number of todos we currently have.
fn number_of_items(&self) -> usize {
self.todos.len()
}
/// For a given row, dequeues a view from the system and passes the appropriate `Transfer` for
/// configuration.
fn item_for(&self, row: usize) -> ListViewRow {
let mut view = self.view.as_ref().unwrap().dequeue::<TodoViewRow>(TODO_ROW);
if let Some(view) = &mut view.delegate {
self.todos.with(row, |todo| view.configure_with(todo));
}
view.into_row()
}
/// Provides support for _swipe-to-reveal_ actions. After a user has completed one of these
/// actions, we make sure to mark the tableview as done (see the message handlers in this
/// file).
fn actions_for(&self, row: usize, edge: RowEdge) -> Vec<RowAction> {
if let RowEdge::Leading = edge {
return vec![];
}
let mut actions = vec![];
self.todos.with(row, |todo| match todo.status {
TodoStatus::Complete => {
actions.push(RowAction::new("Mark Incomplete", RowActionStyle::Destructive, move |_action, row| {
dispatch_ui(Message::MarkTodoIncomplete(row));
}));
},
TodoStatus::Incomplete => {
actions.push(RowAction::new("Mark Complete", RowActionStyle::Regular, move |_action, row| {
dispatch_ui(Message::MarkTodoComplete(row));
}));
}
});
actions
}
}

View file

@ -0,0 +1,60 @@
use cacao::layout::{Layout, LayoutConstraint};
use cacao::text::{Font, Label, LineBreakMode};
use cacao::view::{View, ViewDelegate};
use cacao::color::rgb;
use crate::storage::{Todo, TodoStatus};
/// This view is used as a row in our `TodosListView`. It displays information/status for a provided
/// `Todo`, and gets cached and reused as necessary. It should not store anything long-term.
#[derive(Default, Debug)]
pub struct TodoViewRow {
pub title: Label,
pub status: Label
}
impl TodoViewRow {
/// Called when this view is being presented, and configures itself with the given todo.
pub fn configure_with(&mut self, todo: &Todo) {
self.title.set_text(&todo.title);
match todo.status {
TodoStatus::Incomplete => {
self.status.set_color(rgb(219, 66, 66));
self.status.set_text("Incomplete");
},
TodoStatus::Complete => {
self.status.set_color(rgb(11, 121, 254));
self.status.set_text("Complete");
}
}
}
}
impl ViewDelegate for TodoViewRow {
const NAME: &'static str = "TodoViewRow";
/// Called when the view is first created; handles setup of layout and associated styling that
/// doesn't change.
fn did_load(&mut self, view: View) {
view.add_subview(&self.title);
view.add_subview(&self.status);
self.title.set_line_break_mode(LineBreakMode::TruncateMiddle);
let font = Font::system(10.);
self.status.set_font(&font);
LayoutConstraint::activate(&[
self.title.top.constraint_equal_to(&view.top).offset(16.),
self.title.leading.constraint_equal_to(&view.leading).offset(16.),
self.title.trailing.constraint_equal_to(&view.trailing).offset(-16.),
self.status.top.constraint_equal_to(&self.title.bottom).offset(8.),
self.status.leading.constraint_equal_to(&view.leading).offset(16.),
self.status.trailing.constraint_equal_to(&view.trailing).offset(-16.),
self.status.bottom.constraint_equal_to(&view.bottom).offset(-16.)
]);
}
}

View file

@ -0,0 +1,49 @@
//! The main Todos window.
use cacao::macos::window::{Window, WindowDelegate};
use cacao::macos::toolbar::Toolbar;
use cacao::view::ViewController;
use crate::storage::Message;
mod toolbar;
use toolbar::TodosToolbar;
mod content_view;
use content_view::TodosContentView;
mod list;
pub struct TodosWindow {
pub content: ViewController<TodosContentView>,
pub toolbar: Toolbar<TodosToolbar>,
}
impl TodosWindow {
pub fn new() -> Self {
TodosWindow {
content: ViewController::new(TodosContentView::default()),
toolbar: Toolbar::new("TodosToolbar", TodosToolbar::default())
}
}
pub fn on_message(&self, message: Message) {
if let Some(delegate) = &self.content.view.delegate {
delegate.on_message(message);
}
}
}
impl WindowDelegate for TodosWindow {
const NAME: &'static str = "TodosWindow";
fn did_load(&mut self, window: Window) {
window.set_autosave_name("TodosWindow");
window.set_minimum_content_size(400, 400);
window.set_movable_by_background(true);
window.set_title("Tasks");
window.set_toolbar(&self.toolbar);
window.set_content_view_controller(&self.content);
}
}

View file

@ -0,0 +1,46 @@
//! The main Todos window toolbar. Contains a button to enable adding a new task.
use cacao::button::Button;
use cacao::macos::toolbar::{Toolbar, ToolbarDelegate, ToolbarItem, ToolbarDisplayMode};
use crate::storage::{dispatch_ui, Message};
#[derive(Debug)]
pub struct TodosToolbar(ToolbarItem);
impl Default for TodosToolbar {
fn default() -> Self {
TodosToolbar({
let mut item = ToolbarItem::new("AddTodoButton");
item.set_title("Add Todo");
item.set_button(Button::new("+ New"));
item.set_action(|| {
dispatch_ui(Message::OpenNewTodoSheet);
});
item
})
}
}
impl ToolbarDelegate for TodosToolbar {
const NAME: &'static str = "TodosToolbar";
fn did_load(&mut self, toolbar: Toolbar) {
toolbar.set_display_mode(ToolbarDisplayMode::IconOnly);
}
fn allowed_item_identifiers(&self) -> Vec<&'static str> {
vec!["AddTodoButton"]
}
fn default_item_identifiers(&self) -> Vec<&'static str> {
vec!["AddTodoButton"]
}
// We only have one item, so we don't care about the identifier.
fn item_for(&self, _identifier: &str) -> &ToolbarItem {
&self.0
}
}

View file

@ -0,0 +1,161 @@
//! We use a few different windows in our app lifecycle, so it's easier to
//! just use a small abstraction here and keep the app delegate clean.
//!
//! This could be a lot cleaner, and is something I'd like to make cleaner on a framework level.
use std::sync::RwLock;
use cacao::macos::window::{Window, WindowConfig, WindowStyle, WindowDelegate, WindowToolbarStyle};
use cacao::notification_center::Dispatcher;
use crate::storage::Message;
use crate::add::AddNewTodoWindow;
use crate::todos::TodosWindow;
use crate::preferences::PreferencesWindow;
#[derive(Default)]
pub struct WindowManager {
pub main: RwLock<Option<Window<TodosWindow>>>,
pub preferences: RwLock<Option<Window<PreferencesWindow>>>,
pub add: RwLock<Option<Window<AddNewTodoWindow>>>
}
/// A helper method to handle checking for window existence, and creating
/// it if not - then showing it.
fn open_or_show<T, F>(window: &RwLock<Option<Window<T>>>, vendor: F)
where
T: WindowDelegate + 'static,
F: Fn() -> (WindowConfig, T)
{
let mut lock = window.write().unwrap();
if let Some(win) = &*lock {
win.show();
} else {
let (config, delegate) = vendor();
let win = Window::with(config, delegate);
win.show();
*lock = Some(win);
}
}
impl WindowManager {
pub fn open_main(&self) {
open_or_show(&self.main, || (
WindowConfig::default(), TodosWindow::new()
));
}
/// When we run a sheet, we want to run it on our main window, which is all
/// this helper is for.
pub fn begin_sheet<W, F>(&self, window: &Window<W>, completion: F)
where
W: WindowDelegate + 'static,
F: Fn() + Send + Sync + 'static
{
let main = self.main.write().unwrap();
if let Some(main_window) = &*main {
main_window.begin_sheet(window, completion);
}
}
/// Opens a "add file" window, which asks for a code and optional server to
/// check against. This should, probably, be a sheet - but for now it's fine as a
/// separate window until I can find time to port that API.
pub fn open_add(&self) {
let callback = || {};
let mut lock = self.add.write().unwrap();
if let Some(win) = &*lock {
self.begin_sheet(&win, callback);
} else {
let window = Window::with(WindowConfig::default(), AddNewTodoWindow::new());
self.begin_sheet(&window, callback);
*lock = Some(window);
}
}
pub fn close_sheet(&self) {
let mut add = self.add.write().unwrap();
if let Some(add_window) = &*add {
let main = self.main.write().unwrap();
if let Some(main_window) = &*main {
main_window.end_sheet(&add_window);
}
}
*add = None;
}
/// Opens a "add file" window, which asks for a code and optional server to
/// check against.
pub fn open_preferences(&self) {
open_or_show(&self.preferences, || {
let mut config = WindowConfig::default();
config.set_initial_dimensions(100., 100., 400., 400.);
config.set_styles(&[
WindowStyle::Resizable, WindowStyle::Miniaturizable,
WindowStyle::Closable, WindowStyle::Titled
]);
config.toolbar_style = WindowToolbarStyle::Preferences;
(config, PreferencesWindow::new())
});
}
}
impl Dispatcher for WindowManager {
type Message = Message;
/// Some jank message passing, it's fine for now.
fn on_ui_message(&self, message: Message) {
match message {
Message::OpenMainWindow => {
self.open_main();
},
Message::OpenPreferencesWindow => {
self.open_preferences();
},
Message::CloseSheet => {
self.close_sheet();
},
Message::OpenNewTodoSheet => {
self.open_add();
},
Message::StoreNewTodo(_) => {
self.close_sheet();
},
_ => {}
}
if let Some(w) = &*(self.main.read().unwrap()) {
if let Some(delegate) = &w.delegate {
delegate.on_message(message.clone());
}
}
if let Some(w) = &*(self.preferences.read().unwrap()) {
if let Some(delegate) = &w.delegate {
delegate.on_message(message.clone());
}
}
if let Some(w) = &*(self.add.read().unwrap()) {
if let Some(delegate) = &w.delegate {
delegate.on_message(message.clone());
}
}
}
}

View file

@ -490,6 +490,17 @@ impl<T> ListView<T> {
} }
} }
/// End actions for a row. API subject to change.
pub fn set_row_actions_visible(&self, visible: bool) {
#[cfg(target_os = "macos")]
unsafe {
let _: () = msg_send![&*self.objc, setRowActionsVisible:match visible {
true => YES,
false => NO
}];
}
}
/// Register this view for drag and drop operations. /// Register this view for drag and drop operations.
pub fn register_for_dragged_types(&self, types: &[PasteboardType]) { pub fn register_for_dragged_types(&self, types: &[PasteboardType]) {
unsafe { unsafe {

View file

@ -210,7 +210,7 @@ impl<T> ListViewRow<T> where T: ViewDelegate + 'static {
view view
} }
pub fn wut(mut self) -> ListViewRow { pub fn into_row(mut self) -> ListViewRow {
// "forget" delegate, then move into standard ListViewRow // "forget" delegate, then move into standard ListViewRow
// to ease return type // to ease return type
let delegate = self.delegate.take(); let delegate = self.delegate.take();

View file

@ -216,6 +216,15 @@ impl<T> Label<T> {
} }
} }
/// Call this to set the color of the text.
pub fn set_color(&self, color: Color) {
let color = color.into_platform_specific_color();
unsafe {
let _: () = msg_send![&*self.objc, setTextColor:color];
}
}
/// Call this to set the text for the label. /// Call this to set the text for the label.
pub fn set_text(&self, text: &str) { pub fn set_text(&self, text: &str) {
let s = NSString::new(text); let s = NSString::new(text);