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, + }, + } + }); +}