diff --git a/arcade/examples/gui/0_basic_setup.py b/arcade/examples/gui/0_basic_setup.py index 362d3908d3..00ae87372c 100644 --- a/arcade/examples/gui/0_basic_setup.py +++ b/arcade/examples/gui/0_basic_setup.py @@ -14,6 +14,7 @@ UIManager, UITextureButton, UIAnchorLayout, + UIView, ) # Preload textures, because they are mostly used multiple times, so they are not @@ -70,7 +71,7 @@ def on_draw(self): # ... -class BlueView(arcade.gui.UIView): +class BlueView(UIView): """Uses the arcade.gui.UIView which takes care about the UIManager setup.""" def __init__(self): diff --git a/arcade/examples/gui/1_layouts.py b/arcade/examples/gui/1_layouts.py index 3d77f80487..856fa551bd 100644 --- a/arcade/examples/gui/1_layouts.py +++ b/arcade/examples/gui/1_layouts.py @@ -14,7 +14,7 @@ from datetime import datetime import arcade -from arcade.gui import UIAnchorLayout +from arcade.gui import UIAnchorLayout, UIImage, UITextArea arcade.resources.load_system_fonts() @@ -59,6 +59,36 @@ and layouts in general. """ +TEX_SCROLL_DOWN = arcade.load_texture(":resources:gui_basic_assets/scroll/indicator_down.png") +TEX_SCROLL_UP = arcade.load_texture(":resources:gui_basic_assets/scroll/indicator_up.png") + + +class ScrollableTextArea(UITextArea, UIAnchorLayout): + """This widget is a text area that can be scrolled, like a UITextLayout, but shows indicator, + that the text can be scrolled.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + indicator_size = 22 + self._down_indicator = UIImage( + texture=TEX_SCROLL_DOWN, size_hint=None, width=indicator_size, height=indicator_size + ) + self._down_indicator.visible = False + self.add(self._down_indicator, anchor_x="right", anchor_y="bottom", align_x=-3) + + self._up_indicator = UIImage( + texture=TEX_SCROLL_UP, size_hint=None, width=indicator_size, height=indicator_size + ) + self._up_indicator.visible = False + self.add(self._up_indicator, anchor_x="right", anchor_y="top", align_x=-3) + + def on_update(self, dt): + self._up_indicator.visible = self.layout.view_y < 0 + self._down_indicator.visible = ( + abs(self.layout.view_y) < self.layout.content_height - self.layout.height + ) + class LayoutView(arcade.gui.UIView): """This view demonstrates the use of layouts.""" @@ -71,7 +101,7 @@ def __init__(self): self.anchor = self.add_widget(UIAnchorLayout()) # Add describing text in center - text_area = arcade.gui.UITextArea( + text_area = ScrollableTextArea( text=DESCRIPTION, text_color=arcade.uicolor.WHITE_CLOUDS, font_name=("Lato", "proxima-nova", "Helvetica Neue", "Arial", "sans-serif"), @@ -79,8 +109,8 @@ def __init__(self): size_hint=(0.5, 0.8), ) self.anchor.add(text_area, anchor_x="center_x", anchor_y="center_y") - text_area.with_border(color=arcade.uicolor.GRAY_CONCRETE) - text_area.with_background(color=arcade.uicolor.GRAY_CONCRETE.replace(a=125)) + text_area.with_border(color=arcade.uicolor.DARK_BLUE_MIDNIGHT_BLUE) + text_area.with_background(color=arcade.uicolor.DARK_BLUE_MIDNIGHT_BLUE.replace(a=125)) text_area.with_padding(left=5) # add a grid layout with the window and grid size and grid position @@ -89,8 +119,8 @@ def __init__(self): row_count=2, align_horizontal="left", ) - self.grid.with_background(color=arcade.uicolor.GRAY_CONCRETE) - self.grid.with_border(color=arcade.uicolor.GRAY_ASBESTOS) + self.grid.with_background(color=arcade.uicolor.GRAY_ASBESTOS) + self.grid.with_border(color=arcade.uicolor.GRAY_CONCRETE) self.grid.with_padding(all=10) self.anchor.add(self.grid, anchor_x="left", anchor_y="top", align_x=10, align_y=-10) self.grid.add( diff --git a/arcade/examples/gui/2_widgets.py b/arcade/examples/gui/2_widgets.py index 4bfb6a6246..f36bbd99ba 100644 --- a/arcade/examples/gui/2_widgets.py +++ b/arcade/examples/gui/2_widgets.py @@ -11,13 +11,12 @@ import textwrap from copy import deepcopy -import arcade.gui -from arcade import TextureAnimation, TextureKeyframe, load_texture -from arcade import uicolor +import arcade from arcade.gui import ( UIAnchorLayout, UIButtonRow, UIFlatButton, + UIInputText, UILabel, UISpace, UIOnActionEvent, @@ -35,6 +34,7 @@ UIDropdown, UIMessageBox, UIManager, + UIView, ) # Load system fonts @@ -45,37 +45,42 @@ # Preload textures, because they are mostly used multiple times, so they are not # loaded multiple times -TEX_RED_BUTTON_NORMAL = load_texture(":resources:gui_basic_assets/button/red_normal.png") -TEX_RED_BUTTON_HOVER = load_texture(":resources:gui_basic_assets/button/red_hover.png") -TEX_RED_BUTTON_PRESS = load_texture(":resources:gui_basic_assets/button/red_press.png") -TEX_RED_BUTTON_DISABLE = load_texture(":resources:gui_basic_assets/button/red_disabled.png") +TEX_SCROLL_DOWN = arcade.load_texture(":resources:gui_basic_assets/scroll/indicator_down.png") +TEX_SCROLL_UP = arcade.load_texture(":resources:gui_basic_assets/scroll/indicator_up.png") -TEX_TOGGLE_RED = load_texture(":resources:gui_basic_assets/toggle/red.png") -TEX_TOGGLE_GREEN = load_texture(":resources:gui_basic_assets/toggle/green.png") +TEX_RED_BUTTON_NORMAL = arcade.load_texture(":resources:gui_basic_assets/button/red_normal.png") +TEX_RED_BUTTON_HOVER = arcade.load_texture(":resources:gui_basic_assets/button/red_hover.png") +TEX_RED_BUTTON_PRESS = arcade.load_texture(":resources:gui_basic_assets/button/red_press.png") +TEX_RED_BUTTON_DISABLE = arcade.load_texture(":resources:gui_basic_assets/button/red_disabled.png") -TEX_CHECKBOX_CHECKED = load_texture(":resources:gui_basic_assets/checkbox/blue_check.png") -TEX_CHECKBOX_UNCHECKED = load_texture(":resources:gui_basic_assets/checkbox/empty.png") +TEX_TOGGLE_RED = arcade.load_texture(":resources:gui_basic_assets/toggle/red.png") +TEX_TOGGLE_GREEN = arcade.load_texture(":resources:gui_basic_assets/toggle/green.png") -TEX_SLIDER_THUMB_BLUE = load_texture(":resources:gui_basic_assets/slider/thumb_blue.png") -TEX_SLIDER_TRACK_BLUE = load_texture(":resources:gui_basic_assets/slider/track_blue.png") -TEX_SLIDER_THUMB_RED = load_texture(":resources:gui_basic_assets/slider/thumb_red.png") -TEX_SLIDER_TRACK_RED = load_texture(":resources:gui_basic_assets/slider/track_red.png") -TEX_SLIDER_THUMB_GREEN = load_texture(":resources:gui_basic_assets/slider/thumb_green.png") -TEX_SLIDER_TRACK_GREEN = load_texture(":resources:gui_basic_assets/slider/track_green.png") +TEX_CHECKBOX_CHECKED = arcade.load_texture(":resources:gui_basic_assets/checkbox/blue_check.png") +TEX_CHECKBOX_UNCHECKED = arcade.load_texture(":resources:gui_basic_assets/checkbox/empty.png") -TEX_NINEPATCH_BASE = load_texture(":resources:gui_basic_assets/window/grey_panel.png") +TEX_SLIDER_THUMB_BLUE = arcade.load_texture(":resources:gui_basic_assets/slider/thumb_blue.png") +TEX_SLIDER_TRACK_BLUE = arcade.load_texture(":resources:gui_basic_assets/slider/track_blue.png") +TEX_SLIDER_THUMB_RED = arcade.load_texture(":resources:gui_basic_assets/slider/thumb_red.png") +TEX_SLIDER_TRACK_RED = arcade.load_texture(":resources:gui_basic_assets/slider/track_red.png") +TEX_SLIDER_THUMB_GREEN = arcade.load_texture(":resources:gui_basic_assets/slider/thumb_green.png") +TEX_SLIDER_TRACK_GREEN = arcade.load_texture(":resources:gui_basic_assets/slider/track_green.png") -TEX_ARCADE_LOGO = load_texture(":resources:/logo.png") +TEX_NINEPATCH_BASE = arcade.load_texture(":resources:gui_basic_assets/window/grey_panel.png") + +TEX_ARCADE_LOGO = arcade.load_texture(":resources:/logo.png") # Load animation for the sprite widget frame_textures = [] for i in range(8): - tex = load_texture( + tex = arcade.load_texture( f":resources:images/animated_characters/female_adventurer/femaleAdventurer_walk{i}.png" ) frame_textures.append(tex) -TEX_ANIMATED_CHARACTER = TextureAnimation([TextureKeyframe(frame) for frame in frame_textures]) +TEX_ANIMATED_CHARACTER = arcade.TextureAnimation( + [arcade.TextureKeyframe(frame) for frame in frame_textures] +) TEXT_WIDGET_EXPLANATION = textwrap.dedent(""" Arcade GUI provides three types of text widgets: @@ -97,9 +102,6 @@ A few hints regarding the usage of the text widgets: -//Please scroll ... - - {bold True}UILabel{bold False}: If you want to display frequently changing text, @@ -128,10 +130,37 @@ """).strip() -class GalleryView(arcade.gui.UIView): +class ScrollableTextArea(UITextArea, UIAnchorLayout): + """This widget is a text area that can be scrolled, like a UITextLayout, but shows indicator, + that the text can be scrolled.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + indicator_size = 22 + self._down_indicator = UIImage( + texture=TEX_SCROLL_DOWN, size_hint=None, width=indicator_size, height=indicator_size + ) + self._down_indicator.visible = False + self.add(self._down_indicator, anchor_x="right", anchor_y="bottom", align_x=3) + + self._up_indicator = UIImage( + texture=TEX_SCROLL_UP, size_hint=None, width=indicator_size, height=indicator_size + ) + self._up_indicator.visible = False + self.add(self._up_indicator, anchor_x="right", anchor_y="top", align_x=3) + + def on_update(self, dt): + self._up_indicator.visible = self.layout.view_y < 0 + self._down_indicator.visible = ( + abs(self.layout.view_y) < self.layout.content_height - self.layout.height + ) + + +class GalleryView(UIView): def __init__(self): super().__init__() - self.background_color = uicolor.BLUE_BELIZE_HOLE + self.background_color = arcade.uicolor.BLUE_BELIZE_HOLE root = self.add_widget(UIAnchorLayout()) @@ -142,15 +171,15 @@ def __init__(self): "Categories", font_name=DEFAULT_FONT, font_size=32, - text_color=uicolor.DARK_BLUE_MIDNIGHT_BLUE, + text_color=arcade.uicolor.DARK_BLUE_MIDNIGHT_BLUE, size_hint=(1, 0.1), align="center", ) ) - nav_side.add(UISpace(size_hint=(1, 0.01), color=uicolor.DARK_BLUE_MIDNIGHT_BLUE)) + nav_side.add(UISpace(size_hint=(1, 0.01), color=arcade.uicolor.DARK_BLUE_MIDNIGHT_BLUE)) nav_side.with_padding(all=10) - nav_side.with_background(color=uicolor.WHITE_CLOUDS) + nav_side.with_background(color=arcade.uicolor.WHITE_CLOUDS) nav_side.add_button("Start", style=UIFlatButton.STYLE_BLUE, size_hint=(1, 0.1)) nav_side.add_button("Text", style=UIFlatButton.STYLE_BLUE, size_hint=(1, 0.1)) nav_side.add_button("Interactive", style=UIFlatButton.STYLE_BLUE, size_hint=(1, 0.1)) @@ -194,7 +223,7 @@ def _show_start_widgets(self): """).strip(), font_name=DETAILS_FONT, font_size=32, - text_color=uicolor.WHITE_CLOUDS, + text_color=arcade.uicolor.WHITE, size_hint=(0.8, 0.8), ), anchor_y="top", @@ -229,21 +258,21 @@ def _show_text_widgets(self): self._body.clear() - box = arcade.gui.UIBoxLayout(vertical=True, size_hint=(1, 1), align="left") + box = UIBoxLayout(vertical=True, size_hint=(1, 1), align="left") self._body.add(box) box.add(UILabel("Text Widgets", font_name=DEFAULT_FONT, font_size=32)) box.add(UISpace(size_hint=(1, 0.1))) - row_1 = arcade.gui.UIBoxLayout(vertical=False, size_hint=(1, 0.1)) + row_1 = UIBoxLayout(vertical=False, size_hint=(1, 0.1)) box.add(row_1) row_1.add(UILabel("Name: ", font_name=DEFAULT_FONT, font_size=24)) name_input = row_1.add( - arcade.gui.UIInputText( + UIInputText( width=400, height=40, font_name=DEFAULT_FONT, font_size=24, - border_color=uicolor.GRAY_CONCRETE, + border_color=arcade.uicolor.GRAY_CONCRETE, border_width=2, ) ) @@ -258,28 +287,26 @@ def on_text_change(event: UIOnChangeEvent): box.add(UISpace(size_hint=(1, 0.3))) # Fill some of the left space text_area = box.add( - UITextArea( + ScrollableTextArea( text=TEXT_WIDGET_EXPLANATION, size_hint=(1, 0.9), font_name=DETAILS_FONT, font_size=16, - text_color=uicolor.WHITE_CLOUDS, + text_color=arcade.uicolor.WHITE, document_mode="ATTRIBUTED", ) ) text_area.with_padding(left=10, right=10) - text_area.with_border(color=uicolor.GRAY_CONCRETE, width=2) + text_area.with_border(color=arcade.uicolor.GRAY_CONCRETE, width=2) def _show_interactive_widgets(self): self._body.clear() - box = arcade.gui.UIBoxLayout( - vertical=True, size_hint=(1, 1), align="left", space_between=10 - ) + box = UIBoxLayout(vertical=True, size_hint=(1, 1), align="left", space_between=10) self._body.add(box) box.add(UILabel("Interactive Widgets", font_name=DEFAULT_FONT, font_size=32)) box.add(UISpace(size_hint=(1, 0.1))) - flat_row = arcade.gui.UIBoxLayout(vertical=False, size_hint=(1, 0.1), space_between=10) + flat_row = UIBoxLayout(vertical=False, size_hint=(1, 0.1), space_between=10) box.add(flat_row) flat_row.add( @@ -288,15 +315,13 @@ def _show_interactive_widgets(self): ) ) flat_row.add( - UIFlatButton( - text="UIFlatButton red", style=UIFlatButton.STYLE_RED, size_hint=(0.3, 1) - ) + UIFlatButton(text="UIFlatButton red", style=UIFlatButton.STYLE_RED, size_hint=(0.3, 1)) ) flat_row.add( UIFlatButton(text="disabled", style=UIFlatButton.STYLE_BLUE, size_hint=(0.3, 1)) ).disabled = True - tex_row = arcade.gui.UIBoxLayout(vertical=False, size_hint=(1, 0.1), space_between=10) + tex_row = UIBoxLayout(vertical=False, size_hint=(1, 0.1), space_between=10) box.add(tex_row) tex_row.add( UITextureButton( @@ -322,7 +347,7 @@ def _show_interactive_widgets(self): ) ).disabled = True - toggle_row = arcade.gui.UIBoxLayout(vertical=False, size_hint=(1, 0.1), space_between=10) + toggle_row = UIBoxLayout(vertical=False, size_hint=(1, 0.1), space_between=10) box.add(toggle_row) toggle_row.add( UILabel("UITextureToggle", font_name=DETAILS_FONT, font_size=16, size_hint=(0.3, 0)) @@ -344,9 +369,7 @@ def _show_interactive_widgets(self): ) ) - dropdown_row = arcade.gui.UIBoxLayout( - vertical=False, size_hint=(1, 0.1), space_between=10 - ) + dropdown_row = UIBoxLayout(vertical=False, size_hint=(1, 0.1), space_between=10) box.add(dropdown_row) dropdown_row.add( UILabel("UIDropdown", font_name=DETAILS_FONT, font_size=16, size_hint=(0.3, 0)) @@ -358,7 +381,7 @@ def _show_interactive_widgets(self): ) ) - slider_row = arcade.gui.UIBoxLayout(vertical=False, size_hint=(1, 0.1), space_between=10) + slider_row = UIBoxLayout(vertical=False, size_hint=(1, 0.1), space_between=10) box.add(slider_row) slider_row.add( @@ -375,9 +398,7 @@ def _show_interactive_widgets(self): ) ) - tex_slider_row = arcade.gui.UIBoxLayout( - vertical=False, size_hint=(1, 0.1), space_between=10 - ) + tex_slider_row = UIBoxLayout(vertical=False, size_hint=(1, 0.1), space_between=10) box.add(tex_slider_row) tex_slider_row.add( @@ -398,9 +419,9 @@ def _show_interactive_widgets(self): ) green_style = deepcopy(UITextureSlider.DEFAULT_STYLE) - green_style["normal"].filled_track = uicolor.GREEN_GREEN_SEA - green_style["hover"].filled_track = uicolor.GREEN_EMERALD - green_style["press"].filled_track = uicolor.GREEN_GREEN_SEA + green_style["normal"].filled_track = arcade.uicolor.GREEN_GREEN_SEA + green_style["hover"].filled_track = arcade.uicolor.GREEN_EMERALD + green_style["press"].filled_track = arcade.uicolor.GREEN_GREEN_SEA s2 = tex_slider_row.add( UITextureSlider( thumb_texture=TEX_SLIDER_THUMB_GREEN, @@ -411,9 +432,9 @@ def _show_interactive_widgets(self): ) red_style = deepcopy(UITextureSlider.DEFAULT_STYLE) - red_style["normal"].filled_track = uicolor.RED_POMEGRANATE - red_style["hover"].filled_track = uicolor.RED_ALIZARIN - red_style["press"].filled_track = uicolor.RED_POMEGRANATE + red_style["normal"].filled_track = arcade.uicolor.RED_POMEGRANATE + red_style["hover"].filled_track = arcade.uicolor.RED_ALIZARIN + red_style["press"].filled_track = arcade.uicolor.RED_POMEGRANATE s3 = tex_slider_row.add( UITextureSlider( thumb_texture=TEX_SLIDER_THUMB_RED, @@ -452,25 +473,21 @@ def _(event: UIOnChangeEvent): """).strip(), font_name=DETAILS_FONT, font_size=16, - text_color=uicolor.WHITE_CLOUDS, + text_color=arcade.uicolor.WHITE, size_hint=(1, 0.9), ) ) text_area.with_padding(left=10, right=10) - text_area.with_border(color=uicolor.GRAY_CONCRETE, width=2) + text_area.with_border(color=arcade.uicolor.GRAY_CONCRETE, width=2) def _show_construct_widgets(self): self._body.clear() - box = arcade.gui.UIBoxLayout( - vertical=True, size_hint=(1, 1), align="left", space_between=10 - ) + box = UIBoxLayout(vertical=True, size_hint=(1, 1), align="left", space_between=10) self._body.add(box) box.add(UILabel("Constructs", font_name=DEFAULT_FONT, font_size=32)) box.add(UISpace(size_hint=(1, 0.1))) - message_row = arcade.gui.UIBoxLayout( - vertical=False, size_hint=(1, 0.1), space_between=10 - ) + message_row = UIBoxLayout(vertical=False, size_hint=(1, 0.1), space_between=10) box.add(message_row) message_row.add( UILabel( @@ -506,7 +523,7 @@ def on_click(event): layer=UIManager.OVERLAY_LAYER, ) - button_row = arcade.gui.UIBoxLayout(vertical=False, size_hint=(1, 0.1), space_between=10) + button_row = UIBoxLayout(vertical=False, size_hint=(1, 0.1), space_between=10) box.add(button_row) button_row.add( UILabel( @@ -540,32 +557,26 @@ def on_click(event): """).strip(), font_name=DETAILS_FONT, font_size=16, - text_color=uicolor.WHITE_CLOUDS, + text_color=arcade.uicolor.WHITE, size_hint=(1, 0.5), ) ) text_area.with_padding(left=10, right=10) - text_area.with_border(color=uicolor.GRAY_CONCRETE, width=2) + text_area.with_border(color=arcade.uicolor.GRAY_CONCRETE, width=2) def _show_other_widgets(self): self._body.clear() - box = arcade.gui.UIBoxLayout( - vertical=True, size_hint=(1, 1), align="left", space_between=10 - ) + box = UIBoxLayout(vertical=True, size_hint=(1, 1), align="left", space_between=10) self._body.add(box) box.add(UILabel("Other Widgets", font_name=DEFAULT_FONT, font_size=32)) box.add(UISpace(size_hint=(1, 0.1))) image_row = box.add(UIBoxLayout(vertical=False, size_hint=(1, 0.1))) - image_row.add( - UILabel("UIImage", font_name=DETAILS_FONT, font_size=16, size_hint=(0.3, 0)) - ) + image_row.add(UILabel("UIImage", font_name=DETAILS_FONT, font_size=16, size_hint=(0.3, 0))) image_row.add(UIImage(texture=TEX_ARCADE_LOGO, width=64, height=64)) dummy_row = box.add(UIBoxLayout(vertical=False, size_hint=(1, 0.1))) - dummy_row.add( - UILabel("UIDummy", font_name=DETAILS_FONT, font_size=16, size_hint=(0.3, 0)) - ) + dummy_row.add(UILabel("UIDummy", font_name=DETAILS_FONT, font_size=16, size_hint=(0.3, 0))) dummy_row.add(UIDummy(size_hint=(0.2, 1))) dummy_row.add(UIDummy(size_hint=(0.2, 1))) dummy_row.add(UIDummy(size_hint=(0.2, 1))) @@ -594,12 +605,12 @@ def _show_other_widgets(self): """).strip(), font_name=DETAILS_FONT, font_size=16, - text_color=uicolor.WHITE_CLOUDS, + text_color=arcade.uicolor.WHITE, size_hint=(1, 0.9), ) ) text_area.with_padding(left=10, right=10) - text_area.with_border(color=uicolor.GRAY_CONCRETE, width=2) + text_area.with_border(color=arcade.uicolor.GRAY_CONCRETE, width=2) if __name__ == "__main__": diff --git a/arcade/examples/gui/3_buttons.py b/arcade/examples/gui/3_buttons.py index 594a78a817..c95c91ea40 100644 --- a/arcade/examples/gui/3_buttons.py +++ b/arcade/examples/gui/3_buttons.py @@ -11,29 +11,34 @@ from __future__ import annotations import arcade -from arcade import load_texture -from arcade.gui import UIManager, UIImage -from arcade.gui.events import UIOnChangeEvent -from arcade.gui.widgets.buttons import UIFlatButton, UITextureButton -from arcade.gui.widgets.layout import UIGridLayout, UIAnchorLayout -from arcade.gui.widgets.toggle import UITextureToggle +from arcade.gui import ( + UIAnchorLayout, + UIFlatButton, + UIGridLayout, + UIImage, + UIOnChangeEvent, + UITextureButton, + UITextureToggle, + UIView, +) + # Preload textures, because they are mostly used multiple times, so they are not # loaded multiple times -ICON_SMALLER = load_texture(":resources:gui_basic_assets/icons/smaller.png") -ICON_LARGER = load_texture(":resources:gui_basic_assets/icons/larger.png") +ICON_SMALLER = arcade.load_texture(":resources:gui_basic_assets/icons/smaller.png") +ICON_LARGER = arcade.load_texture(":resources:gui_basic_assets/icons/larger.png") -TEX_SWITCH_GREEN = load_texture(":resources:gui_basic_assets/toggle/green.png") -TEX_SWITCH_RED = load_texture(":resources:gui_basic_assets/toggle/red.png") -TEX_RED_BUTTON_NORMAL = load_texture(":resources:gui_basic_assets/button/red_normal.png") -TEX_RED_BUTTON_HOVER = load_texture(":resources:gui_basic_assets/button/red_hover.png") -TEX_RED_BUTTON_PRESS = load_texture(":resources:gui_basic_assets/button/red_press.png") +TEX_SWITCH_GREEN = arcade.load_texture(":resources:gui_basic_assets/toggle/green.png") +TEX_SWITCH_RED = arcade.load_texture(":resources:gui_basic_assets/toggle/red.png") +TEX_RED_BUTTON_NORMAL = arcade.load_texture(":resources:gui_basic_assets/button/red_normal.png") +TEX_RED_BUTTON_HOVER = arcade.load_texture(":resources:gui_basic_assets/button/red_hover.png") +TEX_RED_BUTTON_PRESS = arcade.load_texture(":resources:gui_basic_assets/button/red_press.png") -class MyView(arcade.View): +class MyView(UIView): def __init__(self): super().__init__() - self.ui = UIManager() + self.background_color = arcade.uicolor.BLUE_PETER_RIVER grid = UIGridLayout( column_count=3, @@ -190,19 +195,6 @@ def on_change(event: UIOnChangeEvent): grid.add(texture_button_with_toggle, row=3, column=0, column_span=3) - def on_show_view(self): - self.window.background_color = arcade.uicolor.BLUE_BELIZE_HOLE - # Enable UIManager when view is shown to catch window events - self.ui.enable() - - def on_hide_view(self): - # Disable UIManager when view gets inactive - self.ui.disable() - - def on_draw(self): - self.clear() - self.ui.draw() - if __name__ == "__main__": window = arcade.Window(800, 600, "GUI Example: Buttons", resizable=True) diff --git a/arcade/examples/gui/4_with_camera.py b/arcade/examples/gui/4_with_camera.py index 072465e2d2..2c160ffec8 100644 --- a/arcade/examples/gui/4_with_camera.py +++ b/arcade/examples/gui/4_with_camera.py @@ -17,8 +17,7 @@ from typing import Optional import arcade -from arcade.gui import UIView, UIFlatButton, UIOnClickEvent, UILabel, UIBoxLayout -from arcade.gui.widgets.layout import UIAnchorLayout +from arcade.gui import UIAnchorLayout, UIBoxLayout, UIFlatButton, UILabel, UIOnClickEvent, UIView COIN_PNG = ":resources:images/items/coinGold.png" ADV_PNG = ":resources:/images/animated_characters/female_adventurer/femaleAdventurer_idle.png" diff --git a/arcade/examples/gui/5_uicolor_picker.py b/arcade/examples/gui/5_uicolor_picker.py index d0b6a73093..e06a8db3d6 100644 --- a/arcade/examples/gui/5_uicolor_picker.py +++ b/arcade/examples/gui/5_uicolor_picker.py @@ -13,9 +13,13 @@ import arcade from arcade.gui import ( UIAnchorLayout, + UIBoxLayout, UIEvent, + UIGridLayout, UIInteractiveWidget, + UILabel, UITextWidget, + UIView, ) @@ -27,7 +31,7 @@ class ChooseColorEvent(UIEvent): color: arcade.color.Color -class Toast(arcade.gui.UILabel): +class Toast(UILabel): """Label which disappears after a certain time.""" def __init__(self, text: str, duration: float = 2.0, **kwargs): @@ -95,7 +99,7 @@ def on_choose_color(self, event: ChooseColorEvent): pass -class ColorView(arcade.gui.UIView): +class ColorView(UIView): """Uses the arcade.gui.UIView which takes care about the UIManager setup.""" def __init__(self): @@ -133,7 +137,7 @@ def __init__(self): # setup grid with colors self.grid = self.root.add( - arcade.gui.UIGridLayout( + UIGridLayout( column_count=5, row_count=4, size_hint=(1, 1), @@ -153,9 +157,7 @@ def __init__(self): button.on_choose_color = self.on_color_button_choose_color # setup toasts (temporary messages) - self.toasts = self.root.add( - arcade.gui.UIBoxLayout(space_between=2), anchor_x="right", anchor_y="top" - ) + self.toasts = self.root.add(UIBoxLayout(space_between=2), anchor_x="right", anchor_y="top") self.toasts.with_padding(all=10) def on_color_button_choose_color(self, event: ChooseColorEvent) -> bool: diff --git a/arcade/examples/gui/6_size_hints.py b/arcade/examples/gui/6_size_hints.py index 11cfe494b6..a3fbe98dcb 100644 --- a/arcade/examples/gui/6_size_hints.py +++ b/arcade/examples/gui/6_size_hints.py @@ -10,8 +10,8 @@ Please note the following: * These do nothing outside a layout -* They are only hints, and do not guarantee a specific size will - always be set. +* They are only hints, and do not guarantee that a specific size will + be provided. If arcade and Python are properly installed, you can run this example with: python -m arcade.examples.gui.6_size_hints @@ -19,53 +19,125 @@ from __future__ import annotations +import textwrap + import arcade -from arcade.gui import UIManager, UIBoxLayout -from arcade.gui.widgets import UIDummy -from arcade.gui.widgets.layout import UIAnchorLayout +from arcade.gui import ( + UIAnchorLayout, + UIBoxLayout, + UILabel, + UIOnChangeEvent, + UISpace, + UITextArea, + UIView, +) + +arcade.resources.load_system_fonts() + +SIZE_HINT_TEXT = textwrap.dedent( + """ + UIWidgets provide three properties, + which are used by layouts to determine the size of a widget. + +These properties are: + +* size_hint - percentage of the layout size +* size_hint_max - maximum size in pixels +* size_hint_min - minimum size in pixels +Theses properties can be None, or a tuple of two values. The first value is +the width, and the second value is the height. -class MyView(arcade.View): +If a value is None, the layout will use the widget's natural size for that dimension. + + """.strip() +) + + +class MyView(UIView): def __init__(self): super().__init__() - self.ui = UIManager() - - anchor = self.ui.add(UIAnchorLayout()) - - self.ui_dummy = UIDummy(size_hint_max=(200, None), size_hint=(1, 0.6)) - self.box = UIBoxLayout( - children=[ - UIDummy(size_hint_max=(50, None), size_hint=(1, 0.3)), - UIDummy(size_hint_max=(100, None), size_hint=(1, 0.3)), - self.ui_dummy, - ], - size_hint=(0.5, 0.5), + self.background_color = arcade.uicolor.BLUE_BELIZE_HOLE + + root = self.ui.add(UIAnchorLayout()) + content = root.add(UIBoxLayout(size_hint=(1, 1)), anchor_x="left", anchor_y="top") + + # title and information + header_box = content.add( + UIBoxLayout(space_between=5, align="left", size_hint=(1, 0)), + ) + header_box.with_border(color=arcade.uicolor.DARK_BLUE_MIDNIGHT_BLUE) + header_box.with_padding(all=10) + + title = header_box.add(UILabel("Size Hint Example", font_size=24, bold=True)) + header_box.add(UISpace(color=arcade.uicolor.WHITE_CLOUDS, height=2, width=title.width)) + + # create text area and set the minimal size to the content size + text = header_box.add( + UITextArea( + text=SIZE_HINT_TEXT, + width=800, # give text enough space to not wrap + font_size=14, + size_hint=(1, 1), + ) ) - anchor.add( - child=self.box, - anchor_x="center_x", - anchor_y="center_y", + text.with_padding(top=10) + text.size_hint_min = ( + None, + text.layout.content_height + 50, + ) # set minimal height to content height + padding + + # add interactive demo + content_anchor = content.add(UIAnchorLayout()) + content_anchor.with_border(color=arcade.uicolor.DARK_BLUE_MIDNIGHT_BLUE) + content_anchor.with_padding(left=10, bottom=10) + + center_box = content_anchor.add( + UIBoxLayout(size_hint=(0.8, 0.5), align="left", space_between=20) ) - def on_show_view(self): - self.window.background_color = arcade.color.DARK_BLUE_GRAY - # Enable UIManager when view is shown to catch window events - self.ui.enable() + width_slider_box = center_box.add(UIBoxLayout(vertical=False, size_hint=(1, 0))) + width_slider_box.add(UILabel("Modify size_hint:")) + width_slider = width_slider_box.add( + arcade.gui.UISlider(min_value=0, max_value=10, value=0, size_hint=None, height=30) + ) + width_value = width_slider_box.add(UILabel()) + + content_anchor.add(UISpace(height=50)) + + # create a horizontal UIBoxLayout to show the effect of the sliders + demo_box = center_box.add(UIBoxLayout(vertical=False, size_hint=(0.8, 1))) + demo_box.with_background(color=arcade.uicolor.GRAY_ASBESTOS) + + # create a dummy widget to show the effect of the sliders + dummy1 = demo_box.add(UILabel()) + dummy1.with_background(color=arcade.uicolor.YELLOW_ORANGE) + dummy2 = demo_box.add(UILabel()) + dummy2.with_background(color=arcade.uicolor.GREEN_EMERALD) + + def update_size_hint_value(value: float): + width_value.text = f"({value:.2f})" + dummy1.size_hint = (value / 10, 1) + dummy1.text = f"size_hint = ({value / 10:.2f}, 1)" + + dummy2.size_hint = (1 - value / 10, 1) + dummy2.text = f"size_hint = ({1 - value / 10:.2f}, 1)" + + @width_slider.event("on_change") + def on_change(event: UIOnChangeEvent): + update_size_hint_value(event.new_value) - def on_hide_view(self): - # Disable UIManager when view gets inactive - self.ui.disable() + initial_value = 10 + width_slider.value = initial_value + update_size_hint_value(initial_value) - def on_draw(self): - self.clear() - self.ui.draw() + content_anchor.add(UISpace(height=20)) - def on_key_press(self, symbol: int, modifiers: int): - print(self.ui_dummy.rect) - print(self.box.rect) + self.ui.execute_layout() + self.ui.debug() if __name__ == "__main__": - window = arcade.Window(800, 600, "UIExample", resizable=True) + window = arcade.Window(title="UIExample: Size Hints") window.show_view(MyView()) window.run() diff --git a/arcade/examples/gui/exp_scroll_area.py b/arcade/examples/gui/exp_scroll_area.py index e618fb0a9e..4e264354f3 100644 --- a/arcade/examples/gui/exp_scroll_area.py +++ b/arcade/examples/gui/exp_scroll_area.py @@ -1,14 +1,9 @@ -"""This example is a proof-of-concept for a UIScrollArea. +"""This example is a proof-of-concept for a scrollable area. -You can currently scroll through the UIScrollArea in the following ways: +The example shows vertical and horizontal scroll areas with a list of buttons. -* scrolling the mouse wheel -* dragging with middle mouse button - -It currently needs the following improvements: - -* A better API, including scroll direction control -* UIScrollBars +The current implementation lags a proper API, customizability, mouse support and documentation, +but shows how to use the current experimental feature. If arcade and Python are properly installed, you can run this example with: python -m arcade.examples.gui.exp_scroll_area @@ -16,33 +11,68 @@ from __future__ import annotations + import arcade -from arcade import Window -from arcade.gui import UIManager, UIDummy, UIBoxLayout, UIFlatButton, UIInputText -from arcade.gui.experimental.scroll_area import UIScrollArea +from arcade.gui import UIAnchorLayout, UIBoxLayout, UIFlatButton, UIView +from arcade.gui.experimental import UIScrollArea +from arcade.gui.experimental.scroll_area import UIScrollBar -class MyWindow(Window): +class MyView(UIView): def __init__(self): super().__init__() + self.background_color = arcade.uicolor.BLUE_BELIZE_HOLE + + # create a layout with two columns + root = self.add_widget(UIAnchorLayout()) + content_left = UIAnchorLayout(size_hint=(0.5, 1)) + root.add(content_left, anchor_x="left", anchor_y="center") + content_right = UIAnchorLayout(size_hint=(0.5, 1)) + root.add(content_right, anchor_x="right", anchor_y="center") + + # create the list, which should be scrolled vertically + vertical_list = UIBoxLayout(size_hint=(1, 0), space_between=1) + for i in range(100): + button = UIFlatButton(height=30, size_hint=(1, None), text=f"Button {i}") + vertical_list.add(button) + + # the scroll area and the scrollbar are added to a box layout + # so they are next to each other, this also reduces complexity for the layout + # implementation + v_scroll_area = UIBoxLayout(vertical=False, size_hint=(0.8, 0.8)) + content_left.add(v_scroll_area, anchor_x="center", anchor_y="center") + + scroll_layout = v_scroll_area.add(UIScrollArea(size_hint=(1, 1))) + scroll_layout.with_border(color=arcade.uicolor.WHITE_CLOUDS) + scroll_layout.add(vertical_list) + + v_scroll_area.add(UIScrollBar(scroll_layout)) + + # create the list, which should be scrolled vertically + horizontal_list = UIBoxLayout(size_hint=(0, 1), space_between=1, vertical=False) + for i in range(100): + button = UIFlatButton(width=50, size_hint=(None, 1), text=f"B{i}") + horizontal_list.add(button) + + # same as above, but for horizontal scrolling + h_scroll_area = UIBoxLayout(vertical=True, size_hint=(0.8, 0.8)) + content_right.add(h_scroll_area, anchor_x="center", anchor_y="center") - self.ui = UIManager() - self.ui.enable() - self.background_color = arcade.color.WHITE - self.input = self.ui.add(UIInputText(x=450, y=300).with_border()) + scroll_layout = h_scroll_area.add(UIScrollArea(size_hint=(1, 1))) + scroll_layout.with_border(color=arcade.uicolor.WHITE_CLOUDS) + scroll_layout.add(horizontal_list) - self.scroll_area = UIScrollArea(x=100, y=100).with_border() - self.ui.add(self.scroll_area) + h_scroll_area.add(UIScrollBar(scroll_layout, vertical=False)) - anchor = self.scroll_area.add(UIBoxLayout(width=300, height=300, space_between=20)) - anchor.add(UIDummy(height=50)) - anchor.add(UIFlatButton(text="Hello from scroll area", multiline=True)) - anchor.add(UIInputText().with_border()) + def on_key_press(self, symbol: int, modifiers: int) -> bool | None: + if symbol == arcade.key.ESCAPE: + arcade.close_window() + return True - def on_draw(self): - self.clear() - self.ui.draw() + return False if __name__ == "__main__": - MyWindow().run() + window = arcade.Window(title="GUI Example: UIScrollLayout") + window.show_view(MyView()) + window.run() diff --git a/arcade/examples/gui/own_progressbar.py b/arcade/examples/gui/own_progressbar.py new file mode 100644 index 0000000000..5ed87993f4 --- /dev/null +++ b/arcade/examples/gui/own_progressbar.py @@ -0,0 +1,163 @@ +"""Example of creating a custom progress bar. + +This example shows how to create a custom progress bar. + +A progress bar consists of a black background box and a color bar that fills the box +from left to right. Unfilled space is gray. +A value between 0 and 1 determines the fill level of the bar. + +The first progress bar is created using a `UIWidget` and the second progress bar is +created using a `UIAnchorLayout`. + +For the first approach, you only need to know about the general GUI concepts, specifically +how widgets are rendered. + +For the second approach, you need to know how to use layouts and size_hints to arrange widgets. + +Both approaches use a Property to trigger a render when the value changes. +Properties are a way to bind a value to a widget and trigger a function when the value changes. +Read more about properties in the `arcade.gui` documentation. + +If arcade and Python are properly installed, you can run this example with: +python -m arcade.examples.gui.own_progressbar +""" + +from __future__ import annotations + +import arcade +from arcade.gui import Property, UIAnchorLayout, UIBoxLayout, UISpace, UIView, UIWidget, bind +from arcade.types import Color + + +class ProgressBar1(UIWidget): + """A custom progress bar widget. + + A UIWidget is a basic building block for GUI elements. It is a rectangle with a + background color and can have children. + + To create a custom progress bar, we create a UIWidget with a black background, + set a border and add a `do_render` method to draw the actual progress bar. + + """ + + value = Property(0.0) + """The fill level of the progress bar. A value between 0 and 1.""" + + def __init__( + self, + *, + value: float = 1.0, + width=100, + height=20, + color: Color = arcade.color.GREEN, + ) -> None: + super().__init__( + width=width, + height=height, + size_hint=None, # disable size hint, so it just uses the size given + ) + self.with_background(color=arcade.uicolor.GRAY_CONCRETE) + self.with_border(color=arcade.uicolor.BLACK) + + self.value = value + self.color = color + + # trigger a render when the value changes + bind(self, "value", self.trigger_render) + + def do_render(self, surface: arcade.gui.Surface) -> None: + """Draw the actual progress bar.""" + # this will set the viewport to the size of the widget + # so that 0,0 is the bottom left corner of the widget content + self.prepare_render(surface) + + # Draw the actual bar + arcade.draw_lbwh_rectangle_filled( + 0, + 0, + self.content_width * self.value, + self.content_height, + self.color, + ) + + +class Progressbar2(UIAnchorLayout): + """A custom progress bar widget. + + A UIAnchorLayout is a layout that arranges its children in a specific way. + The actual bar is a UISpace that fills the parent widget from left to right. + """ + + value = Property(0.0) + + def __init__( + self, + value: float = 1.0, + width=100, + height=20, + color: Color = arcade.color.GREEN, + ) -> None: + super().__init__( + width=width, + height=height, + size_hint=None, # disable size hint, so it just uses the size given + ) + self.with_background(color=arcade.uicolor.GRAY_CONCRETE) + self.with_border(color=arcade.uicolor.BLACK) + + self._bar = UISpace( + color=color, + size_hint=(value, 1), + ) + self.add( + self._bar, + anchor_x="left", + anchor_y="top", + ) + self.value = value + + # update the bar when the value changes + bind(self, "value", self._update_bar) + + def _update_bar(self): + self._bar.size_hint = (self.value, 1) + self._bar.visible = self.value > 0 + + +class MyView(UIView): + def __init__(self): + super().__init__() + self.ui = arcade.gui.UIManager() + + root = self.ui.add(UIAnchorLayout()) + bars = root.add(UIBoxLayout(space_between=10)) + + # UIWidget based progress bar + self.progressbar1 = ProgressBar1( + value=0.8, + color=arcade.color.RED, + ) + bars.add(self.progressbar1) + + # UIAnchorLayout based progress bar + self.progressbar2 = Progressbar2( + value=0.8, + color=arcade.color.BLUE, + ) + bars.add(self.progressbar2) + + def on_key_press(self, symbol: int, modifiers: int) -> bool | None: + if symbol == arcade.key.NUM_ADD: + self.progressbar1.value = (self.progressbar1.value + 0.1) % 1 + self.progressbar2.value = (self.progressbar2.value + 0.1) % 1 + elif symbol == arcade.key.NUM_SUBTRACT: + self.progressbar1.value = (self.progressbar1.value - 0.1) % 1 + self.progressbar2.value = (self.progressbar2.value - 0.1) % 1 + + return None + + +if __name__ == "__main__": + window = arcade.Window(title="GUI Example: Progressbar") + window.show_view(MyView()) + arcade.run() diff --git a/arcade/gui/experimental/scroll_area.py b/arcade/gui/experimental/scroll_area.py index 81d50c2d9f..05fa681bf4 100644 --- a/arcade/gui/experimental/scroll_area.py +++ b/arcade/gui/experimental/scroll_area.py @@ -5,10 +5,12 @@ from pyglet.event import EVENT_UNHANDLED import arcade +from arcade import XYWH from arcade.gui import ( Property, Surface, UIEvent, + UILayout, UIMouseDragEvent, UIMouseEvent, UIMouseScrollEvent, @@ -18,7 +20,69 @@ from arcade.types import LBWH -class UIScrollArea(UIWidget): +class UIScrollBar(UIWidget): + """Scroll bar for a UIScrollLayout. + + Indicating the current view position of the scroll area. + + Does not support mouse interaction yet. + """ + + def __init__(self, scroll_area: UIScrollArea, vertical: bool = True): + size_hint = (0.05, 1) if vertical else (1, 0.05) + + super().__init__(size_hint=size_hint) + self.scroll_area = scroll_area + self.with_background(color=arcade.color.LIGHT_GRAY) + self.with_border(color=arcade.uicolor.GRAY_CONCRETE) + self.vertical = vertical + + bind(scroll_area, "scroll_y", self.trigger_full_render) + bind(scroll_area, "content_height", self.trigger_full_render) + + def do_render(self, surface: Surface): + """Render the scroll bar.""" + self.prepare_render(surface) + + # calc position and size of the scroll bar + scroll_area = self.scroll_area + + # calculate the scroll bar position + scroll_value = scroll_area.scroll_y if self.vertical else scroll_area.scroll_x + scroll_range = ( + scroll_area.surface.height - scroll_area.content_height + if self.vertical + else scroll_area.surface.width - scroll_area.content_width + ) + + scroll_progress = -scroll_value / scroll_range + + scroll_bar_size = 20 + content_size = self.content_height if self.vertical else self.content_width + available_track_size = content_size - scroll_bar_size + + if self.vertical: + scroll_bar_y = scroll_bar_size / 2 + available_track_size * (1 - scroll_progress) + scroll_bar_x = self.content_width / 2 + + # draw the scroll bar + arcade.draw_rect_filled( + XYWH(scroll_bar_x, scroll_bar_y, self.content_height, scroll_bar_size), + color=arcade.uicolor.GRAY_ASBESTOS, + ) + + else: + scroll_bar_x = scroll_bar_size / 2 + available_track_size * scroll_progress + scroll_bar_y = self.content_height / 2 + + # draw the scroll bar + arcade.draw_rect_filled( + XYWH(scroll_bar_x, scroll_bar_y, scroll_bar_size, self.content_width), + color=arcade.uicolor.GRAY_ASBESTOS, + ) + + +class UIScrollArea(UILayout): """A widget that can scroll its children. This widget is highly experimental and only provides a proof of concept. @@ -33,13 +97,15 @@ class UIScrollArea(UIWidget): size_hint_min: minimum size hint of the widget size_hint_max: maximum size hint of the widget canvas_size: size of the canvas, which is scrollable + overscroll_x: allow over scrolling in x direction (scroll past the end) + overscroll_y: allow over scrolling in y direction (scroll past the end) **kwargs: passed to UIWidget """ scroll_x = Property[float](default=0.0) scroll_y = Property[float](default=0.0) - scroll_speed = 1.3 + scroll_speed = 1.8 invert_scroll = False def __init__( @@ -54,6 +120,8 @@ def __init__( size_hint_min=None, size_hint_max=None, canvas_size=(300, 300), + overscroll_x=False, + overscroll_y=False, **kwargs, ): super().__init__( @@ -67,6 +135,11 @@ def __init__( size_hint_max=size_hint_max, **kwargs, ) + self.default_anchor_x = "left" + self.default_anchor_y = "bottom" + self.overscroll_x = overscroll_x + self.overscroll_y = overscroll_y + self.surface = Surface( size=canvas_size, ) @@ -74,11 +147,63 @@ def __init__( bind(self, "scroll_x", self.trigger_full_render) bind(self, "scroll_y", self.trigger_full_render) + def add(self, child: "UIWidget", **kwargs): + """Add a child to the widget.""" + if self._children: + raise ValueError("UIScrollArea can only have one child") + + super().add(child, **kwargs) + self.trigger_full_render() + def remove(self, child: "UIWidget"): """Remove a child from the widget.""" super().remove(child) self.trigger_full_render() + def do_layout(self): + """Layout the children of the widget.""" + total_min_x = 0 + total_min_y = 0 + + for child in self.children: + new_rect = child.rect + # apply sizehint + shw, shh = child.size_hint or (None, None) + # default min_size to be at least 1 for w and h, required by surface + shw_min, shh_min = child.size_hint_min or (1, 1) + shw_max, shh_max = child.size_hint_max or (None, None) + + if shw is not None: + new_width = shw * self.content_width + + new_width = max(shw_min or 1, new_width) + if shw_max is not None: + new_width = min(shw_max, new_width) + new_rect = new_rect.resize(width=new_width) + + if shh is not None: + new_height = shh * self.content_height + new_height = max(shh_min or 1, new_height) + + if shh_max is not None: + new_height = min(shh_max, new_height) + new_rect = new_rect.resize(height=new_height) + + new_rect = new_rect.align_top(self.surface.height).align_left(0) + total_min_x = max(total_min_x, new_rect.width) + total_min_y = max(total_min_y, new_rect.height) + + if new_rect != child.rect: + child.rect = new_rect + + # resize surface to fit all children + if self.surface.size != (total_min_x, total_min_y): + self.surface.resize( + size=(round(total_min_x), round(total_min_y)), pixel_ratio=self.surface.pixel_ratio + ) + self.scroll_x = 0 + self.scroll_y = 0 + def _do_render(self, surface: Surface, force=False) -> bool: if not self.visible: return False @@ -105,33 +230,55 @@ def _do_render(self, surface: Surface, force=False) -> bool: def do_render(self, surface: Surface): """Renders the scolled surface into the given surface.""" self.prepare_render(surface) - # draw the whole surface, the scissor box, will limit the visible area on screen - width, height = self.surface.size - self.surface.position = (-self.scroll_x, -self.scroll_y) - self.surface.draw(LBWH(0, 0, width, height)) + + offset_x, offset_y = self._get_scroll_offset() + # position surface and draw visible area + self.surface.position = offset_x, offset_y + self.surface.draw(LBWH(-offset_x, -offset_y, self.content_width, self.content_height)) + + def _get_scroll_offset(self): + """calculates the scroll offset for the surface position, + also used for calculating mouse event offset.""" + normal_pos_y = self.surface.height - self.content_height + + return self.scroll_x, -normal_pos_y - self.scroll_y def on_event(self, event: UIEvent) -> Optional[bool]: """Handle scrolling of the widget.""" if isinstance(event, UIMouseDragEvent) and not self.rect.point_in_rect(event.pos): return EVENT_UNHANDLED - # drag scroll area around with middle mouse button - if isinstance(event, UIMouseDragEvent) and event.buttons & arcade.MOUSE_BUTTON_MIDDLE: - self.scroll_x -= event.dx - self.scroll_y -= event.dy - return True - - if isinstance(event, UIMouseScrollEvent): + if isinstance(event, UIMouseScrollEvent) and self.rect.point_in_rect(event.pos): invert = -1 if self.invert_scroll else 1 - self.scroll_x -= event.scroll_x * self.scroll_speed * invert + self.scroll_x -= -event.scroll_x * self.scroll_speed * invert self.scroll_y -= event.scroll_y * self.scroll_speed * invert + + # clip scrolling to canvas size + if not self.overscroll_x: + # clip scroll_x between 0 and -(self.surface.width - self.width) + self.scroll_x = min(0, self.scroll_x) + self.scroll_x = max(self.scroll_x, -int(self.surface.width - self.content_width)) + + if not self.overscroll_y: + # clip scroll_y between 0 and -(self.surface.height - self.height) + self.scroll_y = min(0, self.scroll_y) + self.scroll_y = max(self.scroll_y, -int(self.surface.height - self.content_height)) + return True child_event = event if isinstance(event, UIMouseEvent): - child_event = type(event)(**event.__dict__) # type: ignore - child_event.x = int(event.x - self.left + self.scroll_x) - child_event.y = int(event.y - self.bottom + self.scroll_y) + if self.rect.point_in_rect(event.pos): + # create a new event with the position relative to the child + off_x, off_y = self._get_scroll_offset() + + child_event = type(event)(**event.__dict__) # type: ignore + child_event.x = int(event.x - self.left - off_x) + child_event.y = int(event.y - self.bottom - off_y) + + else: + # event is outside the scroll area, do not pass it to the children + return EVENT_UNHANDLED return super().on_event(child_event) diff --git a/arcade/gui/property.py b/arcade/gui/property.py index 87c8b63670..03e54419a9 100644 --- a/arcade/gui/property.py +++ b/arcade/gui/property.py @@ -2,10 +2,10 @@ import sys import traceback -from typing import Any, Callable, Generic, Optional, TypeVar, cast +from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar, cast from weakref import WeakKeyDictionary, ref -from typing_extensions import override +from typing_extensions import Self, overload, override P = TypeVar("P") @@ -21,7 +21,7 @@ def __init__(self, value: P): self.value = value # This will keep any added listener even if it is not referenced anymore # and would be garbage collected - self.listeners: set[Callable[[Any, P], Any]] = set() + self.listeners: set[Callable[[Any, P], Any] | Callable[[], Any]] = set() class Property(Generic[P]): @@ -42,6 +42,9 @@ class MyObject: my_obj.name = "Hans" # > Something changed + Properties provide a less verbose way to implement the observer pattern in comparison to + using the `property` decorator. + Args: default: Default value which is returned, if no value set before default_factory: A callable which returns the default value. @@ -100,7 +103,7 @@ def dispatch(self, instance, value): try: # FIXME if listener() raises an error, the invalid call will be # also shown as an exception - listener(instance, value) + listener(instance, value) # type: ignore except TypeError: # If the listener does not accept arguments, we call it without it listener() # type: ignore @@ -139,12 +142,18 @@ def unbind(self, instance, callback): def __set_name__(self, owner, name): self.name = name - def __get__(self, instance, owner) -> P: + @overload + def __get__(self, instance: None, instance_type) -> Self: ... + + @overload + def __get__(self, instance: Any, instance_type) -> P: ... + + def __get__(self, instance: Any | None, instance_type) -> Self | P: if instance is None: - return self # type: ignore + return self return self.get(instance) - def __set__(self, instance, value): + def __set__(self, instance, value: P): self.set(instance, value) @@ -265,7 +274,11 @@ def update(self, *args): self.dispatch() -class DictProperty(Property): +K = TypeVar("K") +V = TypeVar("V") + + +class DictProperty(Property[Dict[K, V]], Generic[K, V]): """Property that represents a dict. Only dict are allowed. Any other classes are forbidden. @@ -373,7 +386,7 @@ def reverse(self): self.dispatch() -class ListProperty(Property, Generic[P]): +class ListProperty(Property[List[P]], Generic[P]): """Property that represents a list. Only list are allowed. Any other classes are forbidden. diff --git a/arcade/gui/style.py b/arcade/gui/style.py index 67ed8a54e5..7f9304914b 100644 --- a/arcade/gui/style.py +++ b/arcade/gui/style.py @@ -2,7 +2,7 @@ from abc import abstractmethod from dataclasses import dataclass -from typing import Any, Generic, Mapping, TypeVar, overload +from typing import Any, Generic, TypeVar, overload from arcade.gui.property import DictProperty from arcade.gui.widgets import UIWidget @@ -56,9 +56,9 @@ class UIStyledWidget(UIWidget, Generic[StyleRef]): """ # TODO detect StyleBase changes, so that style changes can trigger rendering. - style: Mapping = DictProperty() # type: ignore + style = DictProperty[str, StyleRef]() - def __init__(self, *, style: Mapping[str, StyleRef], **kwargs): + def __init__(self, *, style: dict[str, StyleRef], **kwargs): self.style = style super().__init__(**kwargs) @@ -75,6 +75,6 @@ def get_current_state(self) -> str: """ pass - def get_current_style(self) -> StyleRef: + def get_current_style(self) -> StyleRef | None: """Return style based on any state of the widget""" return self.style.get(self.get_current_state(), None) diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index 676018cea0..b3731940dc 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -101,7 +101,7 @@ def __init__(self, window: Optional[arcade.Window] = None): self._render_to_surface_camera = arcade.Camera2D() # this camera is used for rendering the UI and should not be changed by the user - self.register_event_type("on_event") # type: ignore # https://github.com/pyglet/pyglet/pull/1173 # noqa + self.register_event_type("on_event") def add(self, widget: W, *, index=None, layer=0) -> W: """Add a widget to the :class:`UIManager`. @@ -447,16 +447,31 @@ def rect(self) -> Rect: return LBWH(0, 0, *self.window.get_size()) def debug(self): - """Walks through all widgets of a UIManager and prints out the rect""" + """Walks through all widgets of a UIManager and prints out layout information.""" for index, layer in self.children.items(): print(f"Layer {index}") + # print table headers, widget, rect, size_hint, size_hint_min, size_hint_max + print( + f"{'Widget':<60}|" + f"{'Rect (LBWH)':<30}|" + f"{'Size Hint':<12}|" + f"{'Size Hint Min':<12}|" + f"{'Size Hint Max':<12}" + ) for child in reversed(layer): self._debug(child, prefix=" ") return @staticmethod def _debug(element, prefix=""): - print(f"{prefix}{element.__class__}:{element.rect}") + print( + f"{prefix}{element}".ljust(60), + f"{tuple(round(v, 2) for v in element.rect.lbwh)}".ljust(30), + f"{element.size_hint}".ljust(12), + f"{element.size_hint_min}".ljust(12), + f"{element.size_hint_max}".ljust(12), + sep="|", + ) if isinstance(element, UIWidget): for child in element.children: UIManager._debug(child, prefix=prefix + " ") diff --git a/arcade/gui/view.py b/arcade/gui/view.py index a4a7d52af1..faf8a1384b 100644 --- a/arcade/gui/view.py +++ b/arcade/gui/view.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TypeVar from arcade import View diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index c0f6f3ed55..7b1279e61d 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -57,22 +57,22 @@ class UIWidget(EventDispatcher, ABC): size_hint_max: max width and height in pixel """ - rect: Rect = Property(LBWH(0, 0, 1, 1)) # type: ignore - visible: bool = Property(True) # type: ignore - - size_hint: Optional[Tuple[float | None, float | None]] = Property(None) # type: ignore - size_hint_min: Optional[Tuple[float, float]] = Property(None) # type: ignore - size_hint_max: Optional[Tuple[float, float]] = Property(None) # type: ignore - - _children: List[_ChildEntry] = ListProperty() # type: ignore - _border_width: int = Property(0) # type: ignore - _border_color: Optional[Color] = Property(arcade.color.BLACK) # type: ignore - _bg_color: Optional[Color] = Property(None) # type: ignore - _bg_tex: Union[None, Texture, NinePatchTexture] = Property(None) # type: ignore - _padding_top: int = Property(0) # type: ignore - _padding_right: int = Property(0) # type: ignore - _padding_bottom: int = Property(0) # type: ignore - _padding_left: int = Property(0) # type: ignore + rect = Property(LBWH(0, 0, 1, 1)) + visible = Property(True) + + size_hint = Property[Optional[Tuple[Optional[float], Optional[float]]]](None) + size_hint_min = Property[Optional[Tuple[Optional[float], Optional[float]]]](None) + size_hint_max = Property[Optional[Tuple[Optional[float], Optional[float]]]](None) + + _children = ListProperty[_ChildEntry]() + _border_width = Property(0) + _border_color = Property[Optional[Color]](arcade.color.BLACK) + _bg_color = Property[Optional[Color]]() + _bg_tex = Property[Union[Texture, NinePatchTexture, None]]() + _padding_top = Property(0) + _padding_right = Property(0) + _padding_bottom = Property(0) + _padding_left = Property(0) def __init__( self, @@ -84,8 +84,8 @@ def __init__( children: Iterable["UIWidget"] = tuple(), # Properties which might be used by layouts size_hint: Optional[Tuple[float | None, float | None]] = None, # in percentage - size_hint_min: Optional[Tuple[float, float]] = None, # in pixel - size_hint_max: Optional[Tuple[float, float]] = None, # in pixel + size_hint_min: Optional[Tuple[float | None, float | None]] = None, # in pixel + size_hint_max: Optional[Tuple[float | None, float | None]] = None, # in pixel **kwargs, ): self._requires_render = True @@ -144,14 +144,19 @@ def add(self, child: W, **kwargs) -> W: return child - def remove(self, child: "UIWidget"): + def remove(self, child: "UIWidget") -> dict | None: """Removes a child from the UIManager which was directly added to it. This will not remove widgets which are added to a child of UIManager. + + Return: + kwargs which were used when child was added """ child.parent = None for c in self._children: if c.child == child: self._children.remove(c) + return c.data + return None def clear(self): """Removes all children""" @@ -188,7 +193,7 @@ def _walk_parents(self) -> Iterable[Union["UIWidget", "UIManager"]]: parent = parent.parent if parent: - yield parent # type: ignore + yield parent def trigger_render(self): """This will delay a render right before the next frame is rendered, so that @@ -362,9 +367,9 @@ def padding(self, args: Union[int, Tuple[int, int], Tuple[int, int, int, int]]): args = (args, args, args, args) elif len(args) == 2: # self.padding = 10, 20 -> 10, 20, 10, 20 - args = args + args # type: ignore + args = args + args - pt, pr, pb, pl = args # type: ignore # self.padding = 10, 20, 30, 40 + pt, pr, pb, pl = args # self.padding = 10, 20, 30, 40 self._padding_top = pt self._padding_right = pr self._padding_bottom = pb @@ -510,6 +515,9 @@ def center_on_screen(self: W) -> W: self.rect = self.rect.align_center(center) return self + def __str__(self): + return f"{self.__class__.__name__}()" + def __repr__(self): return f"<{self.__class__.__name__} {self.rect.lbwh}>" @@ -517,6 +525,8 @@ def __repr__(self): class UIInteractiveWidget(UIWidget): """Base class for widgets which use mouse interaction (hover, pressed, clicked) + It provides properties for hovered, pressed and disabled states. + Args: x: x coordinate of bottom left y: y coordinate of bottom left @@ -819,8 +829,8 @@ def __init__( *, x=0, y=0, - width=100, - height=100, + width=1, + height=1, color=None, size_hint=None, size_hint_min=None, diff --git a/arcade/gui/widgets/buttons.py b/arcade/gui/widgets/buttons.py index 76fc6d2216..2c339db9a9 100644 --- a/arcade/gui/widgets/buttons.py +++ b/arcade/gui/widgets/buttons.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from typing import Optional, Union +from typing_extensions import TypeAlias + import arcade from arcade import Texture, color, uicolor from arcade.gui.nine_patch import NinePatchTexture @@ -26,7 +28,7 @@ class UITextureButtonStyle(UIStyleBase): font_size: int = 12 font_name: FontNameOrNames = ("Kenney Future", "arial", "calibri") - font_color: RGBA255 = uicolor.WHITE_CLOUDS + font_color: RGBA255 = uicolor.WHITE class UITextureButton(UIInteractiveWidget, UIStyledWidget[UITextureButtonStyle], UITextWidget): @@ -56,7 +58,7 @@ class UITextureButton(UIInteractiveWidget, UIStyledWidget[UITextureButtonStyle], size_hint_max: max width and height in pixel """ - _textures: dict[str, Union[Texture, NinePatchTexture]] = DictProperty() # type: ignore + _textures = DictProperty[str, Union[Texture, NinePatchTexture]]() UIStyle = UITextureButtonStyle @@ -186,6 +188,8 @@ def do_render(self, surface: Surface): style = self.get_current_style() # update label + if style is None: + raise ValueError(f"No style found for state {self.get_current_state()}") self._apply_style(style) current_state = self.get_current_state() @@ -203,7 +207,24 @@ def _apply_style(self, style: UITextureButtonStyle): self.ui_label.rect = self.ui_label.rect.max_size(self.content_width, self.content_height) -class UIFlatButton(UIInteractiveWidget, UIStyledWidget, UITextWidget): +@dataclass +class UIFlatButtonStyle(UIStyleBase): + """Used to style the button. Below is its use case. + + .. code:: py + + button = UIFlatButton(style={"normal": UIFlatButton.UIStyle(...),}) + """ + + font_size: int = 12 + font_name: FontNameOrNames = ("Kenney Future", "arial", "calibri") + font_color: RGBA255 = color.WHITE + bg: RGBA255 = uicolor.DARK_BLUE_MIDNIGHT_BLUE + border: Optional[RGBA255] = None + border_width: int = 0 + + +class UIFlatButton(UIInteractiveWidget, UIStyledWidget[UIFlatButtonStyle], UITextWidget): """A text button, with support for background color and a border. There are four states of the UITextureButton i.e. normal, hovered, pressed and disabled. @@ -220,21 +241,7 @@ class UIFlatButton(UIInteractiveWidget, UIStyledWidget, UITextWidget): style: Used to style the button """ - @dataclass - class UIStyle(UIStyleBase): - """Used to style the button. Below is its use case. - - .. code:: py - - button = UIFlatButton(style={"normal": UIFlatButton.UIStyle(...),}) - """ - - font_size: int = 12 - font_name: FontNameOrNames = ("Kenney Future", "arial", "calibri") - font_color: RGBA255 = color.WHITE - bg: RGBA255 = uicolor.DARK_BLUE_MIDNIGHT_BLUE - border: Optional[RGBA255] = None - border_width: int = 0 + UIStyle: TypeAlias = UIFlatButtonStyle DEFAULT_STYLE = { "normal": UIStyle(), @@ -337,7 +344,7 @@ def get_current_state(self) -> str: def do_render(self, surface: Surface): """Render a flat button, graphical representation depends on the current state.""" self.prepare_render(surface) - style: UIFlatButton.UIStyle = self.get_current_style() + style = self.get_current_style() if style is None: raise ValueError(f"No style found for state {self.get_current_state()}") diff --git a/arcade/gui/widgets/image.py b/arcade/gui/widgets/image.py index a38c01e963..b234d9d57a 100644 --- a/arcade/gui/widgets/image.py +++ b/arcade/gui/widgets/image.py @@ -23,9 +23,9 @@ class UIImage(UIWidget): **kwargs: passed to UIWidget """ - texture: Union[Texture, NinePatchTexture] = Property() # type: ignore + texture = Property[Union[Texture, NinePatchTexture]]() """Texture to show""" - alpha: int = Property(255) # type: ignore + alpha = Property(255) """Alpha value of the texture, value between 0 and 255. 0 is fully transparent, 255 is fully visible.""" diff --git a/arcade/gui/widgets/layout.py b/arcade/gui/widgets/layout.py index b8c715ee2e..40764723d5 100644 --- a/arcade/gui/widgets/layout.py +++ b/arcade/gui/widgets/layout.py @@ -418,6 +418,9 @@ def do_layout(self): # update child rect child.rect = new_rect + def __str__(self): + return f"UIBoxLayout(vertical={self.vertical})" + class UIGridLayout(UILayout): """Place widgets in a grid. @@ -829,7 +832,7 @@ class _C: min: float max: float | None hint: float - final_size: float = 0.0 + _final_size: float = 0.0 """The final size of the entry which will be returned by the algorithm""" @staticmethod @@ -880,9 +883,9 @@ def _box_orthogonal_algorithm(constraints: list[_C], container_size: float) -> L size = container_size * c.hint c.max = container_size if c.max is None else c.max - c.final_size = min(max(c.min, size), c.max) # clamp width to min and max values + c._final_size = min(max(c.min, size), c.max) # clamp width to min and max values - return [c.final_size for c in constraints] + return [c._final_size for c in constraints] def _box_axis_algorithm(constraints: list[_C], container_size: float) -> List[float]: @@ -897,6 +900,12 @@ def _box_axis_algorithm(constraints: list[_C], container_size: float) -> List[fl Returns: List of tuples with the sizes of each element """ + # adjust hint value based on min and max values + for c in constraints: + c.hint = max(c.min / container_size, c.hint) + if c.max is not None: + c.hint = min(c.hint, c.max / container_size) + # normalize hint - which will cover cases, where the sum of the hints is greater than 1. # children will get a relative size based on their hint total_hint = sum(c.hint for c in constraints) @@ -904,35 +913,28 @@ def _box_axis_algorithm(constraints: list[_C], container_size: float) -> List[fl for c in constraints: c.hint /= total_hint - # calculate the width of each entry based on the hint - for c in constraints: - size = container_size * c.hint - c.min = c.min or 0 - max_value = container_size if c.max is None else c.max - - c.final_size = min(max(c.min, size), max_value) # clamp width to min and max values - - # ---- Constantin - # calculate scaling factor - total_size = sum(c.final_size for c in constraints) - growth_diff = total_size - sum(c.min for c in constraints) # calculate available space - total_adjustable_size = container_size - sum(c.min for c in constraints) - - if growth_diff != 0: - scaling_factor = total_adjustable_size / growth_diff + # adjust hint value based on min values, again + for c in constraints: + c.hint = max(c.min / container_size, c.hint) - # adjust final_width based on scaling factor if scaling factor is less than 1 - if scaling_factor < 1: + # check if the total hint is greater than 1, again (caused by reapplied min values) + total_hint = sum(c.hint for c in constraints) + if total_hint > 1: + # calculate the total hint value of all adjustable constraints + total_adjustable_hint = sum(c.hint - c.min / container_size for c in constraints) + + # check if we have any adjustable constraints + if total_adjustable_hint > 0: + # reduce hint values of adjustable constraints to fit the container size + required_adjustment = total_hint - 1 + possible_adjustment = min(required_adjustment, total_adjustable_hint) for c in constraints: - c.final_size *= scaling_factor + adjustable_size = c.hint - c.min / container_size + c.hint -= possible_adjustment * (adjustable_size / total_adjustable_hint) - # recheck min constraints - for c in constraints: - c.final_size = max(c.final_size, c.min) - - # recheck max constraints + # calculate the width of each entry based on the hint for c in constraints: - max_value = container_size if c.max is None else c.max - c.final_size = min(c.final_size, max_value) + c._final_size = container_size * c.hint - return [c.final_size for c in constraints] + # return the calculated sizes, round to avoid floating point errors + return [round(c._final_size, 5) for c in constraints] diff --git a/arcade/gui/widgets/slider.py b/arcade/gui/widgets/slider.py index acca619781..d040885944 100644 --- a/arcade/gui/widgets/slider.py +++ b/arcade/gui/widgets/slider.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from abc import ABCMeta, abstractmethod from dataclasses import dataclass from typing import Mapping, Optional, Union @@ -50,7 +51,7 @@ class UIBaseSlider(UIInteractiveWidget, metaclass=ABCMeta): """ - value = Property(0) + value = Property(0.0) def __init__( self, @@ -172,6 +173,9 @@ def on_event(self, event: UIEvent) -> Optional[bool]: Returns: True if event was handled, False otherwise. """ + if self.disabled: + return EVENT_UNHANDLED + if super().on_event(event): return EVENT_HANDLED @@ -179,7 +183,8 @@ def on_event(self, event: UIEvent) -> Optional[bool]: if self.pressed: old_value = self.value self._thumb_x = event.x - self.dispatch_event("on_change", UIOnChangeEvent(self, old_value, self.value)) # type: ignore + self.dispatch_event("on_change", UIOnChangeEvent(self, old_value, self.value)) + return EVENT_HANDLED return EVENT_UNHANDLED @@ -195,7 +200,7 @@ def on_click(self, event: UIOnClickEvent): """ old_value = self.value self._thumb_x = event.x - self.dispatch_event("on_change", UIOnChangeEvent(self, old_value, self.value)) # type: ignore + self.dispatch_event("on_change", UIOnChangeEvent(self, old_value, self.value)) def on_change(self, event: UIOnChangeEvent): """To be implemented by the user, triggered when the cursor's value is changed. @@ -233,7 +238,7 @@ class UISliderStyle(UIStyleBase): class UISlider(UIStyledWidget[UISliderStyle], UIBaseSlider): """A simple slider. - A slider contains of a horizontal track and a thumb. + A slider consists of a horizontal track and a thumb. The thumb can be moved along the track to set the value of the slider. Use the `on_change` event to get notified about value changes. @@ -288,7 +293,7 @@ def __init__( size_hint=None, size_hint_min=None, size_hint_max=None, - style: Union[Mapping[str, UISliderStyle], None] = None, + style: Union[dict[str, UISliderStyle], None] = None, **kwargs, ): super().__init__( @@ -325,6 +330,9 @@ def get_current_state(self) -> str: @override def _render_track(self, surface: Surface): style = self.get_current_style() + if style is None: + warnings.warn(f"No style found for state {self.get_current_state()}", UserWarning) + return bg_slider_color = style.get("unfilled_track", UISlider.UIStyle.unfilled_track) fg_slider_color = style.get("filled_track", UISlider.UIStyle.filled_track) @@ -355,6 +363,9 @@ def _render_track(self, surface: Surface): @override def _render_thumb(self, surface: Surface): style = self.get_current_style() + if style is None: + warnings.warn(f"No style found for state {self.get_current_state()}", UserWarning) + return border_width = style.get("border_width", UISlider.UIStyle.border_width) cursor_color = style.get("bg", UISlider.UIStyle.bg) @@ -405,7 +416,11 @@ def __init__( @override def _render_track(self, surface: Surface): - style: UISliderStyle = self.get_current_style() # type: ignore + style = self.get_current_style() + if style is None: + warnings.warn(f"No style found for state {self.get_current_state()}", UserWarning) + return + surface.draw_texture(0, 0, self.width, self.height, self._track_tex) # TODO accept these as constructor params @@ -416,13 +431,14 @@ def _render_track(self, surface: Surface): slider_bottom = (self.height - slider_height) // 2 # slider - arcade.draw_lbwh_rectangle_filled( - slider_left_x - self.left, - slider_bottom, - cursor_center_x - slider_left_x, - slider_height, - style.filled_track, - ) + if style.filled_track: + arcade.draw_lbwh_rectangle_filled( + slider_left_x - self.left, + slider_bottom, + cursor_center_x - slider_left_x, + slider_height, + style.filled_track, + ) @override def _render_thumb(self, surface: Surface): diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index 62f40ad193..c889443b9a 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -745,7 +745,7 @@ def on_event(self, event: UIEvent) -> Optional[bool]: """Handle scrolling of the widget.""" if isinstance(event, UIMouseScrollEvent): if self.rect.point_in_rect(event.pos): - self.layout.view_y += event.scroll_y * self.scroll_speed # type: ignore # pending https://github.com/pyglet/pyglet/issues/916 + self.layout.view_y = round(self.layout.view_y + event.scroll_y * self.scroll_speed) self.trigger_full_render() if super().on_event(event): diff --git a/arcade/gui/widgets/toggle.py b/arcade/gui/widgets/toggle.py index 6f9e2463ef..d854dce105 100644 --- a/arcade/gui/widgets/toggle.py +++ b/arcade/gui/widgets/toggle.py @@ -33,7 +33,7 @@ class UITextureToggle(UIInteractiveWidget): """ # Experimental ui class - value: bool = Property(False) # type: ignore + value = Property(False) def __init__( self, diff --git a/arcade/resources/system/gui_basic_assets/scroll/indicator_down.png b/arcade/resources/system/gui_basic_assets/scroll/indicator_down.png new file mode 100644 index 0000000000..81531557bd Binary files /dev/null and b/arcade/resources/system/gui_basic_assets/scroll/indicator_down.png differ diff --git a/arcade/resources/system/gui_basic_assets/scroll/indicator_up.png b/arcade/resources/system/gui_basic_assets/scroll/indicator_up.png new file mode 100644 index 0000000000..96a6b86c45 Binary files /dev/null and b/arcade/resources/system/gui_basic_assets/scroll/indicator_up.png differ diff --git a/arcade/uicolor.py b/arcade/uicolor.py index e2cd86c619..cc1b4fef2a 100644 --- a/arcade/uicolor.py +++ b/arcade/uicolor.py @@ -3,8 +3,9 @@ https://materialui.co/flatuicolors/ """ -from arcade.color import Color +from arcade.color import BLACK, WHITE, Color +WHITE = WHITE GREEN_TURQUOISE = Color(26, 188, 156) GREEN_GREEN_SEA = Color(22, 160, 133) GREEN_NEPHRITIS = Color(39, 174, 96) @@ -25,8 +26,11 @@ WHITE_SILVER = Color(189, 195, 199) GRAY_CONCRETE = Color(149, 165, 166) GRAY_ASBESTOS = Color(127, 140, 141) +BLACK = BLACK __all__ = [ + "BLACK", + "WHITE", "GREEN_TURQUOISE", "GREEN_GREEN_SEA", "GREEN_NEPHRITIS", diff --git a/doc/example_code/gui_6_size_hints.rst b/doc/example_code/gui_6_size_hints.rst new file mode 100644 index 0000000000..8776c4a840 --- /dev/null +++ b/doc/example_code/gui_6_size_hints.rst @@ -0,0 +1,24 @@ +:orphan: + +.. _gui_6_size_hints: + +GUI Size Hints +============== + +This example shows how to use size hints to control the size of a GUI element. + +The `size_hint` property is a tuple of two values. +The first value is the width, and the second value is the height. +The values are in percent of the parent element. +For example, a size hint of (0.5, 0.5) would make the element half +the width and half the height of the parent element. + + +.. image:: images/gui_6_size_hints.png + :width: 600px + :align: center + :alt: Screen shot + +.. literalinclude:: ../../arcade/examples/gui/6_size_hints.py + :caption: 6_size_hints.py + :linenos: diff --git a/doc/example_code/gui_exp_scroll_area.rst b/doc/example_code/gui_exp_scroll_area.rst new file mode 100644 index 0000000000..0b36b0eed1 --- /dev/null +++ b/doc/example_code/gui_exp_scroll_area.rst @@ -0,0 +1,17 @@ +:orphan: + +.. _gui_exp_scroll_area: + +GUI Scroll Area +=============== + +The following example demonstrates how to make use of the experimental UIScrollLayout widget. + +.. image:: images/gui_exp_scroll_area.png + :width: 600px + :align: center + :alt: Screen shot + +.. literalinclude:: ../../arcade/examples/gui/exp_scroll_area.py + :caption: exp_scroll_area.py + :linenos: diff --git a/doc/example_code/images/gui_6_size_hints.png b/doc/example_code/images/gui_6_size_hints.png new file mode 100644 index 0000000000..68104516cc Binary files /dev/null and b/doc/example_code/images/gui_6_size_hints.png differ diff --git a/doc/example_code/images/gui_exp_scroll_area.png b/doc/example_code/images/gui_exp_scroll_area.png new file mode 100644 index 0000000000..3bbc740a45 Binary files /dev/null and b/doc/example_code/images/gui_exp_scroll_area.png differ diff --git a/doc/example_code/index.rst b/doc/example_code/index.rst index 6677459c7e..f78a2fd42f 100644 --- a/doc/example_code/index.rst +++ b/doc/example_code/index.rst @@ -622,6 +622,12 @@ Graphical User Interface :ref:`gui_5_uicolor_picker` +.. figure:: images/thumbs/gui_6_size_hints.png + :figwidth: 170px + :target: gui_6_size_hints.html + + :ref:`gui_6_size_hints` + .. note:: Not all existing examples made it into this section. You can find more under `Arcade GUI Examples `_ diff --git a/doc/programming_guide/gui/index.rst b/doc/programming_guide/gui/index.rst index 1cd815bdd8..02f95321c6 100644 --- a/doc/programming_guide/gui/index.rst +++ b/doc/programming_guide/gui/index.rst @@ -16,12 +16,24 @@ We recommend to read the :ref:`gui_concepts`, to get a better understanding of t GUI module and its components. +**Learning Steps**: + +- **1 - starting**: Learn about UIManager, widgets like UIFlatButton and layouts like UIAnchorLayout and UIBoxLayout. Create your first button. +- **2 - intermediate**: Learn more about size_hints and how to nest different layouts. +- **3 - advanced**: Learn how to create your own custom widgets. +- **4 - expert**: Create your own layouts supporting non standard positioning and animations. + +**Note**: Contribution and feedback of all skill levels is welcome. Please connect with us on Discord or GitHub. + +Find the required information in the following sections: + .. toctree:: :maxdepth: 2 concepts layouts style + own_widgets diff --git a/doc/programming_guide/gui/own_widgets.rst b/doc/programming_guide/gui/own_widgets.rst new file mode 100644 index 0000000000..ce9a5ae6d5 --- /dev/null +++ b/doc/programming_guide/gui/own_widgets.rst @@ -0,0 +1,48 @@ +.. _gui_own_widgets: + +Own Widgets +----------- + +Creating own widgets is a powerful feature of the GUI module. +It allows you to create custom widgets that can be used in your application. + +In most cases this is even the easiest way to implement your desired interface. + +The following sections will guide you through the process of creating own widgets. + + + +Where to start +~~~~~~~~~~~~~~ + +To create own widgets, you need to create a new class that inherits from :class:`arcade.gui.UIWidget`. + +While inheriting from :class:`arcade.gui.UIWidget`, provides the highest flexibility, +you can also make use of other base classes, which provide a more specialized interface. + +Further baseclasses are: + +- :class:`arcade.gui.UIInteractiveWidget` + `UIInteractiveWidget` is a baseclass for widgets that can be interacted with. + It provides a way to handle mouse events and properties like `hovered` or `pressed`. + In addition it already implements the `on_click` method, + which can be used to react to a click event. + +- :class:`arcade.gui.UIAnchorLayout` + `UIAnchorLayout` is basically a frame, which can be used to position widgets + to a place within the widget. This makes it a great baseclass for a widget containing + multiple other widgets. (Examples: `MessageBox`, `Card`, etc.) + +If your widget should act more as a general layout, position various widgets and handle their size, +you should inherit from :class:`arcade.gui.UILayout` instead. + +In the following example, we will create two progress bar widgets +to show the differences between two of the base classes. + + +Example `ProgressBar` +~~~~~~~~~~~~~~~~~~~~~ + +.. literalinclude:: ../../../arcade/examples/gui/own_progressbar.py + + diff --git a/doc/tutorials/menu/index.rst b/doc/tutorials/menu/index.rst index 27d0aaeb1e..63682ebd7f 100644 --- a/doc/tutorials/menu/index.rst +++ b/doc/tutorials/menu/index.rst @@ -39,8 +39,8 @@ First we will import the arcade gui: .. literalinclude:: menu_02.py :caption: Importing arcade.gui - :lines: 4-5 - :emphasize-lines: 2 + :lines: 6-8 + :emphasize-lines: 3 Modify the MainView ~~~~~~~~~~~~~~~~~~~~ @@ -49,36 +49,38 @@ We are going to add a button to change the view. For drawing a button we would need a ``UIManager``. .. literalinclude:: menu_02.py - :caption: Intialising the Manager - :lines: 16-19 - :emphasize-lines: 4 + :caption: Initialising the Manager + :lines: 19-22 + :emphasize-lines: 3 After initialising the manager we need to enable it when the view is shown and -disable it when the view is hiddien. +disable it when the view is hidden. .. literalinclude:: menu_02.py :caption: Enabling the Manager :pyobject: MainView.on_show_view - :emphasize-lines: 5-6 + :emphasize-lines: 6 .. literalinclude:: menu_02.py :caption: Disabling the Manager :pyobject: MainView.on_hide_view + :emphasize-lines: 3 -We also need to draw the childrens of the menu in ``on_draw``. +We also need to draw the children of the menu in ``on_draw``. .. literalinclude:: menu_02.py - :caption: Drawing Children's of the Manager + :caption: Drawing UI on screen :pyobject: MainView.on_draw - :emphasize-lines: 6-7 + :emphasize-lines: 7 -Now we have successfully setup the manager, only thing left it to add the button. +Now we have successfully setup the manager, we can now add a button to the view. We are using ``UIAnchorLayout`` to position the button. We also setup a function which is called when the button is clicked. .. literalinclude:: menu_02.py + :pyobject: MainView.__init__ :caption: Initialising the Button - :lines: 21-37 + :emphasize-lines: 8-12 Initialise the Menu View ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -113,7 +115,10 @@ First we setup buttons for resume, starting a new game, volume, options and exit .. literalinclude:: menu_03.py :caption: Initialising the Buttons - :lines: 67-72 + :pyobject: MenuView.__init__ + :emphasize-lines: 6-11 + :lines: 1-12 + Displaying the Buttons in a Grid ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -123,7 +128,9 @@ displayed in a grid like manner. .. literalinclude:: menu_03.py :caption: Setting up the Grid - :lines: 74-90 + :pyobject: MenuView.__init__ + :emphasize-lines: 14-23 + :lines: 1-24 Final code for the ``__init__`` method after these. @@ -154,7 +161,7 @@ as they don't have much to explain. .. literalinclude:: menu_04.py :caption: Adding callback for button events 1 - :lines: 94-107 + :lines: 98-113 Adding ``on_click`` Callback for Volume and Options ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -173,7 +180,7 @@ volume and options button to trigger it when they are clicked. .. literalinclude:: menu_04.py :caption: Adding callback for button events 2 - :lines: 109-123 + :lines: 113-123 Program Listings ~~~~~~~~~~~~~~~~ @@ -197,7 +204,7 @@ later why are those parameters needed. .. literalinclude:: menu_05.py :caption: Editing parameters - :lines: 153-156 + :lines: 161-168 We also need to change accordingly the places where we have used this class i.e options and volume ``on_click`` event listener. The layer parameter being set @@ -205,7 +212,7 @@ options and volume ``on_click`` event listener. The layer parameter being set .. literalinclude:: menu_05.py :caption: Editing arguments - :lines: 109-131 + :lines: 115-136 Now you might be getting a little idea why we have edited the parameters but follow on to actually know the reason. @@ -220,13 +227,13 @@ background color so it appears invisible. .. literalinclude:: menu_05.py :caption: Adding title label - :lines: 179-181 + :lines: 193-195 Adding it to the widget layout. .. literalinclude:: menu_05.py :caption: Adding title label to the layout - :lines: 213-215 + :lines: 238-239 Adding a Input Field @@ -239,14 +246,13 @@ the title label. .. literalinclude:: menu_05.py :caption: Adding input field - :lines: 183 + :lines: 197 Adding it to the widget layout. .. literalinclude:: menu_05.py :caption: Adding input field to the layout - :lines: 213-216 - :emphasize-lines: 4 + :lines: 240 If you paid attention when we defined the ``input_text`` variable we passed the ``text`` parameter with our ``input_text_default`` argument. We basically added @@ -265,14 +271,14 @@ toggle label. .. literalinclude:: menu_05.py :caption: Adding toggle button - :lines: 189-201 + :lines: 199-216 Adding it to the widget layout. Add this line after you have added the input field. .. literalinclude:: menu_05.py :caption: Adding toggle button to the layout - :lines: 217 + :lines: 241 Adding a Dropdown ~~~~~~~~~~~~~~~~~ @@ -281,13 +287,13 @@ We add a dropdown by using ``UIDropdown``. .. literalinclude:: menu_05.py :caption: Adding dropdown - :lines: 203-204 + :lines: 219-221 Adding it to the widget layout. .. literalinclude:: menu_05.py :caption: Adding dropdown to the layout - :lines: 218 + :lines: 242 Adding a Slider ~~~~~~~~~~~~~~~ @@ -298,13 +304,13 @@ Theres a functionality to style the slider, this is also present for .. literalinclude:: menu_05.py :caption: Adding slider - :lines: 206-207 + :lines: 223-235 Adding it to the widget layout. .. literalinclude:: menu_05.py :caption: Adding slider to the layout - :lines: 219-220 + :lines: 243-244 Finishing touches ~~~~~~~~~~~~~~~~~ diff --git a/doc/tutorials/menu/menu.gif b/doc/tutorials/menu/menu.gif index 66f84bd86f..d520e0c8cd 100644 Binary files a/doc/tutorials/menu/menu.gif and b/doc/tutorials/menu/menu.gif differ diff --git a/doc/tutorials/menu/menu_01.png b/doc/tutorials/menu/menu_01.png index c8f2990eee..c7a806746a 100644 Binary files a/doc/tutorials/menu/menu_01.png and b/doc/tutorials/menu/menu_01.png differ diff --git a/doc/tutorials/menu/menu_02.png b/doc/tutorials/menu/menu_02.png index a0291b6cd8..3388f8923e 100644 Binary files a/doc/tutorials/menu/menu_02.png and b/doc/tutorials/menu/menu_02.png differ diff --git a/doc/tutorials/menu/menu_02.py b/doc/tutorials/menu/menu_02.py index e0e5357f36..05a51d3c78 100644 --- a/doc/tutorials/menu/menu_02.py +++ b/doc/tutorials/menu/menu_02.py @@ -3,6 +3,7 @@ Shows the usage of almost every gui widget, switching views and making a modal. """ + import arcade import arcade.gui @@ -17,7 +18,6 @@ class MainView(arcade.View): def __init__(self): super().__init__() - self.manager = arcade.gui.UIManager() switch_menu_button = arcade.gui.UIFlatButton(text="Pause", width=250) @@ -38,19 +38,19 @@ def on_click_switch_button(event): child=switch_menu_button, ) - def on_hide_view(self): - # Disable the UIManager when the view is hidden. - self.manager.disable() - def on_show_view(self): - """ This is run once when we switch to this view """ + """This is run once when we switch to this view""" arcade.set_background_color(arcade.color.DARK_BLUE_GRAY) # Enable the UIManager when the view is showm. self.manager.enable() + def on_hide_view(self): + # Disable the UIManager when the view is hidden. + self.manager.disable() + def on_draw(self): - """ Render the screen. """ + """Render the screen.""" # Clear the screen self.clear() @@ -73,7 +73,7 @@ def on_hide_view(self): self.manager.disable() def on_show_view(self): - """ This is run once when we switch to this view """ + """This is run once when we switch to this view""" # Makes the background darker arcade.set_background_color([rgb - 50 for rgb in arcade.color.DARK_BLUE_GRAY]) @@ -81,7 +81,7 @@ def on_show_view(self): self.manager.enable() def on_draw(self): - """ Render the screen. """ + """Render the screen.""" # Clear the screen self.clear() diff --git a/doc/tutorials/menu/menu_03.png b/doc/tutorials/menu/menu_03.png index 053aa29d76..f2334a6a93 100644 Binary files a/doc/tutorials/menu/menu_03.png and b/doc/tutorials/menu/menu_03.png differ diff --git a/doc/tutorials/menu/menu_04.png b/doc/tutorials/menu/menu_04.png index 5e2b2fbc7d..dc6dc46330 100644 Binary files a/doc/tutorials/menu/menu_04.png and b/doc/tutorials/menu/menu_04.png differ diff --git a/doc/tutorials/menu/menu_05.png b/doc/tutorials/menu/menu_05.png index 5aa2c929f3..f3ce224428 100644 Binary files a/doc/tutorials/menu/menu_05.png and b/doc/tutorials/menu/menu_05.png differ diff --git a/doc/tutorials/menu/menu_05.py b/doc/tutorials/menu/menu_05.py index b532552906..d703ee0efc 100644 --- a/doc/tutorials/menu/menu_05.py +++ b/doc/tutorials/menu/menu_05.py @@ -197,9 +197,11 @@ def __init__( input_text_widget = arcade.gui.UIInputText(text=input_text, width=250).with_border() # Load the on-off textures. - on_texture = arcade.load_texture(":resources:gui_basic_assets/toggle/circle_switch_on.png") + on_texture = arcade.load_texture( + ":resources:gui_basic_assets/simple_checkbox/circle_on.png" + ) off_texture = arcade.load_texture( - ":resources:gui_basic_assets/toggle/circle_switch_off.png" + ":resources:gui_basic_assets/simple_checkbox/circle_off.png" ) # Create the on-off toggle and a label diff --git a/tests/unit/gui/test_layouting_box_main_algorithm.py b/tests/unit/gui/test_layouting_box_main_algorithm.py index 0f28e9adf0..092f7b410e 100644 --- a/tests/unit/gui/test_layouting_box_main_algorithm.py +++ b/tests/unit/gui/test_layouting_box_main_algorithm.py @@ -46,6 +46,37 @@ def test_complex_example_with_max_value(): assert sizes == [20, 20, 50] +def test_complex_example_with_max_value_2(): + # GIVEN + entries = [ + _C(hint=0.2, min=10, max=10), + _C(hint=0.2, min=10, max=15), + _C(hint=0.6, min=10, max=50), + ] + + # WHEN + sizes = _box_axis_algorithm(entries, 100) + + # THEN + assert sizes == [10, 15, 50] + + +def test_complex_example_with_max_value_hint_above_1(): + # GIVEN + entries = [ + _C(hint=0.3, min=0, max=None), + _C(hint=0.2, min=0, max=None), + _C(hint=0.6, min=0, max=50), + ] + + # WHEN + sizes = _box_axis_algorithm(entries, 100) + + # THEN + assert sum(sizes) == 100 + assert sizes == [30, 20, 50] + + def test_complex_example_with_min_value(): # GIVEN entries = [ @@ -217,3 +248,43 @@ def test_example_grow_relative_to_size_hint_huge_min(): 20, 80, ] + + +def test_example_grow_relative_to_size_hint_huge_min_2(): + # GIVEN + entries = [ + _C(hint=1, min=0, max=None), + _C(hint=0.5, min=0, max=None), + _C(hint=0.5, min=70, max=None), + ] + + # WHEN + e1, e2, e3 = _box_axis_algorithm(entries, 100) + + # THEN + assert [ + int(e1), + int(e2), + int(e3), + ] == [ + 20, + 10, + 70, + ] + + +def test_enforced_min_size(): + # GIVEN + entries = [ + _C(hint=0, min=30, max=None), + _C(hint=1, min=30, max=None), + ] + + # WHEN + e1, e2 = _box_axis_algorithm(entries, 100) + + # THEN + assert [int(e1), int(e2)] == [ + 30, + 70, + ] diff --git a/tests/unit/gui/test_uislider.py b/tests/unit/gui/test_uislider.py index 8c4e5b415c..6e1608fbf0 100644 --- a/tests/unit/gui/test_uislider.py +++ b/tests/unit/gui/test_uislider.py @@ -25,3 +25,18 @@ def test_change_value_on_drag(ui): # THEN assert slider.value == 20 + + +def test_disable_slider(ui): + # GIVEN + slider = UISlider(height=30, width=120) + ui.add(slider) + slider.disabled = True + + # WHEN + cx, cy = slider._thumb_x, slider.rect.y + ui.click_and_hold(cx, cy) + ui.drag(cx + 20, cy) + + # THEN + assert slider.value == 0 diff --git a/tests/unit/gui/test_widget_tree.py b/tests/unit/gui/test_widget_tree.py index cc9cad1901..13b39fe78f 100644 --- a/tests/unit/gui/test_widget_tree.py +++ b/tests/unit/gui/test_widget_tree.py @@ -43,6 +43,19 @@ def test_widget_remove_child(): assert child not in parent.children +def test_widget_remove_child_returns_kwargs(): + # GIVEN + parent = UIDummy() + child = UIDummy() + + # WHEN + parent.add(child, key="value") + kwargs = parent.remove(child) + + # THEN + assert kwargs == {"key": "value"} + + def test_widget_clear_children(): # GIVEN parent = UIDummy()