From d6263f153510d22c1e10393d3556956ede28a3af Mon Sep 17 00:00:00 2001 From: MrGVSV <gino.valente.code@gmail.com> Date: Fri, 14 Jan 2022 17:54:30 -0800 Subject: [PATCH] Added tabs example --- Cargo.toml | 4 + examples/tabs/tab.rs | 171 +++++++++++++++++++++++++++++ examples/tabs/tab_bar.rs | 103 +++++++++++++++++ examples/tabs/tab_box.rs | 48 ++++++++ examples/tabs/tab_content.rs | 33 ++++++ examples/tabs/tabs.rs | 170 ++++++++++++++++++++++++++++ examples/tabs/theming.rs | 27 +++++ kayak_core/src/event_dispatcher.rs | 6 - kayak_core/src/focus_tree.rs | 13 +-- kayak_core/src/lib.rs | 2 +- kayak_core/src/widget_manager.rs | 29 +++-- src/widgets/button.rs | 2 +- 12 files changed, 581 insertions(+), 27 deletions(-) create mode 100644 examples/tabs/tab.rs create mode 100644 examples/tabs/tab_bar.rs create mode 100644 examples/tabs/tab_box.rs create mode 100644 examples/tabs/tab_content.rs create mode 100644 examples/tabs/tabs.rs create mode 100644 examples/tabs/theming.rs diff --git a/Cargo.toml b/Cargo.toml index 1aadade..e6cddfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,7 @@ bevy = { version = "0.6.0" } [[example]] name = "todo" path = "examples/todo/todo.rs" + +[[example]] +name = "tabs" +path = "examples/tabs/tabs.rs" diff --git a/examples/tabs/tab.rs b/examples/tabs/tab.rs new file mode 100644 index 0000000..63b34d9 --- /dev/null +++ b/examples/tabs/tab.rs @@ -0,0 +1,171 @@ +use kayak_ui::{ + core::{ + render_command::RenderCommand, + styles::{LayoutType, Style, StyleProp, Units}, + Bound, EventType, Handler, KeyCode, OnEvent, rsx, use_state, widget, + }, + widgets::{Background, Text}, +}; + +use crate::TabTheme; + +#[derive(Clone, PartialEq)] +enum TabHoverState { + None, + Inactive, + Active, +} + +#[widget] +pub fn Tab(context: &mut KayakContext, content: String, selected: bool, on_request_remove: Handler) { + let theme = context.create_consumer::<TabTheme>().unwrap_or_default(); + let (hover_state, set_hover_state, ..) = use_state!(TabHoverState::None); + match hover_state { + TabHoverState::Inactive if selected => set_hover_state(TabHoverState::Active), + TabHoverState::Active if !selected => set_hover_state(TabHoverState::Inactive), + _ => {} + }; + let (focus_state, set_focus_state, ..) = use_state!(false); + let (is_exit_hovered, set_is_exit_hovered, ..) = use_state!(false); + + let event_handler = OnEvent::new(move |_, event| { + match event.event_type { + EventType::Hover => { + if selected { + set_hover_state(TabHoverState::Active); + } else { + set_hover_state(TabHoverState::Inactive); + } + } + EventType::MouseOut => { + set_hover_state(TabHoverState::None); + } + EventType::Focus => { + set_focus_state(true); + } + EventType::Blur => { + set_focus_state(false); + } + _ => {} + } + }); + + let exit_btn_event_handler = OnEvent::new(move |_, event| { + match event.event_type { + EventType::Hover => { + set_is_exit_hovered(true); + } + EventType::MouseOut => { + set_is_exit_hovered(false); + } + EventType::Focus => { + set_is_exit_hovered(true); + } + EventType::Blur => { + set_is_exit_hovered(false); + } + EventType::Click => { + // Stop propagation so we don't select a deleted tab! + event.stop_propagation(); + on_request_remove.call(()); + } + EventType::KeyDown(evt) => { + if evt.key() == KeyCode::Return || evt.key() == KeyCode::Space { + // Stop propagation so we don't select a deleted tab! + event.stop_propagation(); + on_request_remove.call(()); + } + } + _ => {} + } + }); + + let tab_color = match hover_state { + TabHoverState::None if selected => theme.get().active_tab.normal, + TabHoverState::None => theme.get().inactive_tab.normal, + TabHoverState::Inactive => theme.get().inactive_tab.hovered, + TabHoverState::Active => theme.get().active_tab.hovered, + }; + + let pad_x = Units::Pixels(2.0); + let bg_styles = Style { + background_color: StyleProp::Value(tab_color), + layout_type: StyleProp::Value(LayoutType::Row), + padding_left: StyleProp::Value(pad_x), + padding_right: StyleProp::Value(pad_x), + ..Default::default() + }; + + + let border_width = Units::Pixels(2.0); + let border_styles = Style { + background_color: if focus_state { + StyleProp::Value(theme.get().focus) + } else { + StyleProp::Value(tab_color) + }, + padding_left: StyleProp::Value(border_width), + padding_right: StyleProp::Value(border_width), + padding_top: StyleProp::Value(border_width), + padding_bottom: StyleProp::Value(border_width), + layout_type: StyleProp::Value(LayoutType::Row), + ..Default::default() + }; + + let text_styles = Style { + background_color: if focus_state { + StyleProp::Value(theme.get().focus) + } else { + StyleProp::Value(tab_color) + }, + color: StyleProp::Value(theme.get().text.normal), + top: StyleProp::Value(Units::Stretch(0.1)), + bottom: StyleProp::Value(Units::Stretch(1.0)), + width: StyleProp::Value(Units::Stretch(1.0)), + ..Default::default() + }; + + let exit_styles = Style { + background_color: if is_exit_hovered { + let mut darkened = theme.get().inactive_tab.hovered; + darkened.r -= 0.025; + darkened.g -= 0.025; + darkened.b -= 0.025; + StyleProp::Value(darkened) + } else { + StyleProp::Value(tab_color) + }, + width: StyleProp::Value(Units::Pixels(theme.get().tab_height - 4.0)), + height: StyleProp::Value(Units::Pixels(theme.get().tab_height - 4.0)), + top: StyleProp::Value(Units::Stretch(0.175)), + bottom: StyleProp::Value(Units::Stretch(1.0)), + left: StyleProp::Value(Units::Stretch(1.0)), + ..Default::default() + }; + + let exit_text_styles = Style { + left: StyleProp::Value(Units::Stretch(1.0)), + right: StyleProp::Value(Units::Stretch(1.0)), + top: StyleProp::Value(Units::Stretch(0.35)), + bottom: StyleProp::Value(Units::Stretch(1.0)), + ..Default::default() + }; + + self.styles = Some(Style { + render_command: StyleProp::Value(RenderCommand::Layout), + height: StyleProp::Value(Units::Pixels(theme.get().tab_height)), + max_width: StyleProp::Value(Units::Pixels(100.0)), + ..styles.clone().unwrap_or_default() + }); + + rsx! { + <Background focusable={Some(true)} on_event={Some(event_handler)} styles={Some(border_styles)}> + <Background styles={Some(bg_styles)}> + <Text content={content} size={12.0} styles={Some(text_styles)} /> + <Background focusable={Some(true)} on_event={Some(exit_btn_event_handler)} styles={Some(exit_styles)}> + <Text content={"X".to_string()} size={8.0} styles={Some(exit_text_styles)} /> + </Background> + </Background> + </Background> + } +} \ No newline at end of file diff --git a/examples/tabs/tab_bar.rs b/examples/tabs/tab_bar.rs new file mode 100644 index 0000000..2d9cece --- /dev/null +++ b/examples/tabs/tab_bar.rs @@ -0,0 +1,103 @@ +use kayak_ui::{ + core::{ + styles::{LayoutType, Style, StyleProp, Units}, + Bound, constructor, EventType, Handler, KeyCode, OnEvent, + rsx, use_state, VecTracker, widget, + }, + widgets::{Background, Element, Text}, +}; + +use crate::tab::Tab; +use crate::TabTheme; + +#[widget] +pub fn TabBar(context: &mut KayakContext, tabs: Vec<String>, selected: usize, on_select_tab: Handler<usize>, on_add_tab: Handler, on_remove_tab: Handler<usize>) { + let theme = context.create_consumer::<TabTheme>().unwrap_or_default(); + let (is_add_hovered, set_is_add_hovered, ..) = use_state!(false); + + let tabs = tabs.iter().enumerate().map(|(index, tab)| { + let on_select = on_select_tab.clone(); + let tab_event_handler = OnEvent::new(move |_, event| { + match event.event_type { + EventType::Click => { + on_select.call(index); + } + EventType::KeyDown(evt) => { + if evt.key() == KeyCode::Return || evt.key() == KeyCode::Space { + on_select.call(index); + } + } + _ => {} + } + }); + + let on_remove = on_remove_tab.clone(); + let on_request_remove = Handler::new(move |_| { + on_remove.call(index); + }); + + constructor! { + <Tab content={tab.clone()} on_event={Some(tab_event_handler.clone())} selected={selected == index} on_request_remove={on_request_remove} /> + } + }).collect::<Vec<_>>(); + + let add_btn_event_handler = OnEvent::new(move |_, event| match event.event_type { + EventType::Hover => { + set_is_add_hovered(true); + } + EventType::MouseOut => { + set_is_add_hovered(false); + } + EventType::Focus => { + set_is_add_hovered(true); + } + EventType::Blur => { + set_is_add_hovered(false); + } + EventType::Click => { + on_add_tab.call(()); + } + EventType::KeyDown(evt) => { + if evt.key() == KeyCode::Return || evt.key() == KeyCode::Space { + on_add_tab.call(()); + } + } + _ => {} + }); + + let add_btn_styles = Style { + height: StyleProp::Value(Units::Pixels(theme.get().tab_height)), + width: StyleProp::Value(Units::Pixels(theme.get().tab_height)), + border_radius: StyleProp::Value((10.0, 10.0, 10.0, 10.0)), + ..Default::default() + }; + let add_btn_text_styles = Style { + color: if is_add_hovered { + StyleProp::Value(theme.get().text.hovered) + } else { + StyleProp::Value(theme.get().text.normal) + }, + left: StyleProp::Value(Units::Stretch(1.0)), + right: StyleProp::Value(Units::Stretch(1.0)), + height: StyleProp::Value(Units::Percentage(100.0)), + width: StyleProp::Value(Units::Percentage(100.0)), + ..Default::default() + }; + + let background_styles = Style { + layout_type: StyleProp::Value(LayoutType::Row), + background_color: StyleProp::Value(theme.get().bg), + height: StyleProp::Value(Units::Auto), + width: StyleProp::Value(Units::Stretch(1.0)), + ..styles.clone().unwrap_or_default() + }; + + rsx! { + <Background styles={Some(background_styles)}> + <VecTracker data={tabs} /> + <Element focusable={Some(true)} on_event={Some(add_btn_event_handler)} styles={Some(add_btn_styles)}> + <Text content={"+".to_string()} size={16.0} styles={Some(add_btn_text_styles)} /> + </Element> + </Background> + } +} \ No newline at end of file diff --git a/examples/tabs/tab_box.rs b/examples/tabs/tab_box.rs new file mode 100644 index 0000000..0f7353f --- /dev/null +++ b/examples/tabs/tab_box.rs @@ -0,0 +1,48 @@ +use std::fmt::Debug; +use kayak_ui::{ + core::{ + render_command::RenderCommand, + styles::{Style, StyleProp}, + Bound, Fragment, Handler, rsx, use_state, widget, + } +}; + +use crate::tab_bar::TabBar; +use crate::TabTheme; +use crate::tab_content::TabContent; + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct TabData { + pub name: String, + pub content: Fragment, +} + +#[widget] +pub fn TabBox(context: &mut KayakContext, tabs: Vec<TabData>, initial_tab: usize, on_add_tab: Handler, on_remove_tab: Handler<usize>) { + let theme = context.create_consumer::<TabTheme>().unwrap_or_default(); + let (selected, set_selected, ..) = use_state!(initial_tab); + + let tab_names = tabs.iter().map(|tab| { + tab.name.clone() + }).collect::<Vec<String>>(); + let tab_content = tabs.iter().map(|tab| { + tab.content.clone() + }).collect::<Vec<_>>(); + + let on_select_tab = Handler::<usize>::new(move |index| { + set_selected(index); + }); + + self.styles = Some(Style { + render_command: StyleProp::Value(RenderCommand::Quad), + background_color: StyleProp::Value(theme.get().fg), + ..Default::default() + }); + + rsx! { + <> + <TabBar tabs={tab_names} selected={selected} on_select_tab={on_select_tab} on_add_tab={on_add_tab} on_remove_tab={on_remove_tab} /> + <TabContent tabs={tab_content} selected={selected} /> + </> + } +} \ No newline at end of file diff --git a/examples/tabs/tab_content.rs b/examples/tabs/tab_content.rs new file mode 100644 index 0000000..1b5b024 --- /dev/null +++ b/examples/tabs/tab_content.rs @@ -0,0 +1,33 @@ +use std::ops::Index; +use kayak_ui::{ + core::{ + render_command::RenderCommand, + styles::{Style, StyleProp}, + Bound, Fragment, rsx, VecTracker, widget, + } +}; + +use crate::TabTheme; + +#[widget] +pub fn TabContent(context: &mut KayakContext, tabs: Vec<Fragment>, selected: usize) { + let theme = context.create_consumer::<TabTheme>().unwrap_or_default(); + + if selected >= tabs.len() { + return; + } + + self.styles = Some(Style { + render_command: StyleProp::Value(RenderCommand::Quad), + background_color: StyleProp::Value(theme.get().fg), + ..Default::default() + }); + + let tab = tabs.index(selected).clone(); + + rsx! { + <> + <VecTracker data={vec![tab.clone()]} /> + </> + } +} \ No newline at end of file diff --git a/examples/tabs/tabs.rs b/examples/tabs/tabs.rs new file mode 100644 index 0000000..926ab29 --- /dev/null +++ b/examples/tabs/tabs.rs @@ -0,0 +1,170 @@ +use bevy::{ + prelude::{App as BevyApp, AssetServer, Commands, Res, ResMut}, + window::WindowDescriptor, + DefaultPlugins, +}; + +use kayak_ui::{ + bevy::{BevyContext, BevyKayakUIPlugin, FontMapping, UICameraBundle}, + core::{ + styles::{Style, StyleProp, Units}, + Children, Color, constructor, Handler, Index, render, rsx, use_state, widget + }, + widgets::{App, Text, Window}, +}; + +use tab_box::TabBox; +use tab_box::TabData; +use crate::theming::{ColorState, TabTheme, TabThemeProvider}; + +mod tab_bar; +mod tab_box; +mod tab_content; +mod tab; +mod theming; + +#[widget] +fn TabDemo() { + let text_style = Style { + width: StyleProp::Value(Units::Percentage(75.0)), + top: StyleProp::Value(Units::Stretch(0.5)), + left: StyleProp::Value(Units::Stretch(1.0)), + bottom: StyleProp::Value(Units::Stretch(1.0)), + right: StyleProp::Value(Units::Stretch(1.0)), + ..Default::default() + }; + + let (count, set_count, ..) = use_state!(0); + let (tabs, set_tabs, ..) = use_state!(vec![ + TabData { + name: "Tab 1".to_string(), + content: { + let children = Children::None; + let text_style = text_style.clone(); + constructor! { + <> + <Text content={"Welcome to Tab 1!".to_string()} size={48.0} styles={Some(text_style)} /> + </> + } + }, + }, + TabData { + name: "Tab 2".to_string(), + content: { + let children = Children::None; + let text_style = text_style.clone(); + constructor! { + <> + <Text content={"Welcome to Tab 2!".to_string()} size={48.0} styles={Some(text_style)} /> + </> + } + }, + }, + TabData { + name: "Tab 3".to_string(), + content: { + let children = Children::None; + let text_style = text_style.clone(); + constructor! { + <> + <Text content={"Welcome to Tab 3!".to_string()} size={48.0} styles={Some(text_style)} /> + </> + } + }, + }, + ]); + + let tab_clone = tabs.clone(); + let set_added_tabs = set_tabs.clone(); + let on_add_tab = Handler::new(move |_| { + let mut tab_clone = (&tab_clone).clone(); + tab_clone.push(TabData { + name: format!("Tab {}", count), + content: { + let children = Children::None; + constructor! { + <> + <Text content={"Hello".to_string()} size={12.0} /> + </> + } + }, + }, ); + set_count(count + 1); + set_added_tabs(tab_clone); + }); + + let tab_clone = tabs.clone(); + let on_remove_tab = Handler::new(move |index: usize| { + let mut tab_clone = (&tab_clone).clone(); + tab_clone.remove(index); + set_tabs(tab_clone); + }); + + rsx! { + <TabBox tabs={tabs} on_add_tab={on_add_tab} on_remove_tab={on_remove_tab} /> + } +} + +fn startup( + mut commands: Commands, + mut font_mapping: ResMut<FontMapping>, + asset_server: Res<AssetServer>, +) { + commands.spawn_bundle(UICameraBundle::new()); + + font_mapping.add(asset_server.load("roboto.kayak_font")); + + let theme = TabTheme { + primary: Default::default(), + bg: Color::new(0.176, 0.227, 0.255, 1.0), + fg: Color::new(0.286, 0.353, 0.392, 1.0), + focus: Color::new(0.388, 0.474, 0.678, 0.5), + text: ColorState { + normal: Color::new(0.949, 0.956, 0.968, 1.0), + hovered: Color::new(0.650, 0.574, 0.669, 1.0), + active: Color::new(0.949, 0.956, 0.968, 1.0), + disabled: Color::new(0.662, 0.678, 0.694, 1.0), + }, + active_tab: ColorState { + normal: Color::new(0.286, 0.353, 0.392, 1.0), + hovered: Color::new(0.246, 0.323, 0.352, 1.0), + active: Default::default(), + disabled: Color::new(0.474, 0.486, 0.505, 1.0), + }, + inactive_tab: ColorState { + normal: Color::new(0.176, 0.227, 0.255, 1.0), + hovered: Color::new(0.16, 0.21, 0.23, 1.0), + active: Default::default(), + disabled: Color::new(0.474, 0.486, 0.505, 1.0), + }, + tab_height: 22.0, + }; + + let context = BevyContext::new(|context| { + render! { + <App> + <Window position={(50.0, 50.0)} size={(600.0, 300.0)} title={"Tabs Example".to_string()}> + <TabThemeProvider initial_theme={theme}> + <TabDemo /> + </TabThemeProvider> + </Window> + </App> + } + }); + + commands.insert_resource(context); +} + +fn main() { + BevyApp::new() + .insert_resource(WindowDescriptor { + width: 1270.0, + height: 720.0, + title: String::from("UI Example"), + ..Default::default() + }) + .add_plugins(DefaultPlugins) + .add_plugin(BevyKayakUIPlugin) + .add_startup_system(startup) + .run(); +} \ No newline at end of file diff --git a/examples/tabs/theming.rs b/examples/tabs/theming.rs new file mode 100644 index 0000000..cbcc1b3 --- /dev/null +++ b/examples/tabs/theming.rs @@ -0,0 +1,27 @@ +use kayak_ui::{core::{Color, rsx, widget}}; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TabTheme { + pub primary: Color, + pub bg: Color, + pub fg: Color, + pub focus: Color, + pub text: ColorState, + pub active_tab: ColorState, + pub inactive_tab: ColorState, + pub tab_height: f32 +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct ColorState { + pub normal: Color, + pub hovered: Color, + pub active: Color, + pub disabled: Color, +} + +#[widget] +pub fn TabThemeProvider(initial_theme: TabTheme) { + context.create_provider(initial_theme); + rsx! { <>{children}</> } +} \ No newline at end of file diff --git a/kayak_core/src/event_dispatcher.rs b/kayak_core/src/event_dispatcher.rs index 848e5f0..c1c242a 100644 --- a/kayak_core/src/event_dispatcher.rs +++ b/kayak_core/src/event_dispatcher.rs @@ -94,12 +94,6 @@ impl EventDispatcher { // --- Call Event --- // let mut target_widget = context.widget_manager.take(index); target_widget.on_event(context, &mut node_event); - // if target_widget.get_name() == String::from("TextBox") { - // println!("Event: {:#?}", node_event); - // println!("Widget Focus: {} - {:?}", target_widget.focusable(), index); - // println!("Context Focus: {} - {:?}", context.get_focusable(index), index); - // } - // target_widget.set_focusable(false); context.widget_manager.repossess(target_widget); event.default_prevented |= node_event.default_prevented; diff --git a/kayak_core/src/focus_tree.rs b/kayak_core/src/focus_tree.rs index eb5edf2..2535a1f 100644 --- a/kayak_core/src/focus_tree.rs +++ b/kayak_core/src/focus_tree.rs @@ -36,14 +36,7 @@ impl FocusTree { } } - if let Some(root) = self.tree.root_node { - // Replace root node - self.tree.replace(root, index); - if widget_tree.is_descendant(root, index) { - // If old root is child -> add it back in - self.add(root, &widget_tree); - } - } else { + if self.tree.root_node.is_none() { // Set root node self.tree.add(index, None); self.focus(index); @@ -158,6 +151,10 @@ impl FocusTree { self.tree.root_node } + + pub fn tree(&self) -> &Tree { + &self.tree + } } diff --git a/kayak_core/src/lib.rs b/kayak_core/src/lib.rs index 2e5693f..ec41180 100644 --- a/kayak_core/src/lib.rs +++ b/kayak_core/src/lib.rs @@ -63,7 +63,7 @@ impl OnEvent { } #[derive(Clone)] -pub struct Handler<T>(pub Arc<RwLock<dyn FnMut(T) + Send + Sync + 'static>>); +pub struct Handler<T = ()>(pub Arc<RwLock<dyn FnMut(T) + Send + Sync + 'static>>); impl<T> Default for Handler<T> { fn default() -> Self { diff --git a/kayak_core/src/widget_manager.rs b/kayak_core/src/widget_manager.rs index c406f47..a570757 100644 --- a/kayak_core/src/widget_manager.rs +++ b/kayak_core/src/widget_manager.rs @@ -360,7 +360,7 @@ impl WidgetManager { ) } - fn build_nodes_tree(&self) -> Tree { + fn build_nodes_tree(&mut self) -> Tree { let mut tree = Tree::default(); let (root_node_id, _) = self.current_widgets.iter().next().unwrap(); tree.root_node = Some(root_node_id); @@ -368,6 +368,11 @@ impl WidgetManager { tree.root_node.unwrap(), self.get_valid_node_children(tree.root_node.unwrap()), ); + + let old_focus = self.focus_tree.current(); + self.focus_tree.clear(); + self.focus_tree.add(root_node_id, &self.tree); + for (widget_id, widget) in self.current_widgets.iter().skip(1) { let widget_styles = widget.as_ref().unwrap().get_styles(); if let Some(widget_styles) = widget_styles { @@ -381,7 +386,19 @@ impl WidgetManager { } } } + + let focusable = self.get_focusable(widget_id).unwrap_or_default(); + if focusable { + self.focus_tree.add(widget_id, &self.tree); + } } + + if let Some(old_focus) = old_focus { + if self.focus_tree.contains(old_focus) { + self.focus_tree.focus(old_focus); + } + } + tree } @@ -426,16 +443,6 @@ impl WidgetManager { } pub fn set_focusable(&mut self, focusable: Option<bool>, index: Index, is_parent: bool) { - let is_focusable = self.get_focusable(index).unwrap_or_default(); self.focus_tracker.set_focusability(index, focusable, is_parent); - let focusable = self.get_focusable(index).unwrap_or_default(); - - if focusable { - if !is_focusable { - self.focus_tree.add(index, &self.tree); - } - } else if is_focusable { - self.focus_tree.remove(index); - } } } diff --git a/src/widgets/button.rs b/src/widgets/button.rs index 5c05b4d..cfe2050 100644 --- a/src/widgets/button.rs +++ b/src/widgets/button.rs @@ -6,7 +6,7 @@ use crate::core::{ widget, Children, Fragment, }; -#[widget] +#[widget(focusable)] pub fn Button(children: Children, styles: Option<Style>) { let base_styles = styles.clone().unwrap_or_default(); *styles = Some(Style { -- GitLab