diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000..f762aa6
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,17 @@
+[alias]
+lint = [
+ "clippy",
+ "--",
+ "-D",
+ "clippy::pedantic",
+ "-D",
+ "clippy::cargo",
+ "-A",
+ "clippy::option_if_let_else",
+ "-A",
+ "clippy::cast_possible_truncation",
+ "-A",
+ "clippy::cast_possible_wrap",
+ "-A",
+ "clippy::cast_sign_loss"
+]
diff --git a/.github/workflows/debug-build.yml b/.github/workflows/debug-build.yml
new file mode 100644
index 0000000..551eb02
--- /dev/null
+++ b/.github/workflows/debug-build.yml
@@ -0,0 +1,72 @@
+name: Debug Build
+
+on:
+ pull_request:
+ branches: main
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ build:
+ runs-on: ${{ matrix.os }}
+ name: ${{ ( startsWith(matrix.os, 'ubuntu') && 'Linux' ) ||
+ ( startsWith(matrix.os, 'windows') && 'Windows' ) ||
+ ( startsWith(matrix.os, 'mac') && 'macOS' ) }}
+ strategy:
+ fail-fast: false
+ matrix:
+ # os: [windows-latest, macos-10.15, ubuntu-18.04]
+ os: [windows-latest]
+
+ steps:
+ - name: Download dependencies (Linux only)
+ if: ${{ startsWith(matrix.os, 'ubuntu') }}
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libpango1.0-dev libx11-dev libxext-dev \
+ libxft-dev libxinerama-dev libxcursor-dev \
+ libxrender-dev libxfixes-dev ninja-build \
+ appstream
+ sudo wget https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage -O /usr/local/bin/linuxdeploy
+ sudo chmod +x /usr/local/bin/linuxdeploy
+ - name: Checkout the repository
+ uses: actions/checkout@v2
+ - name: Build
+ run: |
+ cargo lint
+ cargo build
+ cargo build --release
+ if ${{ startsWith(matrix.os, 'windows') }}; then
+ mv target/debug/shuchu.exe shuchu-debug.exe
+ mv target/release/shuchu.exe .
+ else
+ mv target/debug/shuchu shuchu-debug
+ mv target/release/shuchu .
+ strip shuchu shuchu-debug
+ fi
+ - name: Create an AppImage (Linux only)
+ if: ${{ startsWith(matrix.os, 'ubuntu') }}
+ run: |
+ linuxdeploy --appdir AppDir -e shuchu -d assets/shuchu.desktop -i assets/shuchu-64.png -o appimage
+ mv Shuchu*.AppImage Shuchu.AppImage
+ - name: Create an App Bundle (macOS only)
+ if: ${{ startsWith(matrix.os, 'mac') }}
+ run: |
+ cargo install cargo-bundle
+ cargo bundle
+ mv target/debug/bundle/osx/Shuchu.app .
+ - name: Upload the binaries
+ uses: actions/upload-artifact@v2
+ with:
+ name: ${{ ( startsWith(matrix.os, 'ubuntu') && 'Linux' ) ||
+ ( startsWith(matrix.os, 'windows') && 'Windows' ) ||
+ ( startsWith(matrix.os, 'macOS') && 'macOS' ) }}
+ path: |
+ shuchu-debug
+ shuchu-debug.exe
+ shuchu
+ shuchu.exe
+ Shuchu.AppImage
+ Shuchu.app
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4dfbfeb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+# Rust
+/target
+
+# AppImage
+/AppDir
+/*.AppImage
+/appimage-builder-cache
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..059b3dc
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,166 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "bitflags"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
+
+[[package]]
+name = "cc"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e70cc2f62c6ce1868963827bd677764c62d07c3d9a3e1fb1177ee1a9ab199eb2"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "cmake"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb6210b637171dfba4cda12e579ac6dc73f5165ad56133e5d72ef3131f320855"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06ed27e177f16d65f0f0c22a213e17c696ace5dd64b14258b52f9417ccb52db4"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d82cfc11ce7f2c3faef78d8a684447b40d503d9681acebed6cb728d45940c4db"
+dependencies = [
+ "cfg-if",
+ "lazy_static",
+]
+
+[[package]]
+name = "fltk"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee82581699ebc218b80eb23e6c999e4f304d45632336fcef07d3a592619d5b3b"
+dependencies = [
+ "bitflags",
+ "fltk-derive",
+ "fltk-sys",
+ "lazy_static",
+ "objc",
+ "raw-window-handle",
+]
+
+[[package]]
+name = "fltk-derive"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86ebc92d8a6b63d8011a47d644af7bbea495fff38e92a48c1ca933db797ca660"
+dependencies = [
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "fltk-sys"
+version = "1.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "727a64659be2b6f39c880f3fb228df14a3c8dfc9a0b9eabcba64f29442a0d62e"
+dependencies = [
+ "cmake",
+ "libc",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
+[[package]]
+name = "libc"
+version = "0.2.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320cfe77175da3a483efed4bc0adc1968ca050b098ce4f2f1c13a56626128790"
+
+[[package]]
+name = "malloc_buf"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "objc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
+dependencies = [
+ "malloc_buf",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d8caf72986c1a598726adc988bb5984792ef84f5ee5aa50209145ee8077038"
+dependencies = [
+ "unicode-xid",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "raw-window-handle"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a441a7a6c80ad6473bd4b74ec1c9a4c951794285bf941c2126f607c72e48211"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "shuchu"
+version = "0.1.0"
+dependencies = [
+ "crossbeam-channel",
+ "fltk",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.74"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1873d832550d4588c3dbc20f01361ab00bfe741048f71e3fecf145a7cc18b29c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-xid",
+]
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..a7e064a
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,33 @@
+[package]
+name = "shuchu"
+version = "0.1.0"
+authors = ["paveloom"]
+edition = "2018"
+description = "Whoa! What's that?!"
+documentation = "https://docs.rs/shuchu"
+readme = "README.md"
+homepage = "https://paveloom-a.github.io/Shuchu"
+repository = "https://github.com/paveloom-a/Shuchu"
+license-file = "LICENSE.md"
+keywords = ["productivity", "focus", "timer", "rewards", "life"]
+categories = ["graphics"]
+
+[package.metadata.bundle]
+name = "Shuchu"
+identifier = "io.github.paveloom.shuchu"
+icon = ["assets/shuchu-64.png"]
+resources = ["assets"]
+copyright = "Copyright (c) Pavel Sobolev 2021."
+category = "public.app-category.productivity"
+
+[profile.dev]
+panic = 'abort'
+
+[profile.release]
+lto = true
+codegen-units = 1
+panic = 'abort'
+
+[dependencies]
+fltk = { version = "=1.1.10", features = ["fltk-bundled", "use-ninja"] }
+crossbeam-channel = {version = "~0.5.1"}
diff --git a/assets/menu/arrow.svg b/assets/menu/arrow.svg
new file mode 100644
index 0000000..db3b9d1
--- /dev/null
+++ b/assets/menu/arrow.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/menu/coins.svg b/assets/menu/coins.svg
new file mode 100644
index 0000000..a913357
--- /dev/null
+++ b/assets/menu/coins.svg
@@ -0,0 +1,107 @@
+
diff --git a/assets/menu/divider.svg b/assets/menu/divider.svg
new file mode 100644
index 0000000..7886532
--- /dev/null
+++ b/assets/menu/divider.svg
@@ -0,0 +1,7 @@
+
diff --git a/assets/menu/insert-coin.svg b/assets/menu/insert-coin.svg
new file mode 100644
index 0000000..6b34f07
--- /dev/null
+++ b/assets/menu/insert-coin.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/menu/minus.svg b/assets/menu/minus.svg
new file mode 100644
index 0000000..50be9f5
--- /dev/null
+++ b/assets/menu/minus.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/menu/pencil.svg b/assets/menu/pencil.svg
new file mode 100644
index 0000000..c9b31df
--- /dev/null
+++ b/assets/menu/pencil.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/menu/plus.svg b/assets/menu/plus.svg
new file mode 100644
index 0000000..4f80048
--- /dev/null
+++ b/assets/menu/plus.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/menu/sand-clock.svg b/assets/menu/sand-clock.svg
new file mode 100644
index 0000000..24e1914
--- /dev/null
+++ b/assets/menu/sand-clock.svg
@@ -0,0 +1,16 @@
+
diff --git a/assets/shuchu-32.png b/assets/shuchu-32.png
new file mode 100644
index 0000000..3353d62
Binary files /dev/null and b/assets/shuchu-32.png differ
diff --git a/assets/shuchu-64.png b/assets/shuchu-64.png
new file mode 100644
index 0000000..f5f9e89
Binary files /dev/null and b/assets/shuchu-64.png differ
diff --git a/assets/shuchu.desktop b/assets/shuchu.desktop
new file mode 100644
index 0000000..238bf61
--- /dev/null
+++ b/assets/shuchu.desktop
@@ -0,0 +1,6 @@
+[Desktop Entry]
+Type=Application
+Name=Shuchu
+Exec=shuchu
+Icon=shuchu-64
+Categories=Utility;
diff --git a/assets/shuchu.svg b/assets/shuchu.svg
new file mode 100644
index 0000000..e304526
--- /dev/null
+++ b/assets/shuchu.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/channels.rs b/src/channels.rs
new file mode 100644
index 0000000..284a39d
--- /dev/null
+++ b/src/channels.rs
@@ -0,0 +1,70 @@
+//! This module provides the channels used in the program.
+
+use crossbeam_channel::{self, Receiver, Sender};
+
+macro_rules! channels_default_impl {
+ (
+ $(#[$attr:meta])*
+ pub struct $name:ident {
+ $(
+ #[$field_doc:meta]
+ pub $field_name:ident: $field_type:ty,
+ )*
+ }) => {
+ $(#[$attr])*
+ pub struct $name {
+ $(
+ #[$field_doc]
+ pub $field_name: $field_type,
+ )*
+ }
+
+ impl $name {
+ /// Get the default set of the application's channels
+ pub fn default() -> $name {
+ $name {
+ $(
+ $field_name: Channel::new(crossbeam_channel::unbounded()),
+ )*
+ }
+ }
+ }
+ }
+}
+
+/// A channel
+#[derive(Clone)]
+pub struct Channel {
+ /// Sender
+ pub s: Sender,
+ /// Receiver
+ pub r: Receiver,
+}
+
+impl Channel {
+ /// Initialize a new channel from a tuple of a Sender and a Receiver
+ fn new((s, r): (Sender, Receiver)) -> Channel {
+ Channel { s, r }
+ }
+}
+
+channels_default_impl! {
+ /// A struct that provides access to the application's channels
+ #[derive(Clone)]
+ pub struct Channels {
+ /// Channel 1: From Any Window to Main Window
+ pub mw: Channel,
+ /// Channel 2: From Main Window to Rewards Edit Window
+ pub rewards_edit: Channel,
+ /// Channel 3: Send Coins To Reward in the Rewards Edit Window
+ pub rewards_send_coins: Channel,
+ /// Channel 4: Send an Item's string from the Rewards Edit Window to Rewards
+ pub rewards_send_item: Channel,
+ /// Channel 5: Receive an Item's string from the Rewards
+ pub rewards_receive_item: Channel,
+ /// Channel 6: Receive Coins from an Item
+ pub rewards_receive_coins: Channel,
+ /// Channel 7: Receive a Reward from an Item
+ pub rewards_receive_reward: Channel,
+ }
+}
diff --git a/src/events.rs b/src/events.rs
new file mode 100644
index 0000000..96b8140
--- /dev/null
+++ b/src/events.rs
@@ -0,0 +1,127 @@
+//! This module provides custom events that are handled by the program.
+//!
+//! The numeric constants are generated using a recursive counting macro.
+
+/// Consume the first argument in exchange of unit
+macro_rules! unit {
+ ($_t:tt $unit:expr) => {
+ $unit
+ };
+}
+
+/// Create events using provided identifiers
+macro_rules! events {
+ (
+ $(#[$first_doc:meta])*
+ $first_event:ident $(,)?
+ $(
+ $(#[$other_docs:meta])*
+ $other_events:ident
+ ),*
+ ) => {
+ $(#[$first_doc])*
+ pub const $first_event: i32 = 1000 + <[()]>::len(&[$(unit!(($other_events) ())),*]) as i32;
+ events!($(
+ $(#[$other_docs])*
+ $other_events
+ ),*);
+ };
+ () => {};
+}
+
+events!(
+ /// Hide the secondary pane (called by the Arrow and Coins buttons in the Focus Pane)
+ MAIN_WINDOW_HIDE_THE_PANE,
+ /// Show the secondary pane (called by the Arrow and Coins buttons in the Focus Pane)
+ MAIN_WINDOW_SHOW_THE_PANE,
+ /// Start the timer (called by the Timer button in the Focus Pane)
+ START_TIMER,
+ /// Tick (meaning, advance the timer by one second) (called by Timer in the Focus Pane)
+ TICK,
+ /// Stop the timer (called by the Timer button in the Focus Pane)
+ STOP_TIMER,
+ /// Do the callback of the OK button in the Rewards Edit window (called by the Coins, Reward
+ /// widgets, and the OK button in that window)
+ OK_BUTTON_DO_CALLBACK,
+ /// Set the OK button in the Rewards Edit window to the 'Add a reward' mode (called by the
+ /// Add Button in the Rewards' Menubar)
+ OK_BUTTON_SET_TO_ADD,
+ /// Set the OK button in the Rewards Edit window to the 'Edit the reward' mode (called by the
+ /// Edit Button in the Rewards' Menubar)
+ OK_BUTTON_SET_TO_EDIT,
+ /// Show the Rewards Edit window for adding a new reward (called by the Add Button in the
+ /// Rewards' Menubar)
+ ADD_A_REWARD_OPEN,
+ /// Start a chain of events to create a new reward. Start by sending the input of the
+ /// Coins widget in the Rewards Edit window to its sibling, the Reward widget (called by
+ /// the OK button in that window, but that signal may be inherited from the input widgets, too)
+ ADD_A_REWARD_SEND_COINS,
+ /// Combine the number of coins received from the Coins widget in the Rewards Edit window with
+ /// input of the Reward widget, thus creating a reward, and send a result to the Rewards'
+ /// List (called by the Coins widget as part of the chain of the events)
+ ADD_A_REWARD_SEND_REWARD,
+ /// Receive a reward from the Rewards Edit window (called by the Reward widget in the Rewards
+ /// Edit window as part of the chain of the events)
+ ADD_A_REWARD_RECEIVE,
+ /// Start a chain of events to open an existing reward in the Rewards Edit window. Send the
+ /// currently selected item from the List to the Rewards Edit window, so it's ready to get
+ /// edited (called by the Edit Button in the Rewards' Menubar)
+ EDIT_THE_REWARD_SEND_ITEM,
+ /// Show the Rewards Edit window for editing an existing reward (called by the List as part of
+ /// the chain of the events)
+ EDIT_THE_REWARD_OPEN,
+ /// Receive the price of the reward that's being edited (called by the Rewards Edit window as
+ /// part of the chain of the events)
+ EDIT_THE_REWARD_RECEIVE_COINS,
+ /// Receive the text of the reward that's being edited (called by the Rewards Edit window as
+ /// part of the chain of the events)
+ EDIT_THE_REWARD_RECEIVE_REWARD,
+ /// Start a chain of events to edit the existing (currently selected) reward. Start by sending
+ /// the input of the Coins widget in the Rewards Edit window to its sibling, the Reward widget
+ /// (called by the OK button in that window, but that signal may be inherited from the input
+ /// widgets, too)
+ EDIT_THE_REWARD_SEND_COINS,
+ /// Combine the number of coins received from the Coins widget in the Rewards Edit window with
+ /// input of the Reward widget, thus creating a reward, and send a result to the Rewards'
+ /// List (called by the Coins widget as part of the chain of the events)
+ EDIT_THE_REWARD_SEND_REWARD,
+ /// Receive a reward from the Rewards Edit window (called by the Reward widget in the Rewards
+ /// Edit window as part of the chain of the events)
+ EDIT_THE_REWARD_RECEIVE,
+ /// Delete the selected reward (called by the Delete Button in the Rewards' Menubar)
+ DELETE_A_REWARD,
+ /// Start a chain of events to spend coins on the selected reward. Start by sending the price
+ /// (called by the Spend Button in the Rewards' Menubar)
+ SPEND_COINS_SEND_PRICE,
+ /// Receive the price of the selected item (called by the List)
+ SPEND_COINS_RECEIVE_PRICE,
+ /// Handle the Timer button's shortcut (called by the Main Window)
+ TIMER_SHORTCUT,
+ /// Handle the Rates button's shortcut (called by the Main Window)
+ RATES_SHORTCUT,
+ /// Handle the Rewards button's shortcut (called by the Main Window)
+ REWARDS_SHORTCUT,
+ /// Handle the Add Button's shortcut (called by the Main Window)
+ ADD_BUTTON_SHORTCUT,
+ /// Handle the Delete Button's shortcut (called by the Main Window)
+ DELETE_BUTTON_SHORTCUT,
+ /// Handle the Edit Button's shortcut (called by the Main Window)
+ EDIT_BUTTON_SHORTCUT,
+ /// Handle the Spend Button's shortcut (called by the Main Window)
+ SPEND_BUTTON_SHORTCUT,
+ /// Lock the Delete Button in the Rewards' Menubar (called by the List)
+ LOCK_THE_DELETE_A_REWARD_BUTTON,
+ /// Unlock the Delete Button in the Rewards' Menubar (called by the List)
+ UNLOCK_THE_DELETE_A_REWARD_BUTTON,
+ /// Lock the Edit Button in the Rewards' Menubar (called by the List)
+ LOCK_THE_EDIT_A_REWARD_BUTTON,
+ /// Unlock the Delete Button in the Rewards' Menubar (called by the List)
+ UNLOCK_THE_EDIT_A_REWARD_BUTTON,
+ /// Check the affordability (meaning, if the user has enough coins) of the reward. This leads
+ /// to the Spend button in the Rewards' Menubar getting locked / unlocked (called by the List)
+ CHECK_AFFORDABILITY_RECEIVE_PRICE,
+ /// Lock the Spend Button in the Rewards' Menubar (called by the List)
+ LOCK_THE_SPEND_BUTTON,
+ /// Unlock the Spend Button in the Rewards' Menubar (called by the List)
+ UNLOCK_THE_SPEND_BUTTON
+);
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..0a37d3d
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,41 @@
+// Switch from the console subsystem to the windows subsystem in the release mode
+#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
+
+mod channels;
+mod events;
+mod ui;
+
+use fltk::app;
+
+/// Run the program
+fn main() {
+ // Application channels
+ let channels = channels::Channels::default();
+
+ // Override the system's screen scaling
+ for i in 0..app::screen_count() {
+ app::set_screen_scale(i, 1.0);
+ }
+
+ // 0. App
+ let app = ui::app::new();
+
+ // 1. Main Window
+ let _w = ui::windows::main(&channels);
+
+ // Hidden Windows
+
+ // 1. Reward Edit Window
+ let re_w = ui::windows::rewards_edit(&channels);
+
+ // Start the event loop
+ while app.wait() {
+ // Retranslate the signals between the windows
+ if let Ok(event) = channels.mw.r.try_recv() {
+ app::handle_main(event).ok();
+ };
+ if let Ok(event) = channels.rewards_edit.r.try_recv() {
+ app::handle(event, &re_w).ok();
+ }
+ }
+}
diff --git a/src/ui/app.rs b/src/ui/app.rs
new file mode 100644
index 0000000..50653c8
--- /dev/null
+++ b/src/ui/app.rs
@@ -0,0 +1,19 @@
+//! This module provides the app initialization function.
+
+use fltk::{
+ app::{self, App},
+ enums::{Color, FrameType},
+ misc::Tooltip,
+};
+
+/// Initialize the app
+pub fn new() -> App {
+ let app = App::default();
+ app::background(255, 255, 255);
+ app::set_frame_type(FrameType::BorderBox);
+
+ Tooltip::set_color(Color::BackGround);
+ Tooltip::set_hoverdelay(0.0);
+
+ app
+}
diff --git a/src/ui/constants.rs b/src/ui/constants.rs
new file mode 100644
index 0000000..c2442a7
--- /dev/null
+++ b/src/ui/constants.rs
@@ -0,0 +1,30 @@
+//! This module provides the constants.
+
+use fltk::enums::FrameType;
+
+// Numeric constants
+
+/// Main Window's width
+pub const MAIN_WINDOW_WIDTH: i32 = 340;
+/// Main Window's height
+///
+/// These 3 pixels are the 1px spacing in Rewards / Rates panes and
+/// 1px top and bottom borders of the Scroll widget in the custom List widget
+pub const MAIN_WINDOW_HEIGHT: i32 = 300 + 3;
+/// Focus Pane's height
+pub const FOCUS_PANE_HEIGHT: i32 = 60;
+/// Rewards' Menubar's height
+pub const REWARDS_MENUBAR_HEIGHT: i32 = 30;
+/// Rewards Edit's width
+pub const REWARDS_EDIT_WINDOW_WIDTH: i32 = 320;
+/// Rewards Edit's height
+pub const REWARDS_EDIT_WINDOW_HEIGHT: i32 = 140;
+
+// Default frame types for the buttons
+
+/// Default frame type for boxed buttons
+pub const BOXED_BUTTON_FRAME_TYPE: FrameType = FrameType::BorderBox;
+/// Default frame type for flat buttons
+pub const FLAT_BUTTON_FRAME_TYPE: FrameType = FrameType::FlatBox;
+/// Default down frame type for buttons
+pub const DOWN_BUTTON_FRAME_TYPE: FrameType = FrameType::DownBox;
diff --git a/src/ui/focus/arrow.rs b/src/ui/focus/arrow.rs
new file mode 100644
index 0000000..80390f4
--- /dev/null
+++ b/src/ui/focus/arrow.rs
@@ -0,0 +1,103 @@
+//! A button, which you can click on to bring the [Rates](super::super::rates) pane.
+//!
+//! The arrow represents the conversion process between the time and the coins.
+
+use fltk::{app, button::Button, draw, image::SvgImage, prelude::*};
+
+use crate::events;
+use crate::ui::constants::{DOWN_BUTTON_FRAME_TYPE, FLAT_BUTTON_FRAME_TYPE};
+use crate::ui::logic;
+
+/// Initialize the arrow button
+pub fn arrow() -> Button {
+ let mut a = Button::default();
+ a.set_frame(FLAT_BUTTON_FRAME_TYPE);
+ a.set_down_frame(DOWN_BUTTON_FRAME_TYPE);
+ a.set_tooltip("Show the Conversion Rates pane");
+
+ // These 14px are the padding. Note that the 48 / 30 ratio is the ratio of the SVG
+ a.set_size(48 + 14, 30);
+
+ // Setting the size and position to non-zero values is needed for
+ // the widget to appear after re-adding it to the pane. Initial position
+ // should not overlap other widgets, since this will lead to visual artifacts
+ if let Some(ref p) = a.parent() {
+ if let Some(ref lw) = p.child(0) {
+ if let Some(ref rw) = p.child(1) {
+ replace(&mut a, p, lw, rw);
+ }
+ }
+ }
+
+ a.draw(draw);
+ logic(&mut a);
+
+ a
+}
+
+/// Draw the arrow button
+fn draw(f: &mut T) {
+ // Replace if needed
+ if let Some(ref p) = f.parent() {
+ if let Some(ref lw) = p.child(0) {
+ if let Some(ref rw) = p.child(2) {
+ replace(f, p, lw, rw);
+ }
+ }
+ }
+ // Setting a clip is a safety measure to limit the region of the drawing
+ draw::push_clip(f.x(), f.y(), f.w(), f.h());
+ draw_image(f);
+ draw::pop_clip();
+}
+
+/// Position the arrow in the middle of the two adjacent widgets
+fn replace(f: &mut U, p: &T, lw: &S, rw: &S) {
+ let lx = lw.x() + lw.w();
+ f.set_pos(lx + (rw.x() - lx - f.w()) / 2, p.y() + 15);
+}
+
+/// Draw the image of the arrow
+fn draw_image(f: &mut T) {
+ let mut ai = SvgImage::from_data(include_str!("../../../assets/menu/arrow.svg")).unwrap();
+ // These 14px is the padding
+ ai.scale(f.w() - 14, f.h(), true, true);
+ ai.draw(f.x() + 7, f.y(), f.w() - 14, f.h());
+}
+
+/// Set a callback and a handler for the arrow button
+fn logic(a: &mut T) {
+ // On a callback, show the Rates pane if it's not visible, or,
+ // otherwise, hide it. Change the tooltip as appropriate, too
+ a.set_callback(|a| {
+ if let Some(ref p) = a.parent() {
+ if let Some(ref mut c) = p.child(1) {
+ if let Some(ref mut w) = p.parent() {
+ if let Some(ref mut re_p) = w.child(1) {
+ if let Some(ref mut ra_p) = w.child(2) {
+ if re_p.visible() {
+ re_p.hide();
+ ra_p.show();
+ a.set_tooltip("Hide the Conversion Rates pane");
+ } else if ra_p.visible() {
+ app::handle_main(events::MAIN_WINDOW_HIDE_THE_PANE).ok();
+ ra_p.hide();
+ a.set_tooltip("Show the Conversion Rates pane");
+ } else {
+ app::handle_main(events::MAIN_WINDOW_SHOW_THE_PANE).ok();
+ ra_p.show();
+ a.set_tooltip("Hide the Conversion Rates pane");
+ }
+ c.set_tooltip("Show the Rewards pane");
+ }
+ }
+ }
+ }
+ }
+ });
+ // Handle the shortcut and focus / selection events
+ a.handle(move |a, ev| {
+ logic::handle_shortcut(a, ev.bits(), events::RATES_SHORTCUT)
+ || logic::handle_fp_button(a, ev, 1)
+ });
+}
diff --git a/src/ui/focus/coins.rs b/src/ui/focus/coins.rs
new file mode 100644
index 0000000..a34ce45
--- /dev/null
+++ b/src/ui/focus/coins.rs
@@ -0,0 +1,167 @@
+//! A button, which you can click on to bring the [Rewards](super::super::rewards) pane.
+//!
+//! The widget also keeps the number of coins the user has, and updates it if necessary.
+
+use fltk::{
+ app,
+ button::Button,
+ draw,
+ enums::{Align, Color, LabelType},
+ image::SvgImage,
+ prelude::*,
+};
+
+use crate::channels::Channels;
+use crate::events;
+use crate::ui::constants::{DOWN_BUTTON_FRAME_TYPE, FLAT_BUTTON_FRAME_TYPE};
+use crate::ui::logic;
+
+/// Initialize the coins button
+pub fn coins(channels: &Channels) -> Button {
+ // Setting the label, but in the meantime disabling
+ // its default drawing allows for the custom drawing
+ let mut c = Button::default().with_label("99999");
+ c.set_label_type(LabelType::None);
+
+ c.set_frame(FLAT_BUTTON_FRAME_TYPE);
+ c.set_down_frame(DOWN_BUTTON_FRAME_TYPE);
+ c.set_tooltip("Hide the Rewards pane");
+
+ // This widget changes in size depending on the number of (digits in) the coins
+ resize(&mut c);
+ c.draw(draw);
+ logic(&mut c, channels);
+
+ c
+}
+
+/// Draw the coins button
+fn draw(b: &mut T) {
+ // Setting a clip is a safety measure to limit the region of the drawing
+ draw::push_clip(b.x(), b.y(), b.w(), b.h());
+ draw_label(b);
+ draw_image(b);
+ draw::pop_clip();
+}
+
+/// Resize the button depending on the size of its label
+fn resize(b: &mut T) {
+ let (lw, _) = b.measure_label();
+ if let Some(ref p) = b.parent() {
+ b.set_pos(p.x() + p.w() - lw - 60, p.y() + 10);
+ b.set_size(lw + 50, p.h() - 20);
+ }
+}
+
+/// Draw the button's label
+fn draw_label(b: &mut T) {
+ // Saving the draw color and reapplying it in the end is a safety measure
+ let color = draw::get_color();
+
+ draw::set_font(draw::font(), 16);
+ draw::set_draw_color(Color::Black);
+ draw::draw_text2(
+ &b.label(),
+ b.x() + 33,
+ b.y() + 1,
+ b.w() - 40,
+ b.h(),
+ Align::Right,
+ );
+
+ draw::set_draw_color(color);
+}
+
+/// Draw the image of the coins
+fn draw_image(b: &mut T) {
+ let mut ci = SvgImage::from_data(include_str!("../../../assets/menu/coins.svg")).unwrap();
+ ci.scale(24, 24, true, true);
+ ci.draw(b.x() + 6, b.y() + 8, 24, 24);
+}
+
+/// Set a callback and a handler for the coins button
+fn logic(c: &mut T, channels: &Channels) {
+ // On a callback, show the Rewards pane if it's not visible, or,
+ // otherwise, hide it. Change the tooltip as appropriate, too
+ c.set_callback(|c| {
+ if let Some(ref p) = c.parent() {
+ if let Some(ref mut a) = p.child(2) {
+ if let Some(ref mut w) = p.parent() {
+ if let Some(ref mut re_p) = w.child(1) {
+ if let Some(ref mut ra_p) = w.child(2) {
+ if ra_p.visible() {
+ ra_p.hide();
+ re_p.show();
+ c.set_tooltip("Hide the Rewards pane");
+ } else if re_p.visible() {
+ app::handle_main(events::MAIN_WINDOW_HIDE_THE_PANE).ok();
+ re_p.hide();
+ c.set_tooltip("Show the Rewards pane");
+ } else {
+ app::handle_main(events::MAIN_WINDOW_SHOW_THE_PANE).ok();
+ re_p.show();
+ c.set_tooltip("Hide the Rewards pane");
+ }
+ a.set_tooltip("Show the Conversion Rates pane");
+ }
+ }
+ }
+ }
+ }
+ });
+ // Handle the custom events, shortcut, and focus / selection events
+ c.handle({
+ // Receive coins
+ let r_coins = channels.rewards_receive_coins.r.clone();
+ move |c, ev| {
+ let bits = ev.bits();
+ // In case of the
+ match bits {
+ // 'Spend the coins' event, receive the price
+ events::SPEND_COINS_RECEIVE_PRICE => r_coins.try_recv().map_or(false, |price| {
+ // Get the total number of coins
+ c.label().parse::().map_or(false, |coins| {
+ // Calculate the diff, rounding it to the second digit in mantissa
+ let diff = ((coins - price) * 100.0).round() / 100.0;
+ // Update the total number of coins
+ c.set_label(&diff.to_string());
+ // Send a signal to Rewards to delete the currently selected reward
+ app::handle_main(events::DELETE_A_REWARD).ok();
+ // Resize the coins button if the number of digits changed
+ resize(c);
+ // Redraw the pane, so that the `arrow`'s
+ // position gets updated too if needed
+ if let Some(ref mut p) = c.parent() {
+ p.redraw();
+ }
+ true
+ })
+ }),
+ // `Check the affordability` event
+ events::CHECK_AFFORDABILITY_RECEIVE_PRICE => {
+ // Receive the price
+ r_coins.try_recv().map_or(false, |price| {
+ // Get the total number of coins
+ c.label().parse::().map_or(false, |coins| {
+ // If the reward is affordable
+ if price <= coins {
+ // Unlock the spend button
+ app::handle_main(events::UNLOCK_THE_SPEND_BUTTON).ok();
+ // Otherwise,
+ } else {
+ // Lock it
+ app::handle_main(events::LOCK_THE_SPEND_BUTTON).ok();
+ }
+ true
+ })
+ })
+ }
+ // Otherwise, handle the shortcut and focus / selection events
+ _ => {
+ logic::handle_shortcut(c, bits, events::REWARDS_SHORTCUT)
+ || logic::handle_fp_button(c, ev, 2)
+ }
+ }
+ }
+ });
+}
diff --git a/src/ui/focus/mod.rs b/src/ui/focus/mod.rs
new file mode 100644
index 0000000..9531d39
--- /dev/null
+++ b/src/ui/focus/mod.rs
@@ -0,0 +1,19 @@
+//! Focus pane is the primary pane, providing access to the focus / conversion process.
+//!
+//! It provides ways for the user to:
+//! - start the time-to-coins conversion process;
+//! - see how long this process is running;
+//! - see the total number of coins;
+//! - get access to the [Rates](super::rates) pane;
+//! - get access to the [Rewards](super::rewards) pane
+
+mod arrow;
+mod coins;
+mod pane;
+mod timer;
+
+pub use pane::pane;
+
+use arrow::arrow;
+use coins::coins;
+use timer::timer;
diff --git a/src/ui/focus/pane.rs b/src/ui/focus/pane.rs
new file mode 100644
index 0000000..a2333aa
--- /dev/null
+++ b/src/ui/focus/pane.rs
@@ -0,0 +1,75 @@
+//! The pane itself. It is supposed to be the top-most pane in the main window.
+
+use fltk::{
+ app,
+ enums::{Event, FrameType, Key},
+ group::Group,
+ prelude::*,
+};
+
+use super::{arrow, coins, timer};
+use crate::channels::Channels;
+use crate::ui::constants::FOCUS_PANE_HEIGHT;
+
+/// Initialize the Focus pane
+pub fn pane(channels: &Channels) -> Group {
+ let mut p = Group::default().with_pos(10, 10);
+ p.set_frame(FrameType::BorderBox);
+
+ // If this pane is a child of the Main Window
+ if let Some(ref w) = p.parent() {
+ // Set the size, excluding the margins
+ p.set_size(w.width() - 20, FOCUS_PANE_HEIGHT);
+ }
+
+ // Initialize the widgets, so that the `arrow` widget is the last one
+ let _timer = timer();
+ let coins = coins(channels);
+ let arrow = arrow();
+
+ // The reason why the pack ends here and removes / adds the items later is that
+ // the `arrow` widget needs to know about the positions and sizes of the
+ // other two widgets: `timer` and `coins`, because the `coins`' size depends on
+ // the number of coins, and the `arrow` widget is supposed to be placed between
+ // the two. But it is also important to have the `arrow` widget as a second
+ // child, so it is possible to use the default handle function for the buttons
+ p.end();
+
+ // Reorder the children, so that the `arrow` widget is the second one
+ p.remove(&arrow);
+ p.remove(&coins);
+ p.add(&arrow);
+ p.add(&coins);
+
+ p.handle(handle);
+
+ p
+}
+
+/// Handle events for the Focus pane
+fn handle(p: &mut T, ev: Event) -> bool {
+ match ev {
+ // In case of a pressed down button
+ Event::KeyDown => {
+ // If it's a `Tab` key
+ if app::event_key() == Key::Tab {
+ // Focus on the secondary pane
+ if let Some(ref w) = p.parent() {
+ if let Some(ref mut re_p) = w.child(1) {
+ if let Some(ref mut ra_p) = w.child(2) {
+ if re_p.visible() {
+ re_p.take_focus().ok();
+ } else if ra_p.visible() {
+ ra_p.take_focus().ok();
+ }
+ }
+ }
+ }
+ true
+ } else {
+ false
+ }
+ }
+ _ => false,
+ }
+}
diff --git a/src/ui/focus/timer.rs b/src/ui/focus/timer.rs
new file mode 100644
index 0000000..8803012
--- /dev/null
+++ b/src/ui/focus/timer.rs
@@ -0,0 +1,171 @@
+//! A button, which represents a timer. Clicking on it starts the ticking.
+//!
+//! While it ticks, the coins get generated. Clicking on it again stops and resets the timer.
+
+use fltk::{
+ app,
+ button::Button,
+ draw,
+ enums::{Align, Color, LabelType},
+ image::SvgImage,
+ prelude::*,
+};
+
+use crate::events;
+use crate::ui::constants::{DOWN_BUTTON_FRAME_TYPE, FLAT_BUTTON_FRAME_TYPE};
+use crate::ui::logic;
+
+/// Initialize the timer
+pub fn timer() -> Button {
+ // Setting the label, but in the meantime disabling
+ // its default drawing allows for the custom drawing
+ let mut t = Button::default().with_label("00:00:00");
+ t.set_label_type(LabelType::None);
+
+ t.set_frame(FLAT_BUTTON_FRAME_TYPE);
+ t.set_down_frame(DOWN_BUTTON_FRAME_TYPE);
+ t.set_tooltip("Start the timer");
+
+ // The timer is the only one among the three widgets
+ // in the pane which change neither position nor size
+ if let Some(p) = t.parent() {
+ t.set_pos(p.x() + 10, p.y() + 10);
+ t.set_size(110, p.h() - 20);
+ }
+
+ t.draw(draw);
+
+ logic(&mut t);
+
+ t
+}
+
+/// Draw the timer
+fn draw(b: &mut T) {
+ // Setting a clip is a safety measure to limit the region of the drawing
+ draw::push_clip(b.x(), b.y(), b.w(), b.h());
+ draw_label(b);
+ draw_image(b);
+ draw::pop_clip();
+}
+
+/// Draw the timer's label
+fn draw_label(b: &mut T) {
+ // Saving the draw color and reapplying it in the end is a safety measure
+ let color = draw::get_color();
+
+ draw::set_font(draw::font(), 16);
+ draw::set_draw_color(Color::Black);
+ draw::draw_text2(
+ &b.label(),
+ b.x() + 32,
+ b.y() + 1,
+ b.w() - 40,
+ b.h(),
+ Align::Right,
+ );
+
+ draw::set_draw_color(color);
+}
+
+/// Draw the timer's icon
+fn draw_image(b: &mut T) {
+ let mut ti = SvgImage::from_data(include_str!("../../../assets/menu/sand-clock.svg")).unwrap();
+ ti.scale(24, 24, true, true);
+ ti.draw(b.x() + 6, b.y() + 8, 24, 24);
+}
+
+/// Set a callback and a handler for the timer
+fn logic(t: &mut T) {
+ // The default callback is set to start the ticking
+ t.set_callback(start);
+ // Handle the custom events, shortcut, and focus / selection events
+ t.handle({
+ // Is the timer counting?
+ let mut counting = false;
+ // How many seconds does it have at the moment?
+ let mut seconds = 0;
+ move |t, ev| {
+ let bits = ev.bits();
+ // In case of the
+ match bits {
+ // 'Start the timer` event
+ events::START_TIMER => {
+ // If it's not counting already
+ if !counting {
+ // Start the counting
+ counting = true;
+ app::add_timeout2(1.0, tick);
+ }
+ true
+ }
+ // 'Tick' event
+ events::TICK => {
+ seconds += 1;
+
+ // Calculate the integer parts of the time
+ let hours = seconds / 3600;
+ let minutes = seconds / 60 % 60;
+ let seconds = seconds % 60;
+
+ // Update the label, applying the `00:00:00` format to these numbers
+ t.set_label(&format!(
+ "{}:{}:{}",
+ if hours > 9 {
+ format!("{}", hours)
+ } else {
+ format!("0{}", hours)
+ },
+ if minutes > 9 {
+ format!("{}", minutes)
+ } else {
+ format!("0{}", minutes)
+ },
+ if seconds > 9 {
+ format!("{}", seconds)
+ } else {
+ format!("0{}", seconds)
+ }
+ ));
+
+ true
+ }
+ // 'Stop the timer' event
+ events::STOP_TIMER => {
+ // Stop the counting
+ counting = false;
+ seconds = 0;
+ app::remove_timeout2(tick);
+ // Reset the label
+ t.set_label("00:00:00");
+ true
+ }
+ // Otherwise, handle the shortcut and focus / selection events
+ _ => {
+ logic::handle_shortcut(t, bits, events::TIMER_SHORTCUT)
+ || logic::handle_fp_button(t, ev, 0)
+ }
+ }
+ }
+ });
+}
+
+/// Start the ticking
+fn start(b: &mut T) {
+ app::handle_main(events::START_TIMER).ok();
+ b.set_tooltip("Stop the timer");
+ b.set_callback(stop);
+}
+
+/// Stop the ticking
+fn stop(b: &mut T) {
+ app::handle_main(events::STOP_TIMER).ok();
+ b.set_tooltip("Start the timer");
+ b.set_callback(start);
+}
+
+/// Tick
+fn tick() {
+ app::handle_main(events::TICK).ok();
+ app::repeat_timeout2(1.0, tick);
+}
diff --git a/src/ui/logic/get_price.rs b/src/ui/logic/get_price.rs
new file mode 100644
index 0000000..7939259
--- /dev/null
+++ b/src/ui/logic/get_price.rs
@@ -0,0 +1,14 @@
+//! Parse the price in a Rewards' Item's string.
+
+/// Parse the price in a Rewards' Item's string
+pub fn get_price(string: &str) -> Option {
+ if let Some(rb_i) = string.find(')') {
+ if let Ok(price) = string[1..rb_i].parse::() {
+ Some(price)
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+}
diff --git a/src/ui/logic/get_text.rs b/src/ui/logic/get_text.rs
new file mode 100644
index 0000000..48b8469
--- /dev/null
+++ b/src/ui/logic/get_text.rs
@@ -0,0 +1,14 @@
+//! Get the text from the Rewards' Item's string.
+
+/// Get the text from the Rewards' Item's string
+pub fn get_text(string: &str) -> Option {
+ if let Some(rb_i) = string.find(')') {
+ if rb_i + 2 <= string.len() {
+ Some(string[(rb_i + 2)..].to_string())
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+}
diff --git a/src/ui/logic/handle_button.rs b/src/ui/logic/handle_button.rs
new file mode 100644
index 0000000..d72a826
--- /dev/null
+++ b/src/ui/logic/handle_button.rs
@@ -0,0 +1,34 @@
+//! Handle the events for the button.
+
+use fltk::{
+ app,
+ enums::{Event, Key},
+ prelude::*,
+};
+
+use super::{handle_left, handle_right, handle_selection, handle_tab};
+use crate::ui::constants::FLAT_BUTTON_FRAME_TYPE;
+
+/// Handle the events for the button
+pub fn handle_button(b: &mut T, ev: Event, idx: i32, lock: bool) -> bool {
+ match ev {
+ Event::KeyDown => match app::event_key() {
+ Key::Left => handle_left(b, idx),
+ Key::Right => handle_right(b, idx),
+ _ => handle_selection(b, ev, FLAT_BUTTON_FRAME_TYPE, lock),
+ },
+ _ => handle_selection(b, ev, FLAT_BUTTON_FRAME_TYPE, lock),
+ }
+}
+
+/// Handle the events for the button in the Focus pane
+pub fn handle_fp_button(b: &mut T, ev: Event, idx: i32) -> bool {
+ if ev == Event::KeyDown {
+ match app::event_key() {
+ Key::Tab => handle_tab(b),
+ _ => handle_button(b, ev, idx, false),
+ }
+ } else {
+ handle_button(b, ev, idx, false)
+ }
+}
diff --git a/src/ui/logic/handle_left.rs b/src/ui/logic/handle_left.rs
new file mode 100644
index 0000000..d6e9445
--- /dev/null
+++ b/src/ui/logic/handle_left.rs
@@ -0,0 +1,31 @@
+//! Handle [`Key::Left`](fltk::enums::Key::Left) events for the button.
+
+use fltk::prelude::*;
+
+/// Handle Left events for the button
+pub fn handle_left(b: &mut T, idx: i32) -> bool {
+ if let Some(ref p) = b.parent() {
+ let max = p.children() - 1;
+ let prev_idx = get_prev_idx(idx, max);
+ if let Some(ref mut cw) = p.child(prev_idx) {
+ // This one-time check is needed to jump over menu dividers
+ if cw.has_visible_focus() {
+ cw.take_focus().ok();
+ } else if let Some(ref mut pcw) = p.child(get_prev_idx(prev_idx, max)) {
+ if pcw.has_visible_focus() {
+ pcw.take_focus().ok();
+ }
+ }
+ }
+ }
+ true
+}
+
+/// Get the previous index
+fn get_prev_idx(idx: i32, max: i32) -> i32 {
+ if idx == 0 {
+ max
+ } else {
+ idx - 1
+ }
+}
diff --git a/src/ui/logic/handle_lock.rs b/src/ui/logic/handle_lock.rs
new file mode 100644
index 0000000..f8a1c9a
--- /dev/null
+++ b/src/ui/logic/handle_lock.rs
@@ -0,0 +1,43 @@
+//! Handle custom Lock / Unlock events for the button.
+
+use fltk::prelude::*;
+
+use super::{mouse_hovering, select_active, select_inactive};
+
+/// Handle Lock / Unlock events for the button
+pub fn handle_lock(
+ b: &mut T,
+ lock: &mut bool,
+ callback: F,
+ bits: i32,
+ lock_event: i32,
+ unlock_event: i32,
+) -> bool
+where
+ F: Fn(&mut T),
+{
+ // In case of a lock event
+ if bits == lock_event {
+ // Lock and remove the callback
+ *lock = true;
+ b.set_callback(|_| {});
+ // Redraw as an inactive button if hovering it
+ if mouse_hovering(b) {
+ select_inactive(b);
+ }
+ true
+ // In case of an unlock event
+ } else if bits == unlock_event {
+ // Unlock and reset the callback
+ *lock = false;
+ b.set_callback(callback);
+ // Redraw as an active button if hovering it
+ if mouse_hovering(b) {
+ select_active(b);
+ }
+ true
+ // Otherwise, do nothing
+ } else {
+ false
+ }
+}
diff --git a/src/ui/logic/handle_right.rs b/src/ui/logic/handle_right.rs
new file mode 100644
index 0000000..00beb24
--- /dev/null
+++ b/src/ui/logic/handle_right.rs
@@ -0,0 +1,31 @@
+//! Handle [`Key::Right`](fltk::enums::Key::Right) events for the button.
+
+use fltk::prelude::*;
+
+/// Handle Right events for the button
+pub fn handle_right(b: &mut T, idx: i32) -> bool {
+ if let Some(ref p) = b.parent() {
+ let max = p.children() - 1;
+ let next_idx = get_next_idx(idx, max);
+ if let Some(ref mut cw) = p.child(next_idx) {
+ // This one-time check is needed to jump over menu dividers
+ if cw.has_visible_focus() {
+ cw.take_focus().ok();
+ } else if let Some(ref mut ncw) = p.child(get_next_idx(next_idx, max)) {
+ if ncw.has_visible_focus() {
+ ncw.take_focus().ok();
+ }
+ }
+ }
+ }
+ true
+}
+
+/// Get the next index
+fn get_next_idx(idx: i32, max: i32) -> i32 {
+ if idx == max {
+ 0
+ } else {
+ idx + 1
+ }
+}
diff --git a/src/ui/logic/handle_selection.rs b/src/ui/logic/handle_selection.rs
new file mode 100644
index 0000000..ad8cf03
--- /dev/null
+++ b/src/ui/logic/handle_selection.rs
@@ -0,0 +1,129 @@
+//! Handle [`Focus`](fltk::enums::Event::Focus) and other selection events.
+
+use fltk::{
+ app,
+ enums::{Color, Event, FrameType, Key},
+ prelude::*,
+};
+
+/// Color of an active button
+const ACTIVE_SELECTION_COLOR: u32 = 0xE5_F3_FF;
+/// Color of an inactive button
+const INACTIVE_SELECTION_COLOR: u32 = 0xF5_FA_FE;
+
+/// Handle focus / selection events for the active button
+fn handle_active_selection(
+ b: &mut T,
+ ev: Event,
+ unselect_frame_type: FrameType,
+) -> bool {
+ match ev {
+ Event::Focus => {
+ if app::event_key_down(Key::Enter) {
+ select_active(b);
+ }
+ true
+ }
+ Event::Enter => {
+ select_active(b);
+ true
+ }
+ Event::Leave | Event::Unfocus | Event::Hide => {
+ unselect(b, unselect_frame_type);
+ true
+ }
+ Event::KeyDown => match app::event_key() {
+ Key::Enter => {
+ select_active(b);
+ true
+ }
+ _ => false,
+ },
+ Event::KeyUp => match app::event_key() {
+ Key::Enter => {
+ b.do_callback();
+ unselect(b, unselect_frame_type);
+ true
+ }
+ _ => false,
+ },
+ _ => false,
+ }
+}
+
+/// Handle focus / selection events for the inactive button
+fn handle_inactive_selection(
+ b: &mut T,
+ ev: Event,
+ unselect_frame_type: FrameType,
+) -> bool {
+ match ev {
+ Event::Focus => {
+ if app::event_key_down(Key::Enter) {
+ select_inactive(b);
+ }
+ true
+ }
+ Event::Enter => {
+ select_inactive(b);
+ true
+ }
+ Event::Leave | Event::Unfocus | Event::Hide => {
+ unselect(b, unselect_frame_type);
+ true
+ }
+ Event::KeyDown => match app::event_key() {
+ Key::Enter => {
+ select_inactive(b);
+ true
+ }
+ _ => false,
+ },
+ Event::KeyUp => match app::event_key() {
+ Key::Enter => {
+ unselect(b, unselect_frame_type);
+ true
+ }
+ _ => false,
+ },
+ _ => false,
+ }
+}
+
+/// Handle focus / selection events for the button
+pub fn handle_selection(
+ b: &mut T,
+ ev: Event,
+ unselect_frame_type: FrameType,
+ lock: bool,
+) -> bool {
+ if lock {
+ handle_inactive_selection(b, ev, unselect_frame_type)
+ } else {
+ handle_active_selection(b, ev, unselect_frame_type)
+ }
+}
+
+/// Select the button with the specified color
+fn select(b: &mut T, hex: u32) {
+ b.set_color(Color::from_hex(hex));
+ b.set_frame(FrameType::BorderBox);
+ b.redraw();
+}
+
+/// Select the active button
+pub fn select_active(b: &mut T) {
+ select(b, ACTIVE_SELECTION_COLOR);
+}
+
+/// Select the inactive button
+pub fn select_inactive(b: &mut T) {
+ select(b, INACTIVE_SELECTION_COLOR);
+}
+
+/// Unselect the button
+fn unselect(b: &mut T, unselect_frame_type: FrameType) {
+ b.set_color(Color::BackGround);
+ b.set_frame(unselect_frame_type);
+ b.redraw();
+}
diff --git a/src/ui/logic/handle_shortcut.rs b/src/ui/logic/handle_shortcut.rs
new file mode 100644
index 0000000..51bd1b2
--- /dev/null
+++ b/src/ui/logic/handle_shortcut.rs
@@ -0,0 +1,13 @@
+//! Handle custom Shortcut events for the button.
+
+use fltk::prelude::*;
+
+/// Handle custom shortcut events for the button
+pub fn handle_shortcut(b: &mut T, bits: i32, shortcut_event: i32) -> bool {
+ if bits == shortcut_event {
+ b.do_callback();
+ true
+ } else {
+ false
+ }
+}
diff --git a/src/ui/logic/handle_tab.rs b/src/ui/logic/handle_tab.rs
new file mode 100644
index 0000000..2948ea5
--- /dev/null
+++ b/src/ui/logic/handle_tab.rs
@@ -0,0 +1,18 @@
+//! Handle [`Key::Tab`](fltk::enums::Key::Tab) events for the buttons.
+
+use fltk::prelude::*;
+
+/// Handle [`Key::Tab`](fltk::enums::Key::Tab) events for the buttons
+pub fn handle_tab(b: &mut T) -> bool {
+ // The idea here is to block the handling of the `Tab` event (by
+ // returning `true`) if neither of the secondary panes is visible.
+ // If that's `false`, the pane's `Tab` handling will be used
+ b.parent().map_or(false, |p| {
+ p.parent().map_or(false, |w| {
+ w.child(1).map_or(false, |re_p| {
+ w.child(2)
+ .map_or(false, |ra_p| !(re_p.visible() || ra_p.visible()))
+ })
+ })
+ })
+}
diff --git a/src/ui/logic/mod.rs b/src/ui/logic/mod.rs
new file mode 100644
index 0000000..730641d
--- /dev/null
+++ b/src/ui/logic/mod.rs
@@ -0,0 +1,25 @@
+//! This module provides shared behavior between the whole program.
+
+mod get_price;
+mod get_text;
+mod handle_button;
+mod handle_left;
+mod handle_lock;
+mod handle_right;
+mod handle_selection;
+mod handle_shortcut;
+mod handle_tab;
+mod mouse_hovering;
+
+pub use get_price::get_price;
+pub use get_text::get_text;
+pub use handle_button::{handle_button, handle_fp_button};
+pub use handle_lock::handle_lock;
+pub use handle_selection::handle_selection;
+pub use handle_shortcut::handle_shortcut;
+
+use handle_left::handle_left;
+use handle_right::handle_right;
+use handle_selection::{select_active, select_inactive};
+use handle_tab::handle_tab;
+use mouse_hovering::mouse_hovering;
diff --git a/src/ui/logic/mouse_hovering.rs b/src/ui/logic/mouse_hovering.rs
new file mode 100644
index 0000000..68fd2f6
--- /dev/null
+++ b/src/ui/logic/mouse_hovering.rs
@@ -0,0 +1,11 @@
+//! Return [`true`] if mouse hovers the widget.
+
+use fltk::{app, prelude::*};
+
+/// Return [`true`] if the mouse hovers the widget
+pub fn mouse_hovering(w: &T) -> bool {
+ app::event_x() >= w.x()
+ && app::event_x() <= w.x() + w.w()
+ && app::event_y() >= w.y()
+ && app::event_y() <= w.y() + w.h()
+}
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
new file mode 100644
index 0000000..acc2b3b
--- /dev/null
+++ b/src/ui/mod.rs
@@ -0,0 +1,11 @@
+//! This module provides the program's UI.
+
+pub mod app;
+pub mod constants;
+pub mod widgets;
+pub mod windows;
+
+mod focus;
+mod logic;
+mod rates;
+mod rewards;
diff --git a/src/ui/rates/list.rs b/src/ui/rates/list.rs
new file mode 100644
index 0000000..8dced30
--- /dev/null
+++ b/src/ui/rates/list.rs
@@ -0,0 +1,26 @@
+//! Rates' List contains the conversion rates.
+
+use fltk::prelude::*;
+
+use crate::ui::constants::REWARDS_MENUBAR_HEIGHT;
+use crate::ui::widgets::list::List;
+
+/// Initialize the list
+pub fn new() -> List {
+ let mut l = List::default();
+
+ // If this list is a child of the Rates pane
+ if let Some(ref p) = l.parent() {
+ // Set the list's height to all of the available parent's height
+ l.set_size(0, p.h() - REWARDS_MENUBAR_HEIGHT);
+ }
+
+ // Add examples (temporary)
+ l.add("5/m");
+ l.add("0.1/s");
+
+ // Select the first one. This list is supposed to always have a selected item
+ l.select(1);
+
+ l
+}
diff --git a/src/ui/rates/menubar/add_button.rs b/src/ui/rates/menubar/add_button.rs
new file mode 100644
index 0000000..f073416
--- /dev/null
+++ b/src/ui/rates/menubar/add_button.rs
@@ -0,0 +1,37 @@
+//! Add Button opens the Rates Edit window, set to add a new item.
+
+use fltk::{button::Button, image::SvgImage, prelude::*};
+
+use crate::events;
+use crate::ui::constants::{
+ DOWN_BUTTON_FRAME_TYPE, FLAT_BUTTON_FRAME_TYPE, REWARDS_MENUBAR_HEIGHT,
+};
+use crate::ui::logic;
+
+/// Initialize the add button
+pub fn add_button() -> Button {
+ let mut ai = SvgImage::from_data(include_str!("../../../../assets/menu/plus.svg")).unwrap();
+ ai.scale(24, 24, true, true);
+
+ let mut ab = Button::default().with_size(REWARDS_MENUBAR_HEIGHT, 0);
+ ab.set_image(Some(ai));
+ ab.set_frame(FLAT_BUTTON_FRAME_TYPE);
+ ab.set_down_frame(DOWN_BUTTON_FRAME_TYPE);
+ ab.set_tooltip("Add a Rate");
+
+ logic(&mut ab);
+
+ ab
+}
+
+/// Set a callback and a handler for the add button
+fn logic(ab: &mut T) {
+ ab.set_callback(|_| {
+ println!("Add Pressed!");
+ });
+ // Handle the shortcut and focus / selection events
+ ab.handle(|ab, ev| {
+ logic::handle_shortcut(ab, ev.bits(), events::ADD_BUTTON_SHORTCUT)
+ || logic::handle_button(ab, ev, 0, false)
+ });
+}
diff --git a/src/ui/rates/menubar/delete_button.rs b/src/ui/rates/menubar/delete_button.rs
new file mode 100644
index 0000000..ca28f08
--- /dev/null
+++ b/src/ui/rates/menubar/delete_button.rs
@@ -0,0 +1,37 @@
+//! Delete Button deletes the selected item in the [Rates](super::super)' List.
+
+use fltk::{button::Button, image::SvgImage, prelude::*};
+
+use crate::events;
+use crate::ui::constants::{
+ DOWN_BUTTON_FRAME_TYPE, FLAT_BUTTON_FRAME_TYPE, REWARDS_MENUBAR_HEIGHT,
+};
+use crate::ui::logic;
+
+/// Initialize the delete button
+pub fn delete_button() -> Button {
+ let mut di = SvgImage::from_data(include_str!("../../../../assets/menu/minus.svg")).unwrap();
+ di.scale(24, 24, true, true);
+
+ let mut db = Button::default().with_size(REWARDS_MENUBAR_HEIGHT, 0);
+ db.set_image(Some(di));
+ db.set_frame(FLAT_BUTTON_FRAME_TYPE);
+ db.set_down_frame(DOWN_BUTTON_FRAME_TYPE);
+ db.set_tooltip("Delete the Rate");
+
+ logic(&mut db);
+
+ db
+}
+
+/// Set a callback and a handler for the delete button
+fn logic(db: &mut T) {
+ db.set_callback(|_| {
+ println!("Delete Pressed!");
+ });
+ // Handle the shortcut and focus / selection events
+ db.handle(|db, ev| {
+ logic::handle_shortcut(db, ev.bits(), events::DELETE_BUTTON_SHORTCUT)
+ || logic::handle_button(db, ev, 1, false)
+ });
+}
diff --git a/src/ui/rates/menubar/mod.rs b/src/ui/rates/menubar/mod.rs
new file mode 100644
index 0000000..971e287
--- /dev/null
+++ b/src/ui/rates/menubar/mod.rs
@@ -0,0 +1,10 @@
+//! Rates' Menubar provides ways to add / delete / edit the conversion rates.
+
+mod add_button;
+mod delete_button;
+mod new;
+
+pub use new::new;
+
+use add_button::add_button;
+use delete_button::delete_button;
diff --git a/src/ui/rates/menubar/new.rs b/src/ui/rates/menubar/new.rs
new file mode 100644
index 0000000..93b8231
--- /dev/null
+++ b/src/ui/rates/menubar/new.rs
@@ -0,0 +1,52 @@
+//! Menubar initializer.
+
+use fltk::{
+ app,
+ enums::{Event, Key},
+ group::{Pack, PackType},
+ prelude::*,
+};
+
+use super::{add_button, delete_button};
+use crate::ui::constants::REWARDS_MENUBAR_HEIGHT;
+
+/// Initialize the menubar
+pub fn new() -> Pack {
+ // The menubar is a horizontal pack instead of an
+ // actual menubar just for the customization's sake
+ let mut m = Pack::default().with_size(0, REWARDS_MENUBAR_HEIGHT);
+ m.set_type(PackType::Horizontal);
+
+ // 1. Add a Rate
+ let _ab = add_button();
+ // 2. Delete the Rate
+ let _db = delete_button();
+
+ m.end();
+
+ logic(&mut m);
+
+ m
+}
+
+/// Set a handler for the menubar
+fn logic(m: &mut T) {
+ m.handle(|m, ev| match ev {
+ // In case of a pressed down button
+ Event::KeyDown => {
+ // If it's a `Tab`
+ if app::event_key() == Key::Tab {
+ // Focus on the Rates' list
+ if let Some(ref p) = m.parent() {
+ if let Some(ref mut l) = p.child(1) {
+ l.take_focus().ok();
+ }
+ }
+ true
+ } else {
+ false
+ }
+ }
+ _ => false,
+ });
+}
diff --git a/src/ui/rates/mod.rs b/src/ui/rates/mod.rs
new file mode 100644
index 0000000..67a9d67
--- /dev/null
+++ b/src/ui/rates/mod.rs
@@ -0,0 +1,12 @@
+//! Rates Pane is a secondary pane that provides access to conversion rates.
+//!
+//! It gives ways for the user to:
+//! - select an existing conversion rate;
+//! - create a new conversion rate;
+//! - delete the existing conversion rate
+
+mod list;
+mod menubar;
+mod pane;
+
+pub use pane::pane;
diff --git a/src/ui/rates/pane.rs b/src/ui/rates/pane.rs
new file mode 100644
index 0000000..58f6e57
--- /dev/null
+++ b/src/ui/rates/pane.rs
@@ -0,0 +1,35 @@
+//! The pane itself. It is supposed to be the second pane in the main window.
+
+use fltk::{group::Pack, prelude::*};
+
+use super::{list, menubar};
+use crate::ui::constants::FOCUS_PANE_HEIGHT;
+
+/// Initialize the pane
+pub fn pane() -> Pack {
+ let mut p = Pack::default().with_pos(10, 10 + FOCUS_PANE_HEIGHT + 10);
+ p.set_spacing(1);
+
+ // If this pane is a child of the Main Window
+ if let Some(ref w) = p.parent() {
+ // Set the size, excluding the pane's margins and the height taken by the Focus Pane
+ p.set_size(
+ w.width() - 20,
+ w.height() - 10 - FOCUS_PANE_HEIGHT - 10 - 10,
+ );
+ }
+
+ // Initialize the widgets
+ let _menubar = menubar::new();
+ let list = list::new();
+
+ p.end();
+
+ // The list's Scroll is handling the resizing
+ p.resizable(list.scroll());
+
+ // This pane is hidden by default. Pressing the Arrow button will bring it up
+ p.hide();
+
+ p
+}
diff --git a/src/ui/rewards/list.rs b/src/ui/rewards/list.rs
new file mode 100644
index 0000000..b53eea9
--- /dev/null
+++ b/src/ui/rewards/list.rs
@@ -0,0 +1,175 @@
+//! Reward's List keeps the rewards and changes them if requested.
+
+use crossbeam_channel::Sender;
+use fltk::{app, enums::Key, group::Scroll, prelude::*};
+
+use crate::channels::Channels;
+use crate::events;
+use crate::ui::constants::REWARDS_MENUBAR_HEIGHT;
+use crate::ui::logic;
+use crate::ui::widgets::list::{Holder, Items, List, Selected};
+
+/// Initialize the list
+pub fn new(channels: &Channels) -> List {
+ let mut l = List::default();
+
+ // If this list is a child of the Rewards pane
+ if let Some(ref p) = l.parent() {
+ // Set the list's height to all of the available parent's height
+ l.set_size(0, p.h() - REWARDS_MENUBAR_HEIGHT);
+ }
+
+ // Add examples (temporary)
+ l.add("(15) A reward.");
+ l.add("(10) Another reward.");
+ l.add("(5) One more reward.");
+
+ // Apply custom logic
+ l.handle(
+ handle_selection_closure(channels),
+ handle_selection_closure(channels),
+ handle_custom_events(channels),
+ );
+
+ l
+}
+
+/// Handle custom selection events in the returned closure
+fn handle_selection_closure(channels: &Channels) -> impl Fn(&mut Scroll, &Selected, &Items) {
+ // Send the price of an item
+ let s_price = channels.rewards_receive_coins.s.clone();
+ move |_, selected, items| handle_selection(selected, items, &s_price)
+}
+
+/// Handle custom selection events
+fn handle_selection(selected: &Selected, items: &Items, s_price: &Sender) {
+ // If there is a selected item, and the event is a click or an Up / Down button
+ if selected.get() > 0
+ && (app::event_is_click() || app::event_key() == Key::Down || app::event_key() == Key::Up)
+ {
+ // Unlock the buttons
+ handle_selection_unlock_buttons();
+ // Check if the selected reward is affordable
+ handle_selection_check_affordability(selected, items, s_price);
+ }
+}
+
+/// Unlock the Delete / Edit buttons
+fn handle_selection_unlock_buttons() {
+ app::handle_main(events::UNLOCK_THE_DELETE_A_REWARD_BUTTON).ok();
+ app::handle_main(events::UNLOCK_THE_EDIT_A_REWARD_BUTTON).ok();
+}
+
+/// Lock the Delete / Edit / Spend buttons
+fn handle_selection_lock_buttons() {
+ app::handle_main(events::LOCK_THE_DELETE_A_REWARD_BUTTON).ok();
+ app::handle_main(events::LOCK_THE_EDIT_A_REWARD_BUTTON).ok();
+ app::handle_main(events::LOCK_THE_SPEND_BUTTON).ok();
+}
+
+/// Check if the selected item is affordable
+fn handle_selection_check_affordability(selected: &Selected, items: &Items, s_price: &Sender) {
+ let index = selected.index();
+ // Get the price of the item
+ if let Some(price) = logic::get_price(&*items.index(index).string()) {
+ // Send it to the Coins widget for an affordability check
+ s_price.try_send(price).ok();
+ app::handle_main(events::CHECK_AFFORDABILITY_RECEIVE_PRICE).ok();
+ }
+}
+
+/// Handle custom events in Rewards
+fn handle_custom_events(
+ channels: &Channels,
+) -> impl Fn(&mut Scroll, &Holder, &Selected, &Items, i32) -> bool {
+ // Send signals to the Rewards Edit window
+ let s_re = channels.rewards_edit.s.clone();
+ // Send the price of an item
+ let s_price = channels.rewards_receive_coins.s.clone();
+ // Send an item
+ let s_item = channels.rewards_receive_item.s.clone();
+ // Receive an item
+ let r_item = channels.rewards_send_item.r.clone();
+
+ // In case of the
+ move |scroll, holder, selected, items, bits| match bits {
+ // 'Add a reward' event
+ events::ADD_A_REWARD_RECEIVE => r_item.try_recv().map_or(false, |string| {
+ // Add a new reward
+ holder.add(string, scroll, items);
+ true
+ }),
+ // 'Delete a reward' event
+ events::DELETE_A_REWARD => {
+ // If there is a selected item
+ if selected.get() > 0 {
+ // Get its index
+ let index = selected.index();
+ // If the selected item is the last one in the list
+ if selected.get() == items.len() {
+ // Decrement the selection
+ selected.decrement();
+ }
+ // If that's the only item in the list
+ if items.len() == 1 {
+ // Lock the Edit / Delete / Spend buttons
+ handle_selection_lock_buttons();
+ // Otherwise,
+ } else {
+ // Handle the custom selection
+ handle_selection(selected, items, &s_price);
+ }
+ // Remove the item from the list
+ holder.remove(index, scroll, items);
+ // Update the selection
+ items.select(selected.get());
+ }
+ true
+ }
+ // 'Edit a reward: receive the item' event, receive the item
+ events::EDIT_THE_REWARD_RECEIVE => r_item.try_recv().map_or(false, |string| {
+ // Update the selected item
+ items.index_mut(selected.index()).set(string);
+ // Check the affordability of the selected item
+ handle_selection_check_affordability(selected, items, &s_price);
+ // Redraw the holder
+ holder.redraw();
+ true
+ }),
+ // 'Edit a reward: send the item' event
+ events::EDIT_THE_REWARD_SEND_ITEM => {
+ // If there is a selected item
+ if selected.get() > 0 {
+ // Get a copy of its Item String
+ let string = items.index(selected.index()).clone();
+ // Send it to the Rewards Edit window
+ s_item.try_send(string).ok();
+ s_re.try_send(events::EDIT_THE_REWARD_OPEN).ok();
+ }
+ true
+ }
+ // 'Spend coins: send price' event
+ events::SPEND_COINS_SEND_PRICE => {
+ // If there is a selected item
+ if selected.get() > 0 {
+ // Get the price of the item (or set it to negative if couldn't parse it)
+ let price = if let Some(price) =
+ logic::get_price(&*items.index(selected.index()).string())
+ {
+ price
+ } else {
+ -1.0
+ };
+ // Using this check instead of `let Some()` because
+ // the list gets mutated later in the chain of events
+ if price >= 0.0 {
+ // Send it to the Coins widget
+ s_price.try_send(price).ok();
+ app::handle_main(events::SPEND_COINS_RECEIVE_PRICE).ok();
+ }
+ }
+ true
+ }
+ _ => false,
+ }
+}
diff --git a/src/ui/rewards/menubar/add_button.rs b/src/ui/rewards/menubar/add_button.rs
new file mode 100644
index 0000000..60f2dbf
--- /dev/null
+++ b/src/ui/rewards/menubar/add_button.rs
@@ -0,0 +1,44 @@
+//! Add Button opens the [Rewards Edit](mod@crate::ui::windows::rewards_edit) window, set to
+//! add a new item.
+
+use fltk::{button::Button, image::SvgImage, prelude::*};
+
+use crate::channels::Channels;
+use crate::events;
+use crate::ui::constants::{
+ DOWN_BUTTON_FRAME_TYPE, FLAT_BUTTON_FRAME_TYPE, REWARDS_MENUBAR_HEIGHT,
+};
+use crate::ui::logic;
+
+/// Initialize the add button
+pub fn add_button(channels: &Channels) -> Button {
+ let mut ai = SvgImage::from_data(include_str!("../../../../assets/menu/plus.svg")).unwrap();
+ ai.scale(24, 24, true, true);
+
+ let mut ab = Button::default().with_size(REWARDS_MENUBAR_HEIGHT, 0);
+ ab.set_image(Some(ai));
+ ab.set_frame(FLAT_BUTTON_FRAME_TYPE);
+ ab.set_down_frame(DOWN_BUTTON_FRAME_TYPE);
+ ab.set_tooltip("Add a Reward");
+
+ logic(&mut ab, channels);
+
+ ab
+}
+
+/// Set a callback and a handler for the add button
+fn logic(ab: &mut T, channels: &Channels) {
+ // Open the Rewards Edit window on push, set to add a new reward
+ ab.set_callback({
+ // Send signals to the Rewards Edit window
+ let s = channels.rewards_edit.s.clone();
+ move |_| {
+ s.try_send(events::ADD_A_REWARD_OPEN).ok();
+ }
+ });
+ // Handle the shortcut and focus / selection events
+ ab.handle(|ab, ev| {
+ logic::handle_shortcut(ab, ev.bits(), events::ADD_BUTTON_SHORTCUT)
+ || logic::handle_button(ab, ev, 0, false)
+ });
+}
diff --git a/src/ui/rewards/menubar/delete_button.rs b/src/ui/rewards/menubar/delete_button.rs
new file mode 100644
index 0000000..576fb12
--- /dev/null
+++ b/src/ui/rewards/menubar/delete_button.rs
@@ -0,0 +1,56 @@
+//! Delete Button deletes the selected item in the [Rewards](super::super)' List.
+
+use fltk::{app, button::Button, image::SvgImage, prelude::*};
+
+use crate::events;
+use crate::ui::constants::{
+ DOWN_BUTTON_FRAME_TYPE, FLAT_BUTTON_FRAME_TYPE, REWARDS_MENUBAR_HEIGHT,
+};
+use crate::ui::logic;
+
+/// Initialize the delete button
+pub fn delete_button() -> Button {
+ let mut di = SvgImage::from_data(include_str!("../../../../assets/menu/minus.svg")).unwrap();
+ di.scale(24, 24, true, true);
+
+ let mut db = Button::default().with_size(REWARDS_MENUBAR_HEIGHT, 0);
+ db.set_image(Some(di));
+ db.set_frame(FLAT_BUTTON_FRAME_TYPE);
+ db.set_down_frame(DOWN_BUTTON_FRAME_TYPE);
+ db.set_tooltip("Delete the Reward");
+
+ logic(&mut db);
+
+ db
+}
+
+/// Set a callback and a handler for the delete button
+fn logic(db: &mut T) {
+ // The callback is disabled by default since there
+ // is no reward selection at the start of the program
+ db.set_callback(|_| {});
+ // Handle the shortcut, lock, and focus / selection events
+ db.handle({
+ // This button is locked by default. Selecting an item will unlock it
+ let mut lock = true;
+ move |db, ev| {
+ let bits = ev.bits();
+ logic::handle_shortcut(db, bits, events::DELETE_BUTTON_SHORTCUT)
+ || logic::handle_lock(
+ db,
+ &mut lock,
+ callback,
+ bits,
+ events::LOCK_THE_DELETE_A_REWARD_BUTTON,
+ events::UNLOCK_THE_DELETE_A_REWARD_BUTTON,
+ )
+ || logic::handle_button(db, ev, 1, lock)
+ }
+ });
+}
+
+/// The callback of the delete button when it's active
+fn callback(_: &mut T) {
+ // Send a signal to the Rewards' list to delete the selected reward
+ app::handle_main(events::DELETE_A_REWARD).ok();
+}
diff --git a/src/ui/rewards/menubar/divider.rs b/src/ui/rewards/menubar/divider.rs
new file mode 100644
index 0000000..82de4c6
--- /dev/null
+++ b/src/ui/rewards/menubar/divider.rs
@@ -0,0 +1,18 @@
+//! Divider divides elements in the menubar.
+
+use fltk::{frame::Frame, image::SvgImage, prelude::*};
+
+/// Initialize a divider
+pub fn divider() -> Frame {
+ let mut di = SvgImage::from_data(include_str!("../../../../assets/menu/divider.svg")).unwrap();
+ // Note that the ratio is the same as in the SVG
+ di.scale(8, 24, true, true);
+
+ let mut d = Frame::default().with_size(10, 0);
+ d.set_image(Some(di));
+
+ // Disabling the visible focus allows for `Left` / `Right` navigation to ignore this frame
+ d.visible_focus(false);
+
+ d
+}
diff --git a/src/ui/rewards/menubar/edit_button.rs b/src/ui/rewards/menubar/edit_button.rs
new file mode 100644
index 0000000..de569a8
--- /dev/null
+++ b/src/ui/rewards/menubar/edit_button.rs
@@ -0,0 +1,57 @@
+//! Edit Button opens the [Rewards Edit](mod@crate::ui::windows::rewards_edit) window, set to
+//! edit the selected item.
+
+use fltk::{app, button::Button, image::SvgImage, prelude::*};
+
+use crate::events;
+use crate::ui::constants::{
+ DOWN_BUTTON_FRAME_TYPE, FLAT_BUTTON_FRAME_TYPE, REWARDS_MENUBAR_HEIGHT,
+};
+use crate::ui::logic;
+
+/// Initialize the edit button
+pub fn edit_button() -> Button {
+ let mut ei = SvgImage::from_data(include_str!("../../../../assets/menu/pencil.svg")).unwrap();
+ ei.scale(24, 24, true, true);
+
+ let mut eb = Button::default().with_size(REWARDS_MENUBAR_HEIGHT, 0);
+ eb.set_image(Some(ei));
+ eb.set_frame(FLAT_BUTTON_FRAME_TYPE);
+ eb.set_down_frame(DOWN_BUTTON_FRAME_TYPE);
+ eb.set_tooltip("Edit the Reward");
+
+ logic(&mut eb);
+
+ eb
+}
+
+/// Set a callback and a handler for the edit button
+fn logic(eb: &mut T) {
+ // The callback is disabled by default since there
+ // is no reward selection at the start of the program
+ eb.set_callback(|_| {});
+ // Handle the shortcut, lock, and focus / selection events
+ eb.handle({
+ // This button is locked by default. Selecting an item will unlock it
+ let mut lock = true;
+ move |eb, ev| {
+ let bits = ev.bits();
+ logic::handle_shortcut(eb, bits, events::EDIT_BUTTON_SHORTCUT)
+ || logic::handle_lock(
+ eb,
+ &mut lock,
+ callback,
+ bits,
+ events::LOCK_THE_EDIT_A_REWARD_BUTTON,
+ events::UNLOCK_THE_EDIT_A_REWARD_BUTTON,
+ )
+ || logic::handle_button(eb, ev, 2, lock)
+ }
+ });
+}
+
+/// The callback of the edit button when it's active
+fn callback(_: &mut T) {
+ // Start a chain of events to edit the selected reward
+ app::handle_main(events::EDIT_THE_REWARD_SEND_ITEM).ok();
+}
diff --git a/src/ui/rewards/menubar/mod.rs b/src/ui/rewards/menubar/mod.rs
new file mode 100644
index 0000000..239b9ea
--- /dev/null
+++ b/src/ui/rewards/menubar/mod.rs
@@ -0,0 +1,16 @@
+//! Rewards' Menubar provides ways for the user to add / delete / edit / spend money on rewards.
+
+mod add_button;
+mod delete_button;
+mod divider;
+mod edit_button;
+mod new;
+mod spend_button;
+
+pub use new::new;
+
+use add_button::add_button;
+use delete_button::delete_button;
+use divider::divider;
+use edit_button::edit_button;
+use spend_button::spend_button;
diff --git a/src/ui/rewards/menubar/new.rs b/src/ui/rewards/menubar/new.rs
new file mode 100644
index 0000000..d468b94
--- /dev/null
+++ b/src/ui/rewards/menubar/new.rs
@@ -0,0 +1,61 @@
+//! Menubar initializer.
+
+use fltk::{
+ app,
+ enums::{Event, Key},
+ group::{Pack, PackType},
+ prelude::*,
+};
+
+use super::{add_button, delete_button, divider, edit_button, spend_button};
+use crate::channels::Channels;
+use crate::ui::constants::REWARDS_MENUBAR_HEIGHT;
+
+/// Initialize the menubar
+pub fn new(channels: &Channels) -> Pack {
+ let mut m = Pack::default().with_size(0, REWARDS_MENUBAR_HEIGHT);
+ m.set_type(PackType::Horizontal);
+
+ // 1. Add a Reward
+ let _ab = add_button(channels);
+
+ // 2. Delete the Reward
+ let _db = delete_button();
+
+ // 3. Edit the Reward
+ let _eb = edit_button();
+
+ // 4. Divider
+ let _d = divider();
+
+ // 5. Spend Coins for the Reward
+ let _sb = spend_button();
+
+ m.end();
+
+ logic(&mut m);
+
+ m
+}
+
+/// Set a handler for the menubar
+fn logic(m: &mut T) {
+ m.handle(|m, ev| match ev {
+ // In case of a pressed down button
+ Event::KeyDown => {
+ // If it's `Tab`
+ if app::event_key() == Key::Tab {
+ // Focus on the Rewards' list
+ if let Some(ref p) = m.parent() {
+ if let Some(ref mut l) = p.child(1) {
+ l.take_focus().ok();
+ }
+ }
+ true
+ } else {
+ false
+ }
+ }
+ _ => false,
+ });
+}
diff --git a/src/ui/rewards/menubar/spend_button.rs b/src/ui/rewards/menubar/spend_button.rs
new file mode 100644
index 0000000..1bb7dec
--- /dev/null
+++ b/src/ui/rewards/menubar/spend_button.rs
@@ -0,0 +1,58 @@
+//! Spend Button deletes the selected item and subtracts its price from the total number of
+//! coins.
+
+use fltk::{app, button::Button, image::SvgImage, prelude::*};
+
+use crate::events;
+use crate::ui::constants::{
+ DOWN_BUTTON_FRAME_TYPE, FLAT_BUTTON_FRAME_TYPE, REWARDS_MENUBAR_HEIGHT,
+};
+use crate::ui::logic;
+
+/// Initialize the spend button
+pub fn spend_button() -> Button {
+ let mut si =
+ SvgImage::from_data(include_str!("../../../../assets/menu/insert-coin.svg")).unwrap();
+ si.scale(24, 24, true, true);
+
+ let mut sb = Button::default().with_size(REWARDS_MENUBAR_HEIGHT, 0);
+ sb.set_image(Some(si));
+ sb.set_frame(FLAT_BUTTON_FRAME_TYPE);
+ sb.set_down_frame(DOWN_BUTTON_FRAME_TYPE);
+ sb.set_tooltip("Spend Coins for the Reward");
+
+ logic(&mut sb);
+
+ sb
+}
+
+/// Set a callback and a handler for the spend button
+fn logic(sb: &mut T) {
+ // The callback is disabled by default since there
+ // is no reward selection at the start of the program
+ sb.set_callback(|_| {});
+ // Handle the shortcut, lock, and focus / selection events
+ sb.handle({
+ // This button is locked by default. Selecting an item will unlock it
+ let mut lock = true;
+ move |sb, ev| {
+ let bits = ev.bits();
+ logic::handle_shortcut(sb, bits, events::SPEND_BUTTON_SHORTCUT)
+ || logic::handle_lock(
+ sb,
+ &mut lock,
+ callback,
+ bits,
+ events::LOCK_THE_SPEND_BUTTON,
+ events::UNLOCK_THE_SPEND_BUTTON,
+ )
+ || logic::handle_button(sb, ev, 4, lock)
+ }
+ });
+}
+
+/// The callback of the spend button when it's active
+fn callback(_: &mut T) {
+ // Start a chain of events to spend coins on the selected reward (if it's affordable)
+ app::handle_main(events::SPEND_COINS_SEND_PRICE).ok();
+}
diff --git a/src/ui/rewards/mod.rs b/src/ui/rewards/mod.rs
new file mode 100644
index 0000000..70e77b3
--- /dev/null
+++ b/src/ui/rewards/mod.rs
@@ -0,0 +1,13 @@
+//! Rewards Pane is a secondary pane that provides access to rewards.
+//!
+//! It gives ways for the user to:
+//! - select an existing reward;
+//! - create a new reward;
+//! - delete the existing reward;
+//! - spend coins on the selected reward
+
+mod list;
+mod menubar;
+mod pane;
+
+pub use pane::pane;
diff --git a/src/ui/rewards/pane.rs b/src/ui/rewards/pane.rs
new file mode 100644
index 0000000..bad62cd
--- /dev/null
+++ b/src/ui/rewards/pane.rs
@@ -0,0 +1,34 @@
+//! The pane itself. It is supposed to be the second pane in the main window.
+
+use fltk::{group::Pack, prelude::*};
+
+use super::{list, menubar};
+use crate::channels::Channels;
+use crate::ui::constants::FOCUS_PANE_HEIGHT;
+
+/// Initialize the pane
+pub fn pane(channels: &Channels) -> Pack {
+ let mut p = Pack::default().with_pos(10, 10 + FOCUS_PANE_HEIGHT + 10);
+ p.set_spacing(1);
+
+ // If this pane is a child of the Main Window
+ if let Some(ref w) = p.parent() {
+ // Set the size, excluding the pane's margins and the height taken by the Focus Pane
+ p.set_size(
+ w.width() - 20,
+ w.height() - 10 - FOCUS_PANE_HEIGHT - 10 - 10,
+ );
+ }
+
+ // Initialize the widgets
+ let _menubar = menubar::new(channels);
+ let list = list::new(channels);
+
+ // This pane is shown by default. Pressing the Coins button will hide it
+ p.end();
+
+ // The list's Scroll is handling the resizing
+ p.resizable(list.scroll());
+
+ p
+}
diff --git a/src/ui/widgets/list/holder.rs b/src/ui/widgets/list/holder.rs
new file mode 100644
index 0000000..b2c867b
--- /dev/null
+++ b/src/ui/widgets/list/holder.rs
@@ -0,0 +1,121 @@
+//! List's Holder is a wrapper around a [`Pack`](fltk::group::Pack) that manages
+//! [`Items`](super::Items).
+
+use fltk::{
+ group::{Pack, Scroll},
+ prelude::*,
+};
+use std::{
+ cell::{Ref, RefCell, RefMut},
+ rc::Rc,
+};
+
+use super::{item::Item, Items, ITEM_HEIGHT};
+
+/// A wrapper around a [`Pack`](fltk::group::Pack)
+pub struct Holder {
+ /// A reference-counting pointer to a mutable [`Pack`](fltk::group::Pack)
+ holder: Rc>,
+}
+
+impl Holder {
+ /// Get the default struct
+ pub fn default() -> Self {
+ Holder {
+ holder: Rc::>::default(),
+ }
+ }
+
+ /// Get an immutable reference to the pack
+ fn holder(&self) -> Ref {
+ self.holder.borrow()
+ }
+
+ /// Get a mutable reference to the pack
+ fn holder_mut(&self) -> RefMut {
+ self.holder.borrow_mut()
+ }
+
+ /// Get the height of the pack
+ pub fn h(&self) -> i32 {
+ self.holder().h()
+ }
+
+ /// Set the position of the pack and return it back
+ pub fn with_pos(self, x: i32, y: i32) -> Self {
+ self.holder_mut().set_pos(x, y);
+ self
+ }
+
+ /// Set the size of the pack
+ pub fn set_size(&self, width: i32, height: i32) {
+ self.holder_mut().set_size(width, height);
+ }
+
+ /// Resize the pack's height to `ITEM_HEIGHT * n`
+ fn resize(&self, n: usize) {
+ let w = self.holder().w();
+ self.holder_mut().set_size(w, n as i32 * ITEM_HEIGHT);
+ }
+
+ /// Redraw the pack
+ pub fn redraw(&self) {
+ self.holder_mut().redraw();
+ }
+
+ /// Add an item to the List
+ pub fn add(&self, string: String, scroll: &mut Scroll, items: &Items) {
+ // Resize the Holder (expand it)
+ self.resize(items.len() + 1);
+
+ // Create an Item as a child of the Holder
+ self.holder().begin();
+ let item = Item::new(string);
+ self.holder().end();
+
+ items.push(item);
+
+ // Reset the scroll position to where it was before, redraw the Scroll
+ scroll.scroll_to(0, scroll.yposition());
+ scroll.redraw();
+ }
+
+ /// Remove an item from the List
+ pub fn remove(&self, index: usize, scroll: &mut Scroll, items: &Items) {
+ // Determine the next scroll position:
+ // If the first item is partially visible
+ let to = if items.index(0).hidden(false) {
+ // If the last item is completely hidden
+ if items.index(items.len() - 1).hidden(true) {
+ // Leave the scroll where it is
+ scroll.yposition()
+ // Otherwise,
+ } else {
+ // Move the scroll so that the last item is visible at the very bottom
+ (items.len() as i32 - 1) * ITEM_HEIGHT - scroll.h() + 2
+ }
+ // Otherwise,
+ } else {
+ // Leave the scroll at the top
+ 0
+ };
+
+ // Remove the item from the Items and its frame from the Holder
+ self.holder_mut().remove(items.index(index).frame());
+ items.remove(index);
+
+ // Resize the Holder (shrink it), change the scroll position
+ // if hitting the bottom of the list, redraw the Scroll
+ self.resize(items.len());
+ scroll.scroll_to(0, to);
+ scroll.redraw();
+ }
+}
+
+impl Clone for Holder {
+ fn clone(&self) -> Self {
+ Holder {
+ holder: Rc::clone(&self.holder),
+ }
+ }
+}
diff --git a/src/ui/widgets/list/item.rs b/src/ui/widgets/list/item.rs
new file mode 100644
index 0000000..4721bab
--- /dev/null
+++ b/src/ui/widgets/list/item.rs
@@ -0,0 +1,179 @@
+//! List's Item is a wrapper around a string in the list.
+//!
+//! It manages its associated widget, label (the displayed string), and a selection state.
+
+use fltk::{
+ draw,
+ enums::{Align, Color, FrameType},
+ frame::Frame,
+ prelude::*,
+};
+use std::{
+ cell::{Ref, RefCell},
+ rc::Rc,
+};
+
+use super::{ITEM_HEIGHT, SCROLLBAR_WIDTH, TEXT_PADDING};
+
+/// An item in the List
+pub struct Item {
+ /// Associated widget
+ frame: Frame,
+ /// Contents of the item
+ string: Rc>,
+ /// Selection state
+ selected: Rc>,
+ /// Visible (within the width of the list) part of the [`string`](Item#structfield.string)
+ label: Rc>,
+}
+
+impl Item {
+ /// Initialize a new item
+ pub fn new(string: String) -> Self {
+ // Create an item with default values
+ let mut item = Item {
+ frame: Frame::default(),
+ string: Rc::new(RefCell::new(string)),
+ selected: Rc::>::default(),
+ label: Rc::>::default(),
+ };
+
+ item.frame.set_frame(FrameType::FlatBox);
+
+ // If the item is a child of the Holder
+ if let Some(ref h) = item.frame.parent() {
+ // Change the width of the frame and update the label
+ item.frame.set_size(h.w(), ITEM_HEIGHT);
+ item.update_label();
+
+ // Set a custom `draw` function
+ item.frame.draw({
+ let selected = Rc::clone(&item.selected);
+ let label = Rc::clone(&item.label);
+ move |f| {
+ // Saving the draw color and reapplying it in the end is a safety measure
+ let color = draw::get_color();
+
+ // If the item is selected
+ if *selected.borrow() {
+ draw::set_draw_color(Color::White);
+ } else {
+ draw::set_draw_color(Color::Black);
+ }
+
+ // Draw the text within the padding box
+ draw::set_font(draw::font(), 16);
+ draw::draw_text2(
+ &*label.borrow(),
+ f.x() + TEXT_PADDING,
+ f.y(),
+ f.w() - 2 * TEXT_PADDING - SCROLLBAR_WIDTH,
+ f.h(),
+ Align::Left,
+ );
+
+ draw::set_draw_color(color);
+ }
+ });
+ }
+
+ item
+ }
+
+ /// Get the frame of the item
+ pub fn frame(&self) -> &Frame {
+ &self.frame
+ }
+
+ /// Get the contents of the item
+ pub fn string(&self) -> Ref {
+ self.string.borrow()
+ }
+
+ /// Clone the contents of the item
+ pub fn clone(&self) -> String {
+ self.string().clone()
+ }
+
+ /// Get the selection state of the item
+ pub fn selected(&self) -> bool {
+ *self.selected.borrow()
+ }
+
+ /// Check if the item is partially or completely hidden
+ pub fn hidden(&self, completely: bool) -> bool {
+ self.frame.parent().map_or(false, |ref p| {
+ // Get the List's Scroll
+ p.parent().map_or(false, |ref s| {
+ // Return `true` if
+ if completely {
+ self.frame.y() > s.y() + s.h() // The top border is hidden below, or
+ || self.frame.y() + self.frame.h() < s.y() // The bottom border is hidden above
+ } else {
+ self.frame.y() < s.y() // The top border is hidden above, or
+ || self.frame.y() + self.frame.h() > s.y() + s.h() // The bottom border is hidden below
+ }
+ })
+ })
+ }
+
+ /// Set the contents of the item to the passed string
+ pub fn set(&mut self, string: String) {
+ *self.string.borrow_mut() = string;
+ self.update_label();
+ }
+
+ /// Update the label (the visible part of the string) of the item
+ fn update_label(&mut self) {
+ // Get the copy of the item's contents
+ let string = self.string().clone();
+
+ // Setting the font size is crucial for the calculation of the visible width
+ draw::set_font(draw::font(), 16);
+
+ // Width of the frame, but
+ let aw = f64::from(self.frame.w())
+ // Minus the width of the text padding (meaning, from the right side)
+ - f64::from(TEXT_PADDING)
+ // Minus the scrollbar width
+ - f64::from(SCROLLBAR_WIDTH);
+ // Visible width of the string
+ let sw = draw::width(&string);
+ // Visible width of the "..." string
+ let dw = draw::width("...");
+
+ // If the available width is positive (this is a safety measure)
+ if aw > 0.0 {
+ // If the string's width is shorter than the available width
+ if sw < aw {
+ // Disable the tooltip
+ self.frame.set_tooltip("");
+ // Set the label to the string
+ *self.label.borrow_mut() = string;
+ // Otherwise,
+ } else {
+ // Shorten the string until it (plus the dots) fits
+ let mut n = self.string().len();
+ while draw::width(&string[..n]) + dw > aw {
+ n -= 1;
+ }
+ // Set the tooltip to the string
+ self.frame.set_tooltip(&string);
+ // Set the label to the shortened string
+ *self.label.borrow_mut() = self.string()[..n].to_string() + "...";
+ }
+ }
+ }
+
+ /// Select the item
+ pub fn select(&mut self) {
+ *self.selected.borrow_mut() = true;
+ self.frame.set_color(Color::DarkBlue);
+ }
+
+ /// Unselect the item
+ pub fn unselect(&mut self) {
+ *self.selected.borrow_mut() = false;
+ self.frame.set_color(Color::BackGround);
+ }
+}
diff --git a/src/ui/widgets/list/items.rs b/src/ui/widgets/list/items.rs
new file mode 100644
index 0000000..35a73e7
--- /dev/null
+++ b/src/ui/widgets/list/items.rs
@@ -0,0 +1,92 @@
+//! List's Items is a wrapper around a vector of the [`Item`](super::Item)s.
+
+use std::{
+ cell::{Ref, RefCell, RefMut},
+ rc::Rc,
+};
+
+use super::item::Item;
+
+/// A wrapper around a vector of the [`Item`](super::Item)s
+pub struct Items {
+ /// A reference-counting pointer to a mutable vector of the [`Item`](super::Item)s
+ items: Rc>>,
+}
+
+impl Items {
+ /// Get the default struct
+ pub fn default() -> Self {
+ Items {
+ items: Rc::>>::default(),
+ }
+ }
+
+ /// Get an immutable reference to the vector
+ fn items(&self) -> Ref> {
+ self.items.borrow()
+ }
+
+ /// Get a mutable reference to the vector
+ fn items_mut(&self) -> RefMut> {
+ self.items.borrow_mut()
+ }
+
+ /// Get the length of the vector
+ pub fn len(&self) -> usize {
+ self.items().len()
+ }
+
+ /// Get an immutable reference to an item at the specified index
+ pub fn index(&self, index: usize) -> Ref {
+ Ref::map(self.items(), |items| &items[index])
+ }
+
+ /// Get a mutable reference to an item at the specified index
+ pub fn index_mut(&self, index: usize) -> RefMut {
+ RefMut::map(self.items_mut(), |items| &mut items[index])
+ }
+
+ /// Push an item to the vector
+ pub fn push(&self, item: Item) {
+ self.items_mut().push(item);
+ }
+
+ /// Select an item at the specified index, unselecting all others.
+ ///
+ /// Counting of the items here starts from 1. Selecting 0, or
+ /// any other value outside the bounds will unselect all items.
+ pub fn select(&self, idx: usize) {
+ // If the index is in the limits of the vector (if counting from 1)
+ if idx > 0 && idx <= self.len() {
+ // Get the actual index
+ let idx = idx - 1;
+ // Select the specified item, unselecting all others
+ for i in 0..self.len() {
+ if i == idx {
+ self.index_mut(i).select();
+ } else if self.index(i).selected() {
+ self.index_mut(i).unselect();
+ }
+ }
+ // Otherwise,
+ } else {
+ // Unselect all items
+ for i in 0..self.len() {
+ self.index_mut(i).unselect();
+ }
+ }
+ }
+
+ /// Remove an item at the specified index from the vector
+ pub fn remove(&self, index: usize) {
+ self.items_mut().remove(index);
+ }
+}
+
+impl Clone for Items {
+ fn clone(&self) -> Self {
+ Items {
+ items: Rc::clone(&self.items),
+ }
+ }
+}
diff --git a/src/ui/widgets/list/mod.rs b/src/ui/widgets/list/mod.rs
new file mode 100644
index 0000000..24fa872
--- /dev/null
+++ b/src/ui/widgets/list/mod.rs
@@ -0,0 +1,22 @@
+//! List widget is a rework of the standard [`Browser`](fltk::browser::Browser) widget that removes
+//! some annoyances (like the focus box) and allows for some customization (items' tooltips,
+//! shortened labels, custom selection, etc). The user interface is similar where possible.
+
+mod holder;
+mod item;
+mod items;
+mod selected;
+mod widget;
+
+pub use holder::Holder;
+pub use item::Item;
+pub use items::Items;
+pub use selected::Selected;
+pub use widget::List;
+
+/// Height of an item in the list
+const ITEM_HEIGHT: i32 = 20;
+/// Width of the scrollbar
+const SCROLLBAR_WIDTH: i32 = 17;
+/// The text padding for the label
+const TEXT_PADDING: i32 = 4;
diff --git a/src/ui/widgets/list/selected.rs b/src/ui/widgets/list/selected.rs
new file mode 100644
index 0000000..8d70400
--- /dev/null
+++ b/src/ui/widgets/list/selected.rs
@@ -0,0 +1,53 @@
+//! List's Selected is a wrapper around an index of the selected item.
+//!
+//! Note that the counting of the items starts from 1.
+
+use std::{cell::RefCell, rc::Rc};
+
+/// A wrapper around an index of the selected item
+pub struct Selected {
+ /// A reference-counting pointer to an index of the selected item
+ selected: Rc>,
+}
+
+impl Selected {
+ /// Get the default struct
+ pub fn default() -> Self {
+ Selected {
+ selected: Rc::>::default(),
+ }
+ }
+
+ /// Get the index
+ pub fn get(&self) -> usize {
+ *self.selected.borrow()
+ }
+
+ /// Get the index into a vector
+ pub fn index(&self) -> usize {
+ self.get() - 1
+ }
+
+ /// Decrement the index
+ pub fn decrement(&self) {
+ self.set(self.get() - 1);
+ }
+
+ /// Increment the index
+ pub fn increment(&self) {
+ self.set(self.get() + 1);
+ }
+
+ /// Set the index to the passed value
+ pub fn set(&self, idx: usize) {
+ *self.selected.borrow_mut() = idx;
+ }
+}
+
+impl Clone for Selected {
+ fn clone(&self) -> Self {
+ Selected {
+ selected: Rc::clone(&self.selected),
+ }
+ }
+}
diff --git a/src/ui/widgets/list/widget.rs b/src/ui/widgets/list/widget.rs
new file mode 100644
index 0000000..80a1967
--- /dev/null
+++ b/src/ui/widgets/list/widget.rs
@@ -0,0 +1,251 @@
+//! The widget itself.
+//!
+//! Visually it consists of a packed into a vertical [`Scroll`](Scroll)
+//! [`Holder`](Holder), which handles the items as a collection of [`Frame`](fltk::frame::Frame)s.
+//! The items itself are stored in the [`Items`] vector.
+
+use fltk::{
+ app,
+ enums::{Event, FrameType, Key},
+ group::{Group, Scroll, ScrollType},
+ prelude::*,
+};
+
+use super::{Holder, Items, Selected, ITEM_HEIGHT, SCROLLBAR_WIDTH};
+
+/// A [`Browser`](fltk::browser::Browser) replica that provides more customization
+pub struct List {
+ /// Vertical scroll
+ scroll: Scroll,
+ /// Items holder
+ holder: Holder,
+ /// Index of the selected item
+ selected: Selected,
+ /// Vector of the items
+ items: Items,
+}
+
+impl List {
+ /// Get the default struct
+ pub fn default() -> List {
+ // The Scroll will be visible as a bounding box
+ let mut scroll = Scroll::default();
+ scroll.set_frame(FrameType::BorderBox);
+ scroll.set_type(ScrollType::Vertical);
+ scroll.set_scrollbar_size(SCROLLBAR_WIDTH);
+
+ // Create the Holder as a child of the Scroll. Mind the 1px borders of the Scroll
+ let holder = Holder::default().with_pos(scroll.x() + 1, scroll.y() + 1);
+
+ scroll.end();
+
+ let mut l = List {
+ scroll,
+ holder,
+ selected: Selected::default(),
+ items: Items::default(),
+ };
+
+ // Set the default handles (implicitly), providing empty custom handles (explicitly)
+ l.handle(|_, _, _| {}, |_, _, _| {}, |_, _, _, _, _| false);
+
+ l
+ }
+
+ /// Get the Scroll
+ pub fn scroll(&self) -> &Scroll {
+ &self.scroll
+ }
+
+ /// Get the parent of the Scroll
+ pub fn parent(&self) -> Option {
+ self.scroll.parent()
+ }
+
+ /// Set the size of the widget
+ pub fn set_size(&mut self, width: i32, height: i32) {
+ // If the specified width is equal to 0
+ if width == 0 {
+ // Imitate the standard behavior for the packs: inherit the width of the parent
+ if let Some(p) = self.parent() {
+ self.scroll.set_size(p.w(), height);
+ // Also, mind the borders of the Scroll
+ self.holder.set_size(p.w() - 2, self.holder.h());
+ }
+ // Otherwise,
+ } else {
+ // Set the size to the specified values
+ self.scroll.set_size(width, height);
+ // Also, mind the borders of the Scroll
+ self.holder.set_size(width - 2, self.holder.h());
+ }
+ }
+
+ /// Add an item to the List
+ pub fn add(&mut self, s: &'static str) {
+ self.holder
+ .add(s.to_string(), &mut self.scroll, &self.items);
+ }
+
+ /// Select an item at the specified index (explicitly; useful in the handles)
+ pub fn select_in(selected: &Selected, items: &Items, idx: usize) {
+ selected.set(idx);
+ items.select(idx);
+ }
+
+ /// Select an item at the specified index
+ pub fn select(&self, idx: usize) {
+ Self::select_in(&self.selected, &self.items, idx);
+ }
+
+ /// Apply the default handles and set the custom ones
+ pub fn handle(
+ &mut self,
+ handle_push: A,
+ handle_key_down: B,
+ handle_custom_events: C,
+ ) where
+ A: Fn(&mut Scroll, &Selected, &Items),
+ B: Fn(&mut Scroll, &Selected, &Items),
+ C: Fn(&mut Scroll, &Holder, &Selected, &Items, i32) -> bool,
+ {
+ self.scroll.handle({
+ let mut handle = self.holder.clone();
+ let mut selected = self.selected.clone();
+ let mut items = self.items.clone();
+ move |s, ev| match ev {
+ // Setting `true` here will allow to focus on the List
+ Event::Focus => true,
+ // Apply default and custom handles for the `Push` event
+ Event::Push => {
+ handle_push_default(s, &mut selected, &mut items);
+ handle_push(s, &selected, &items);
+ true
+ }
+ // Apply the default and custom handles for the `KeyDown` event
+ Event::KeyDown => {
+ // If the default `KeyDown` buttons were handled
+ if handle_key_down_default(s, &mut selected, &mut items) {
+ // Handle the custom `KeyDown` events for these buttons
+ handle_key_down(s, &selected, &items);
+ true
+ } else {
+ false
+ }
+ }
+ // Block the `Enter` button from doing anything on the `KeyUp` event
+ Event::KeyUp => app::event_key() == Key::Enter,
+ // Handle custom events (that is, signals)
+ _ => handle_custom_events(s, &mut handle, &mut selected, &mut items, ev.bits()),
+ }
+ });
+ }
+}
+
+/// Set the default handle for the [`Push`](Event::Push) event
+fn handle_push_default(s: &mut Scroll, selected: &mut Selected, items: &mut Items) {
+ s.take_focus().ok();
+
+ // Get the Holder
+ if let Some(h) = s.child(0) {
+ // If the click happened on an item (that is, if there is a scrollbar, exclude its width)
+ if h.h() < s.h() - ITEM_HEIGHT
+ || (h.h() >= s.h() - ITEM_HEIGHT && app::event_x() < s.x() + s.w() - SCROLLBAR_WIDTH)
+ {
+ // Compute the index of the clicked item
+ let idx = ((app::event_y() - s.y() + s.yposition()) / ITEM_HEIGHT) as usize + 1;
+
+ // If this index is inside the bounds
+ if idx < items.len() {
+ // Select an item at that index
+ selected.set(idx);
+ // Otherwise (that is, if clicking at the empty space below the list),
+ } else {
+ // Select the last item
+ selected.set(items.len());
+ }
+
+ // Apply the selection
+ items.select(selected.get());
+
+ s.redraw();
+ }
+ }
+}
+
+/// Set the default handles for the [`Event::KeyDown`] events
+fn handle_key_down_default(s: &mut Scroll, selected: &mut Selected, items: &mut Items) -> bool {
+ match app::event_key() {
+ // Pressing Up
+ Key::Up => {
+ // Without any selection or on the top item will
+ let to = if selected.get() == 0 || selected.get() == 1 {
+ // Select the last item
+ selected.set(items.len());
+ // Set the scroll position to the bottom of the list
+ // (these two pixels come from the compensation of the Scroll borders)
+ selected.get() as i32 * ITEM_HEIGHT - s.h() + 2
+ // On any other item will
+ } else {
+ // Select the previous item
+ selected.decrement();
+ // If the selected item is close to the bottom of the list (meaning, if the
+ // scroll is at the bottom of the list, this item would be visible)
+ if (selected.get() as i32) > (items.len() as i32) - s.h() / ITEM_HEIGHT {
+ // Set the scroll position to the bottom of the list
+ // (these two pixels come from the compensation of the Scroll borders)
+ items.len() as i32 * ITEM_HEIGHT - s.h() + 2
+ // Otherwise,
+ } else {
+ // Set the scroll position to the top border of the previous item
+ selected.index() as i32 * ITEM_HEIGHT
+ }
+ };
+ // If the selected item is partially or completely hidden, change the scroll position
+ if items.index(selected.index()).hidden(false) {
+ s.scroll_to(0, to);
+ }
+ // Select the new item, unselecting all others
+ items.select(selected.get());
+
+ s.redraw();
+ true
+ }
+ // Pressing Down
+ Key::Down => {
+ // Without any selection or on the last item will
+ let to = if selected.get() == items.len() {
+ // Select the top item
+ selected.set(1);
+ // Set the scroll position to the top of the list
+ 0
+ // On any other item will
+ } else {
+ // Select the next item
+ selected.increment();
+ // If the selected item is close to the top of the list (meaning, if the
+ // scroll is at the top of the list, this item would be visible)
+ if (selected.get() as i32) <= s.h() / ITEM_HEIGHT {
+ // Set the scroll position to the top of the list
+ 0
+ // Otherwise,
+ } else {
+ // Set the scroll position, so that the current item is at the bottom
+ // (these two pixels come from the compensation of the Scroll borders)
+ selected.get() as i32 * ITEM_HEIGHT - s.h() + 2
+ }
+ };
+ // If the selected item is partially or completely hidden, change the scroll position
+ if items.index(selected.index()).hidden(false) {
+ s.scroll_to(0, to);
+ }
+ // Select the new item, unselecting all others
+ items.select(selected.get());
+
+ s.redraw();
+ true
+ }
+ Key::Enter => true,
+ _ => false,
+ }
+}
diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs
new file mode 100644
index 0000000..6e9b923
--- /dev/null
+++ b/src/ui/widgets/mod.rs
@@ -0,0 +1,3 @@
+//! This module provides custom [FLTK](fltk) widgets.
+
+pub mod list;
diff --git a/src/ui/windows/icon.rs b/src/ui/windows/icon.rs
new file mode 100644
index 0000000..590dcf2
--- /dev/null
+++ b/src/ui/windows/icon.rs
@@ -0,0 +1,8 @@
+//! Load a Window Icon.
+
+use fltk::image::PngImage;
+
+/// Load a Window Icon
+pub fn icon() -> PngImage {
+ PngImage::from_data(include_bytes!("../../../assets/shuchu-32.png")).unwrap()
+}
diff --git a/src/ui/windows/main.rs b/src/ui/windows/main.rs
new file mode 100644
index 0000000..c90d04b
--- /dev/null
+++ b/src/ui/windows/main.rs
@@ -0,0 +1,116 @@
+//! Main Window is the window the user sees after launching the program.
+
+use fltk::{
+ app,
+ enums::{Event, Key},
+ prelude::*,
+ window::Window,
+};
+
+use super::icon;
+use crate::channels::Channels;
+use crate::events;
+use crate::ui::{
+ constants::{FOCUS_PANE_HEIGHT, MAIN_WINDOW_HEIGHT, MAIN_WINDOW_WIDTH},
+ focus, rates, rewards,
+};
+
+/// Initialize the Main Window
+pub fn main(channels: &Channels) -> Window {
+ let mut w = Window::new(100, 100, MAIN_WINDOW_WIDTH, MAIN_WINDOW_HEIGHT, "Shuchu");
+
+ // Set the default size range
+ expand(&mut w);
+
+ w.set_icon(Some(icon()));
+
+ // 1. Focus Pane
+ let _fp = focus::pane(channels);
+
+ // 2. Rewards Pane
+ let re_p = rewards::pane(channels);
+
+ // 3. Conversion Rates Pane
+ let _ra_p = rates::pane();
+
+ w.resizable(&re_p);
+ w.end();
+
+ logic(&mut w);
+
+ w.show();
+ w
+}
+
+/// Set a handle for the window
+fn logic(w: &mut T) {
+ w.handle(|w, ev| match ev {
+ // Handle shortcuts. This is done manually instead of using the `set_shortcut`
+ // method because this way associated buttons will not take focus
+ Event::KeyUp => {
+ if app::event_key() == Key::from_char('f') {
+ app::handle_main(events::TIMER_SHORTCUT).ok();
+ true
+ } else if app::event_key() == Key::from_char('r') {
+ app::handle_main(events::RATES_SHORTCUT).ok();
+ true
+ } else if app::event_key() == Key::from_char('c') {
+ app::handle_main(events::REWARDS_SHORTCUT).ok();
+ true
+ } else if app::event_key() == Key::from_char('a') {
+ app::handle_main(events::ADD_BUTTON_SHORTCUT).ok();
+ true
+ } else if app::event_key() == Key::from_char('d') {
+ app::handle_main(events::DELETE_BUTTON_SHORTCUT).ok();
+ true
+ } else if app::event_key() == Key::from_char('e') {
+ app::handle_main(events::EDIT_BUTTON_SHORTCUT).ok();
+ true
+ } else if app::event_key() == Key::from_char('s') {
+ app::handle_main(events::SPEND_BUTTON_SHORTCUT).ok();
+ true
+ } else {
+ false
+ }
+ }
+ // In case of the
+ _ => match ev.bits() {
+ // 'Main window: hide the pane' event
+ events::MAIN_WINDOW_HIDE_THE_PANE => {
+ // Shrink the window
+ w.resize(w.x(), w.y(), w.w(), 10 + FOCUS_PANE_HEIGHT + 10);
+ // Shrink the size range
+ shrink(w);
+ true
+ }
+ events::MAIN_WINDOW_SHOW_THE_PANE => {
+ // Expand the window
+ w.resize(w.x(), w.y(), w.w(), MAIN_WINDOW_HEIGHT);
+ // Expand the size range
+ expand(w);
+ true
+ }
+ _ => false,
+ },
+ });
+}
+
+/// Shrink the size range of the window
+fn shrink(w: &mut T) {
+ w.size_range(
+ MAIN_WINDOW_WIDTH,
+ 10 + FOCUS_PANE_HEIGHT + 10,
+ MAIN_WINDOW_WIDTH,
+ 10 + FOCUS_PANE_HEIGHT + 10,
+ );
+}
+
+/// Expand the size range of the window
+fn expand(w: &mut T) {
+ w.size_range(
+ MAIN_WINDOW_WIDTH,
+ MAIN_WINDOW_HEIGHT,
+ MAIN_WINDOW_WIDTH,
+ MAIN_WINDOW_HEIGHT + 100,
+ );
+}
diff --git a/src/ui/windows/mod.rs b/src/ui/windows/mod.rs
new file mode 100644
index 0000000..fe3b7ea
--- /dev/null
+++ b/src/ui/windows/mod.rs
@@ -0,0 +1,11 @@
+//! This module defines the windows the program creates.
+
+pub mod rewards_edit;
+
+mod icon;
+mod main;
+
+pub use main::main;
+pub use rewards_edit::new as rewards_edit;
+
+use icon::icon;
diff --git a/src/ui/windows/rewards_edit/buttons.rs b/src/ui/windows/rewards_edit/buttons.rs
new file mode 100644
index 0000000..fa90050
--- /dev/null
+++ b/src/ui/windows/rewards_edit/buttons.rs
@@ -0,0 +1,29 @@
+//! The buttons pack contains the [`Ok`](mod@super::ok) and [`Cancel`](mod@super::cancel) buttons.
+
+use fltk::{
+ group::{Pack, PackType},
+ prelude::*,
+};
+
+use super::{cancel, ok};
+use crate::ui::constants::{REWARDS_EDIT_WINDOW_HEIGHT, REWARDS_EDIT_WINDOW_WIDTH};
+
+/// Initialize the buttons pack
+pub fn buttons() -> Pack {
+ // 80px is the width of the buttons, 25px — the height, 10px — the margins of the pack
+ let mut bs = Pack::default()
+ .with_pos(
+ REWARDS_EDIT_WINDOW_WIDTH - 10 - 2 * 80 - 10,
+ REWARDS_EDIT_WINDOW_HEIGHT - 25 - 10,
+ )
+ .with_size(2 * 80 + 10, 25);
+ bs.set_spacing(10);
+ bs.set_type(PackType::Horizontal);
+
+ // Initialize the buttons
+ let _cancel = cancel();
+ let _ok = ok();
+
+ bs.end();
+ bs
+}
diff --git a/src/ui/windows/rewards_edit/cancel.rs b/src/ui/windows/rewards_edit/cancel.rs
new file mode 100644
index 0000000..320f313
--- /dev/null
+++ b/src/ui/windows/rewards_edit/cancel.rs
@@ -0,0 +1,33 @@
+//! Cancel button cancels the adding / editing process and hides the window.
+//!
+//! The inputs are then reset by the widgets' `Hide` event handlers.
+
+use fltk::{button::Button, prelude::*};
+
+use crate::ui::constants::{BOXED_BUTTON_FRAME_TYPE, DOWN_BUTTON_FRAME_TYPE};
+use crate::ui::logic;
+
+/// Initialize the cancel button
+pub fn cancel() -> Button {
+ let mut c = Button::default().with_size(80, 0).with_label("Cancel");
+ c.set_frame(BOXED_BUTTON_FRAME_TYPE);
+ c.set_down_frame(DOWN_BUTTON_FRAME_TYPE);
+
+ logic(&mut c);
+
+ c
+}
+
+/// Set a callback and a handler for the cancel button
+fn logic(b: &mut T) {
+ // Pushing the button will hide the window
+ b.set_callback(|b| {
+ if let Some(ref p) = b.parent() {
+ if let Some(ref mut w) = p.parent() {
+ w.hide();
+ }
+ }
+ });
+ // Handle focus / selection events
+ b.handle(|c, ev| logic::handle_selection(c, ev, BOXED_BUTTON_FRAME_TYPE, false));
+}
diff --git a/src/ui/windows/rewards_edit/coins.rs b/src/ui/windows/rewards_edit/coins.rs
new file mode 100644
index 0000000..f841958
--- /dev/null
+++ b/src/ui/windows/rewards_edit/coins.rs
@@ -0,0 +1,105 @@
+//! Coins valuator sets the number of coins in a reward.
+//!
+//! Dragging will change the value in the available range.
+
+use fltk::{
+ app,
+ enums::{Align, CallbackTrigger, Event, FrameType, Key},
+ prelude::*,
+ valuator::ValueInput,
+};
+
+use crate::channels::Channels;
+use crate::events;
+use crate::ui::constants::REWARDS_EDIT_WINDOW_WIDTH;
+
+/// Initialize the coins valuator
+pub fn coins(channels: &Channels) -> ValueInput {
+ let mut c = ValueInput::new(10, 25, REWARDS_EDIT_WINDOW_WIDTH - 20, 20, "Coins:");
+ c.set_frame(FrameType::BorderBox);
+ c.set_align(Align::TopLeft);
+ c.set_label_size(16);
+ c.set_text_size(16);
+ c.set_step(0.0, 5);
+ c.set_precision(2);
+ c.set_bounds(0.0, 999_999_999.0);
+ c.set_range(0.0, 999_999_999.0);
+ c.set_soft(false);
+
+ // This trick makes the default value display as `0.00` instead of `0`
+ c.set_value(0.1);
+ c.set_value(0.0);
+
+ logic(&mut c, channels);
+
+ c
+}
+
+/// Set a trigger, a callback and a handler for the coins valuator
+fn logic(v: &mut T, channels: &Channels) {
+ // The value is clamped as soon as it changes
+ v.set_trigger(CallbackTrigger::Changed);
+ // The callback is to clamp the valuator
+ v.set_callback(|v| {
+ v.set_value(v.clamp(v.value()));
+ });
+ // Handle standard and custom events
+ v.handle({
+ // Send coins
+ let s_coins = channels.rewards_send_coins.s.clone();
+ // Receive coins
+ let r_coins = channels.rewards_receive_coins.r.clone();
+ move |v, ev| match ev {
+ // Holding `Enter` down is ignored
+ Event::KeyDown => app::event_key() == Key::Enter,
+ // In case of a released button
+ Event::KeyUp => {
+ // If it's `Enter`
+ if app::event_key() == Key::Enter {
+ // Do the `OK` button's callback
+ app::handle_main(events::OK_BUTTON_DO_CALLBACK).ok();
+ true
+ } else {
+ false
+ }
+ }
+ // Hiding the widget will reset its value
+ Event::Hide => {
+ v.set_value(0.0);
+ true
+ }
+ // In case of the
+ _ => match ev.bits() {
+ // 'Add a reward: send coins' event
+ events::ADD_A_REWARD_SEND_COINS => {
+ // Get the coins from the valuator
+ s_coins.try_send(v.value()).ok();
+ // Pass a signal to the Reward widget in the same window
+ app::handle_main(events::ADD_A_REWARD_SEND_REWARD).ok();
+ // Reset the valuator (the window will become hidden after this chain of events)
+ v.set_value(0.0);
+ true
+ }
+ // 'Edit a reward: send coins' event
+ events::EDIT_THE_REWARD_SEND_COINS => {
+ // Get the coins from the valuator
+ s_coins.try_send(v.value()).ok();
+ // Pass a signal to the Reward widget in the same window
+ app::handle_main(events::EDIT_THE_REWARD_SEND_REWARD).ok();
+ // Reset the valuator (the window will become hidden after this chain of events)
+ v.set_value(0.0);
+ true
+ }
+ // 'Edit a reward: receive coins` event, receive the coins
+ events::EDIT_THE_REWARD_RECEIVE_COINS => {
+ r_coins.try_recv().map_or(false, |coins| {
+ // Set them as a new value of the valuator
+ v.set_value(coins);
+ true
+ })
+ }
+ _ => false,
+ },
+ }
+ });
+}
diff --git a/src/ui/windows/rewards_edit/mod.rs b/src/ui/windows/rewards_edit/mod.rs
new file mode 100644
index 0000000..4dc1616
--- /dev/null
+++ b/src/ui/windows/rewards_edit/mod.rs
@@ -0,0 +1,20 @@
+//! Rewards Edit window provides a way to add / edit items in the [Rewards](super::super::rewards)
+//! list.
+//!
+//! This secondary window is called by the 'Add a Reward' and 'Edit the Reward' buttons in the
+//! Rewards' Menubar.
+
+mod buttons;
+mod cancel;
+mod coins;
+mod new;
+mod ok;
+mod reward;
+
+pub use buttons::buttons;
+pub use coins::coins;
+pub use new::new;
+pub use reward::reward;
+
+use cancel::cancel;
+use ok::ok;
diff --git a/src/ui/windows/rewards_edit/new.rs b/src/ui/windows/rewards_edit/new.rs
new file mode 100644
index 0000000..97eaa8a
--- /dev/null
+++ b/src/ui/windows/rewards_edit/new.rs
@@ -0,0 +1,86 @@
+//! Window initializer. Reexported as the main module.
+
+use fltk::app;
+use fltk::{prelude::*, window::Window};
+
+use super::{super::icon, buttons, coins, reward};
+use crate::channels::Channels;
+use crate::events;
+use crate::ui::constants::{REWARDS_EDIT_WINDOW_HEIGHT, REWARDS_EDIT_WINDOW_WIDTH};
+use crate::ui::logic;
+
+/// Initialize the Rewards Edit Window
+pub fn new(channels: &Channels) -> Window {
+ let mut w = Window::new(
+ 100,
+ 100,
+ REWARDS_EDIT_WINDOW_WIDTH,
+ REWARDS_EDIT_WINDOW_HEIGHT,
+ "Add a Reward",
+ )
+ .center_screen();
+
+ w.set_icon(Some(icon()));
+ w.make_modal(true);
+
+ // 1. Coins
+ let _c = coins(channels);
+
+ // 2. Reward
+ let _r = reward(channels);
+
+ // 3. Buttons
+ let _bs = buttons();
+
+ w.end();
+
+ logic(&mut w, channels);
+
+ w
+}
+
+/// Set a handle for the window
+fn logic(w: &mut T, channels: &Channels) {
+ w.handle({
+ let r_item = channels.rewards_receive_item.r.clone();
+ let s_coins = channels.rewards_receive_coins.s.clone();
+ let s_reward = channels.rewards_receive_reward.s.clone();
+ // In case of the
+ move |w, ev| match ev.bits() {
+ // 'Add a reward` event
+ events::ADD_A_REWARD_OPEN => {
+ w.set_label("Add a Reward");
+ w.show();
+ // Set the OK button's callback to 'Add a reward' (this is done after the window
+ // is shown, so that the OK button is able to receive it)
+ app::handle_main(events::OK_BUTTON_SET_TO_ADD).ok();
+ true
+ }
+ // 'Edit the reward' event
+ events::EDIT_THE_REWARD_OPEN => {
+ w.set_label("Edit the Reward");
+ w.show();
+ // Set the OK button's callback to 'Edit a reward' (this is done after the window
+ // is shown, so that the OK button is able to receive it)
+ app::handle_main(events::OK_BUTTON_SET_TO_EDIT).ok();
+ // Get the item
+ if let Ok(item) = r_item.try_recv() {
+ // Get the price and the text
+ if let Some(price) = logic::get_price(&item) {
+ if let Some(text) = logic::get_text(&item) {
+ // Send the price to the Reward Edit's Coins widget
+ s_coins.try_send(price).ok();
+ app::handle_main(events::EDIT_THE_REWARD_RECEIVE_COINS).ok();
+
+ // Send the text to the Reward Edit's Reward widget
+ s_reward.try_send(text).ok();
+ app::handle_main(events::EDIT_THE_REWARD_RECEIVE_REWARD).ok();
+ }
+ }
+ }
+ true
+ }
+ _ => false,
+ }
+ });
+}
diff --git a/src/ui/windows/rewards_edit/ok.rs b/src/ui/windows/rewards_edit/ok.rs
new file mode 100644
index 0000000..329ad49
--- /dev/null
+++ b/src/ui/windows/rewards_edit/ok.rs
@@ -0,0 +1,62 @@
+//! OK button combines the values of the valuators in an item and sends this item to the
+//! [Rewards](super)' list.
+//!
+//! The OK button's callback changes, depending on whether the user wants to add a new reward or
+//! edit the existing one.
+
+use fltk::{app, button::Button, prelude::*};
+
+use crate::events;
+use crate::ui::constants::{BOXED_BUTTON_FRAME_TYPE, DOWN_BUTTON_FRAME_TYPE};
+use crate::ui::logic;
+
+/// Initialize the OK button
+pub fn ok() -> Button {
+ let mut ok = Button::default().with_size(80, 0).with_label("OK");
+ ok.set_frame(BOXED_BUTTON_FRAME_TYPE);
+ ok.set_down_frame(DOWN_BUTTON_FRAME_TYPE);
+
+ logic(&mut ok);
+
+ ok
+}
+
+/// Set a callback and a handler for the OK button
+fn logic(b: &mut T) {
+ // As a safety measure, the default callback is set to add a reward
+ b.set_callback(add_callback);
+ // Handle the custom events
+ b.handle(|b, ev|
+ // In case of the
+ match ev.bits() {
+ // 'Do callback' event
+ events::OK_BUTTON_DO_CALLBACK => {
+ b.do_callback();
+ true
+ }
+ // 'Set to add` event
+ events::OK_BUTTON_SET_TO_ADD => {
+ // Set the callback to add a reward
+ b.set_callback(add_callback);
+ true
+ }
+ // 'Set to edit` event
+ events::OK_BUTTON_SET_TO_EDIT => {
+ // Set the callback to edit the reward
+ b.set_callback(edit_callback);
+ true
+ }
+ // Otherwise, handle the focus / selection events
+ _ => logic::handle_selection(b, ev, BOXED_BUTTON_FRAME_TYPE, false),
+ });
+}
+
+/// 'Add a reward' callback
+fn add_callback(_: &mut T) {
+ app::handle_main(events::ADD_A_REWARD_SEND_COINS).ok();
+}
+
+/// 'Edit the reward' callback
+fn edit_callback(_: &mut T) {
+ app::handle_main(events::EDIT_THE_REWARD_SEND_COINS).ok();
+}
diff --git a/src/ui/windows/rewards_edit/reward.rs b/src/ui/windows/rewards_edit/reward.rs
new file mode 100644
index 0000000..62acaa3
--- /dev/null
+++ b/src/ui/windows/rewards_edit/reward.rs
@@ -0,0 +1,101 @@
+//! Reward input sets the reward text in a reward.
+
+use fltk::{
+ app,
+ enums::{Align, Event, FrameType, Key},
+ input::Input,
+ prelude::*,
+};
+
+use crate::channels::Channels;
+use crate::events;
+use crate::ui::constants::REWARDS_EDIT_WINDOW_WIDTH;
+
+/// Initialize the reward input
+pub fn reward(channels: &Channels) -> Input {
+ let mut r = Input::new(10, 70, REWARDS_EDIT_WINDOW_WIDTH - 20, 20, "Reward:");
+ r.set_frame(FrameType::BorderBox);
+ r.set_align(Align::TopLeft);
+ r.set_label_size(16);
+ r.set_text_size(16);
+
+ logic(&mut r, channels);
+
+ r
+}
+
+/// Set a callback and a handler for the reward input
+fn logic(i: &mut T, channels: &Channels) {
+ // Handle standard and custom events
+ i.handle({
+ // Receive coins
+ let r_coins = channels.rewards_send_coins.r.clone();
+ // Receive a reward
+ let r_reward = channels.rewards_receive_reward.r.clone();
+ // Send an item
+ let s_item = channels.rewards_send_item.s.clone();
+ // Send signals to the main window
+ let s_mw = channels.mw.s.clone();
+ move |i, ev| match ev {
+ // Holding `Enter` down is ignored
+ Event::KeyDown => app::event_key() == Key::Enter,
+ // In case of a released button
+ Event::KeyUp => {
+ // If it's `Enter`
+ if app::event_key() == Key::Enter {
+ // Do the `OK` button's callback
+ app::handle_main(events::OK_BUTTON_DO_CALLBACK).ok();
+ true
+ } else {
+ false
+ }
+ }
+ // Hiding the widget will reset its value
+ Event::Hide => {
+ i.set_value("");
+ true
+ }
+ // In case of the
+ _ => match ev.bits() {
+ // 'Add a reward: send reward' event, receive the coins from the coins valuator
+ events::ADD_A_REWARD_SEND_REWARD => r_coins.try_recv().map_or(false, |coins| {
+ // Create an Item String and send it to the Rewards' list
+ s_item.try_send(format!("({}) {}", coins, i.value())).ok();
+ // Pass a signal to the Reward's list to receive the item
+ s_mw.try_send(events::ADD_A_REWARD_RECEIVE).ok();
+ // Reset the input
+ i.set_value("");
+ // Hide the window
+ if let Some(ref mut w) = i.parent() {
+ w.hide();
+ }
+ true
+ }),
+ // 'Edit a reward: send reward' event, receive the coins from the coins valuator
+ events::EDIT_THE_REWARD_SEND_REWARD => r_coins.try_recv().map_or(false, |coins| {
+ // Create an Item String and send it to the Rewards' list
+ s_item.try_send(format!("({}) {}", coins, i.value())).ok();
+ // Pass a signal to the Reward's list to receive the item
+ s_mw.try_send(events::EDIT_THE_REWARD_RECEIVE).ok();
+ // Reset the input
+ i.set_value("");
+ // Hide the window
+ if let Some(ref mut w) = i.parent() {
+ w.hide();
+ }
+ true
+ }),
+ // 'Edit a reward: receive reward' event
+ events::EDIT_THE_REWARD_RECEIVE_REWARD => {
+ // Receive the reward text
+ r_reward.try_recv().map_or(false, |ref reward| {
+ // Set it as a new value of the input
+ i.set_value(reward);
+ true
+ })
+ }
+ _ => false,
+ },
+ }
+ });
+}