diff --git a/sgc/LICENSE b/sgc/LICENSE new file mode 100644 index 0000000..bc51544 --- /dev/null +++ b/sgc/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2010-2012, Sam Bull +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/sgc/__init__.py b/sgc/__init__.py new file mode 100644 index 0000000..7676f64 --- /dev/null +++ b/sgc/__init__.py @@ -0,0 +1,14 @@ +""" +Module Packages: + :py:mod:`widgets`: All the widgets available for use in this toolkit. + +Modules: + :py:mod:`locals`: Constants to be imported into the local namespace for convenience. + :py:mod:`surface`: Extended pygame.surface classes. + +""" + +import surface +import locals +import widgets +from widgets._locals import Font diff --git a/sgc/example/menu b/sgc/example/menu new file mode 100644 index 0000000..28c7972 --- /dev/null +++ b/sgc/example/menu @@ -0,0 +1,16 @@ +("m:Main Menu", + ("m:Sub-menu", + ("w:input_box","name=input","label=Input","default=start typing"), + ("c:Category/divider",), + ("w:button","name=btn","label=Click\nhere","func=print_input") + ), + ("m:Settings", + ("m:Graphic Settings", + ("c:Graphic stuff",) + ), + ("m:Sound Settings", + ("c:Sound stuff",) + ) + ), + ("f:remove", "Quit") +) diff --git a/sgc/example/test.py b/sgc/example/test.py new file mode 100644 index 0000000..e01f297 --- /dev/null +++ b/sgc/example/test.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python + +# Copyright (C) 2010-2012 Sam Bull + +""" +An example file demonstrating proper use of widgets. + +""" + +import os +import sys +import random + +import pygame +from pygame.locals import * +try: + from OpenGL.GL import * +except: pass + +pygame.display.init() +pygame.font.init() + +sys.path.insert(0, "../..") + +import sgc +from sgc.locals import * + +ver_no = "0.1.3" + +screen = sgc.surface.Screen((640,480)) #, flags=OPENGL +clock = pygame.time.Clock() +pygame.scrap.init() + +def clear(): + """Clear input box when enter key is pressed.""" + input_box.text = "" + + +class MainMenu(sgc.widgets.Menu): + """Create a subclass for custom functions.""" + + func_dict = lambda self: {"print_input": self.print_input, + "remove": self.remove} + + def print_input(self): + print self["input"].text + +sgc.Font.col = (150,150,150) # TODO Button font colour + + +# Title +title = sgc.widgets.Label(text="Simple Game Code " + ver_no, + font=sgc.Font["title"], col=sgc.Font.col) +title.rect.center = (screen.rect.w/2, 40) +title.add() + +# Create input_box +input_box = sgc.widgets.InputBox(label="Input Box", default="default text...") +input_box.config(pos=(30,120)) +input_box.add(0) +# Change colour button, activate event caught in event loop +button = sgc.widgets.Button(label="Change\ncolour", pos=(40,200)) +# Create FPS counter +fps = sgc.widgets.FPSCounter(clock=clock) +fps.rect.midbottom = (screen.rect.w/2, screen.rect.h) +fps.add() +# Pass config file as argument, to have Menu parse file +with open("menu") as menu_file: + menu = MainMenu(menu=menu_file) + +# Display menu on button click, activate replaced through assignment +btn_menu = sgc.widgets.Button(label="Menu", pos=(250,200)) +btn_menu.activate = menu.add + + +# Input_box for dialog window +password_box = sgc.widgets.InputBox(label="Password", default="Enter password...") +password_box.pos = (0,10) +# Button for dialog window +def print_pass(): + print password_box.text + dialogs[-1].remove() +btn_ok = sgc.widgets.Button(label="OK", pos=(30,60)) +btn_ok.activate = print_pass +# Place widgets into a container +dialog_container = sgc.widgets.Container(widgets=(password_box, btn_ok), + border=10) +# Display dialog window, activate replaced through inheritance +dialogs = [] +class BtnDialog(sgc.widgets.Button): + def activate(self): + dialogs.append(sgc.widgets.Dialog(widget=dialog_container, + title="Window title here...")) + dialogs[-1].rect.center = screen.rect.center + dialogs[-1].add() +btn_dialog = BtnDialog(label="Dialog", pos=(460,200)) + +box_btn = sgc.widgets.HBox(widgets=[button, btn_menu, btn_dialog], spacing=70) + +scroll_box = sgc.widgets.ScrollBox((300, box_btn.rect.h), widget=box_btn) +scroll_box.rect.center = screen.rect.center +scroll_box.add(1) + +# Radio Buttons +radio1 = sgc.widgets.Radio(group="group1", label="Option 1", active=True) +radio2 = sgc.widgets.Radio(group="group1", label="Option 2") +radio3 = sgc.widgets.Radio(group="group1", label="Option 3") +radio_box = sgc.widgets.VBox(widgets=(radio1, radio2, radio3), pos=(40,320)) +radio_box.add(2) + +# Toggle Button +toggle = sgc.widgets.Toggle(label="Toggle", pos=(200,320)) +toggle.add(3) + +# Selectable Label +label = sgc.widgets.Label(text="This is a selectable label", selectable=True) +label.rect.midtop = title.rect.midbottom +label.add() + +while True: + time = clock.tick(30) + for event in pygame.event.get(): + # Send event to widgets + sgc.widgets.event(event) + if event.type == GUI: + if event.widget_type is sgc.widgets.Button: + print "Button event" + if event.widget is button and event.gui_type == "activate": + button.config(col=[random.randrange(1,200) for x in range(3)]) + elif event.widget is input_box: + clear() + elif event.type == KEYDOWN: + if event.key == K_f: + fps.toggle() + elif event.type == QUIT: + exit() + + # Cleanup removed windows + for widget in dialogs: + if not widget.active(): + dialogs.remove(widget) + + if not screen._opengl: + screen.fill((0,0,255)) + else: + glClearColor(0,0,1,1) + glClear(GL_COLOR_BUFFER_BIT) + # Update the widgets once for each frame + sgc.widgets.update(time) + + pygame.display.flip() diff --git a/sgc/locals.py b/sgc/locals.py new file mode 100644 index 0000000..8b870f5 --- /dev/null +++ b/sgc/locals.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python + +# Copyright (C) 2012 Sam Bull + +""" +Imports useful objects into the local namespace. + +Constants: + GUI: Event type for any event emitted by this toolkit. + +""" + +from widgets._locals import GUI diff --git a/sgc/surface.py b/sgc/surface.py new file mode 100644 index 0000000..dbc986c --- /dev/null +++ b/sgc/surface.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python + +#Copyright (C) 2010-2012 Sam Bull + +""" +Screen class to store rect information with the screen and setup the toolkit. + +""" + +import pygame.display +from pygame.locals import * + +import widgets._locals + +class Screen(): + + """ + Class for the screen. + + This must be used instead of ``pygame.display.set_mode()``. + + Attributes: + image: The pygame.display screen. + rect: ``pygame.Rect`` containing screen size. + + """ + + __slots__ = ("_a", "rect", "image", "_opengl") + + _a = 1 # Base alpha for OpenGLImage's + _opengl = False + + def __init__(self, size, flags=0, depth=0): + """ + Args: + size, flags, depth: Arguments for pygame.display.set_mode() + + """ + self.rect = Rect((0,0), size) + self.image = pygame.display.set_mode(size, flags, depth) + if flags & OPENGL: + self._opengl = True + widgets._locals.SCREEN = self + widgets._locals.Font.set_fonts() + + def __getattr__(self, atr): + return getattr(self.image, atr) diff --git a/sgc/widgets/__init__.py b/sgc/widgets/__init__.py new file mode 100644 index 0000000..1858568 --- /dev/null +++ b/sgc/widgets/__init__.py @@ -0,0 +1,40 @@ +""" +All widgets are imported into widget's namespace. This means you can access +widgets without their modules, such as ``sgc.widgets.Button``. + +Widgets: + :py:class:`Simple`: Simple widget that does nothing. May be useful for images etc. + :py:class:`Button`: Clickable button. + :py:class:`FPSCounter`: FPS counter. + :py:class:`InputBox`: Input box. + :py:class:`Label`: Label. + :py:class:`Menu`: Game menu. + :py:class:`Radio`: Radio button. + :py:class:`settings`: TODO (Stay away). Common user settings (keymap etc.) + :py:class:`Toggle`: Toggle button. + +Container widgets: + :py:class:`Container`: Basic container, holds a group of other widgets and handles + focus between them. + :py:class:`VBox`: Automatically aligns widgets into a vertical column. + :py:class:`HBox`: Automatically aligns widgets into a horizontal row. + :py:class:`Dialog`: Dialog window. + :py:class:`ScrollBox`: Allows another widget to be scrollable. + +""" + +from . import * +from _locals import update, event +from base_widget import Simple +from boxes import VBox, HBox +from button import Button +from container import Container +from dialog import Dialog +from fps_counter import FPSCounter +from input_box import InputBox +from label import Label +from menu import Menu +from radio_button import Radio +from scroll_box import ScrollBox +from settings import Keys +from toggle import Toggle diff --git a/sgc/widgets/_locals.py b/sgc/widgets/_locals.py new file mode 100644 index 0000000..29e0f60 --- /dev/null +++ b/sgc/widgets/_locals.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python + +# Copyright (C) 2012 Sam Bull + +""" +A collection of things for widgets to use. + +Constants: + GUI: Widgets should use this for the event type of any events emitted. + +get_screen(): Returns the screen object. + +""" + +import pygame.sprite +from pygame.locals import * + +try: + import opengl +except ImportError: pass + +# Things for widgets to import +__all__ = ["GUI", "get_screen", "Font"] + +# Event type +GUI = USEREVENT + +SCREEN = None +get_screen = lambda: SCREEN + +# Cursor queue for set_cursor() and remove_cursor() +cursors = [] + + + +# ----- EXTERNAL FUNCTIONS ----- + +def update(time): + """Updates all active widgets or modal widgets each frame.""" + + def _fade(widget): + """Fade widget.""" + if widget._fade is not None: + widget.image.set_alpha(widget._fade) + if widget._fade_up: + widget._fade += time / 3. + else: + widget._fade -= time / 4. + if widget._fade <= 0: + # Remove after fading + widget.kill() + # Reset widget to be added again + widget._fade = None + elif widget._fade >= 255: + widget._fade = None + widget.image.set_alpha(255) + + if SCREEN._opengl: + glMatrixMode(GL_PROJECTION) + glLoadIdentity() + w,h = SCREEN.get_size() + glOrtho(0, w, h, 0, 0, 1) + glMatrixMode(GL_MODELVIEW) + glPushMatrix() + glLoadIdentity() + glDisable(GL_LIGHTING) + glDisable(GL_DEPTH_TEST) + glEnable(GL_SCISSOR_TEST) + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + + # Fade widgets + for widget in layer_widgets: + _fade(widget) + for widget in active_widgets: + _fade(widget) + + # Update widgets + active_widgets.update(time) + if not SCREEN._opengl: + active_widgets.draw(SCREEN) + else: + for widget in active_widgets: + widget.image.draw() + + # Update layered widgets + layer_widgets.update(time) + if not SCREEN._opengl: + layer_widgets.draw(SCREEN) + else: + for widget in layer_widgets: + widget.image.draw() + + if SCREEN._opengl: + glDisable(GL_SCISSOR_TEST) + glPopMatrix() + +def event(event): + """Send event to focused widget and handle widget focus.""" + if modal_widgets and not focus: + modal_widgets.sprites()[-1].add(0) + # Mouse focus + if event.type == MOUSEBUTTONDOWN: + if not modal_widgets: + hit = False + for widget_list in (reversed(layer_widgets.sprites()), + active_widgets): + for widget in widget_list: + # Check if user clicked a widget + if widget._can_focus and \ + widget.rect.collidepoint(event.pos): + focus.add(2, widget) + hit = True + if widget in layer_widgets: + layer_widgets.move_to_front(widget) + break + if hit: break + # Lose focus if clicking away from widgets + if not hit: + focus.empty() + # Keyboard focus + elif event.type == KEYDOWN and event.key == K_TAB: + if not modal_widgets and focus_order: + # Flattened focus_order + order = sum(focus_order,()) + if focus.sprite not in order: + curr_num = None + else: + # Focus number for current focused widget + curr_num = order[order.index(focus.sprite)-1] + # Sorted list of the focus numbers being used + list_num = sorted(order[::2]) + if not event.mod & KMOD_SHIFT: # Move focus to next widget + if curr_num is None: + # If nothing focused, focus first widget + new_num = list_num[0] + elif not focus.sprite._change_focus(True): + # Don't change when not at end of container widget + new_num = curr_num + elif list_num.index(curr_num) == len(list_num)-1: + # Jump back to first widget + new_num = list_num[0] + else: + # Next focus number in the list + new_num = list_num[list_num.index(curr_num)+1] + else: # Shift key - move focus to previous widget + if curr_num is None: + new_num = list_num[-1] + elif not focus.sprite._change_focus(False): + new_num = curr_num + elif list_num.index(curr_num) == 0: + # Jump back to last widget + new_num = list_num[len(list_num)-1] + else: + new_num = list_num[list_num.index(curr_num)-1] + if curr_num != new_num: + # Set widget at new focus number + focus.add(1, order[order.index(new_num)+1]) + + # Send event to focused widget + if focus: + focus.sprite._event(event) + + + +# ----- FONTS ----- + +class _Font(): + """Wrapper class for font objects.""" + __slots__ = ("_font",) + _font = None + + def replace(self, font): + """Replace the font in-place.""" + self._font = font + + def __getattr__(self, atr): + return getattr(self._font, atr) + + def __nonzero__(self): + return True if self._font else False + +class FontMetaclass(type): + """Font metaclass to allow indexing of class.""" + def __getitem__(cls, item): + return cls._fonts[item] + +class Font(): + """ + Class containing fonts available for use. + + Index class to get fonts, such as ``Font["widget"]`` for the widget font. + + The default fonts are: + widget: The default font for widgets. + title: A larger title font. + mono: A monospaced font. + + Attributes: + col: (r,g,b) tuple, containing the default font colour. + + """ + + __metaclass__ = FontMetaclass + __slots__ = ("_fonts", "col") + _fonts = {"widget": _Font(), "title": _Font(), "mono": _Font()} + col = (255,255,255) + + @classmethod + def set_fonts(cls, fonts={}): + """ + Set fonts to a specific font. If a font exists, it will be replaced, + otherwise it will be newly created. + + Args: + fonts: Dictionary containing fonts to use. + Key should be name of font. Value should be string + naming either custom FreeType or a system font. + + """ + for font in fonts: + if font not in cls._fonts: + cls._fonts[font] = _Font() + cls._fonts[font].replace(cls._create_font(fonts[font], 16)) + + if not cls._fonts["widget"]: + cls._fonts["widget"].replace(cls._create_font("Arial", 16)) + if not cls._fonts["title"]: + name = fonts["widget"] if ("widget" in fonts) else "Arial" + cls._fonts["title"].replace(cls._create_font(name, 30)) + if not cls._fonts["mono"]: + cls._fonts["mono"].replace(cls._create_font( + "FreeMono, Monospace", 16)) + + if SCREEN._opengl: + cls.mono_w = cls["mono"].font.Advance("e") + else: + cls.mono_w = cls["mono"].render("e", False, (0,0,0)).get_size()[0] + + @classmethod + def _create_font(cls, font, size): + """ + Returns the correct font object for FreeType or system font, and + for OpenGL or Pygame. + + """ + if font[-4:] in (".ttf", ".otf"): + if not SCREEN._opengl: + return pygame.font.Font(font, size) + else: + return opengl.OpenGLFont(font, size) + else: + if not SCREEN._opengl: + return pygame.font.SysFont(font, size) + else: + font = str(pygame.font.match_font(font)) + return opengl.OpenGLFont(font, size) + + + +# ----- WIDGET GROUPS ----- + +class Focus(pygame.sprite.GroupSingle): + + """ + Contains currently focused widget. + + """ + + def add(self, focus=0, *sprites): + """Extend add to call _focus_exit and _focus_enter methods.""" + if self.sprite: self.sprite._focus_exit() + pygame.sprite.GroupSingle.add(self, *sprites) + self.sprite._focus_enter(focus) + + def empty(self): + """Extend empty to call _focus_exit method.""" + if self.sprite: self.sprite._focus_exit() + pygame.sprite.GroupSingle.empty(self) + +# Widget groups +active_widgets = pygame.sprite.Group() +modal_widgets = pygame.sprite.OrderedUpdates() +layer_widgets = pygame.sprite.LayeredUpdates() +# The widget that currently has focus +focus = Focus() +# Order the widgets should receive focus through TAB +focus_order = [] + + + +# ----- WIDGET FUNCTIONS ----- + +def add_widget(widget, order): + """ + Add widget to screen. Used by the base widget. + + Returns: + True if widget has been added. False if already added. + + """ + added = False + # Add to group of active widgets + if widget not in active_widgets and not widget._layered: + active_widgets.add(widget) + added = True + if order is not None and widget._can_focus: + focus_order.append((order,widget)) + # Add to layered group + elif widget._layered and widget not in layer_widgets: + layer_widgets.add(widget) + added = True + # Add to group of modal widgets + if widget._modal and widget not in modal_widgets: + modal_widgets.add(widget) + added = True + + # Focus newly added modal widgets + if widget._modal: + focus.add(0, widget) + + return added + +def remove_widget_order(widget): + """Remove widget from focus order. Called by the base widget.""" + order = sum(focus_order,()) + if widget in order: + # Remove from focus_order + num = (order.index(widget)-1)/2 + del focus_order[num] + +def has_focus(widget): + """Checks if a widget currently has focus.""" + for group in widget.groups(): + if isinstance(group, Focus): + return True + return False + +def is_active(widget): + """Checks if widget is onscreen.""" + return widget in active_widgets or widget in layer_widgets + +def set_cursor(widget, size, hotspot, xormasks, andmasks): + """ + Sets a cursor and adds to a queue. + + Args: + widget: The widget that set the cursor, used as an ID in the queue. + size,hotspot,xormasks,andmasks: Arguments for pygame.mouse.set_cursor(). + + """ + if not cursors: + cursors.append((None, pygame.mouse.get_cursor())) + cursors.append((widget, (size, hotspot, xormasks, andmasks))) + pygame.mouse.set_cursor(size, hotspot, xormasks, andmasks) + +def remove_cursor(widget): + """ + Removes the cursor set by widget and sets cursor to whichever cursor + is now at the end of the queue. + + """ + for w, c in cursors: + if w == widget: + cursors.remove((w, c)) + pygame.mouse.set_cursor(*cursors[-1][1]) + if len(cursors) <= 1: + del cursors[:] diff --git a/sgc/widgets/base_widget.py b/sgc/widgets/base_widget.py new file mode 100644 index 0000000..c1b8ad0 --- /dev/null +++ b/sgc/widgets/base_widget.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python + +# Copyright (C) 2010-2012 Sam Bull + +""" +Base widget, all widgets inherit from this. + +""" + +import pygame +from pygame.locals import Rect + +from _locals import * +from _locals import (has_focus, is_active, add_widget, remove_widget_order, + set_cursor, remove_cursor) +try: + import opengl +except ImportError: + print "OpenGL widgets disabled." + +class Simple(pygame.sprite.Sprite): + + """ + Widget foundations all widgets should inherit from. + This can also be used as a simple widget that does nothing, such as + displaying an image. + + Attributes: + image: The current surface that will be drawn to the screen. + rect: The ``pygame.Rect`` used for the widget's position and size. + rect_abs: A ``pygame.Rect`` using the absolute screen position. + pos: The widget's position. Can be retrieved or assigned as a shortcut + for rect.topleft. Also a shortcut for setting pos through config(). + pos_abs: The widget's absolute screen position. + + """ + + # Widget settings + _can_focus = False + _modal = False + _layered = False # Layered updates for dialog windows etc. + _default_size = None + _parent = None + _available_images = () + _extra_images = {} + _settings_default = {} + + _fade = None # Alpha level when fading + _fade_up = True + _custom_image = False + + def __init__(self, surf=None, **kwargs): + """ + Args: + surf: The surface that should be drawn to screen, of type: + pygame.Surface: Use an existing surface. + tuple,list: Contains size as (width,height), creates a new surface. + str: Contains file name to load an image. + dict: Contains multiple images to be loaded. The documentation will + specify if a widget uses multiple images and what names to use. + kwargs: Any number of keyword arguments matching those for config(). + + """ + pygame.sprite.Sprite.__init__(self) + + # Implicitly pass attributes into _draw() + # Used to reduce complexity for widget developers + draw = self._draw + self._draw = lambda d=self.get_draw(): draw(d) + + # Initialise attributes + self._images = {} + self._available_images = ("image",) + self._available_images + self._settings = self._settings_default.copy() + self.rect = Rect((0,0), (0,0)) + + # Use default size if none specified + if surf is None: + surf = self._default_size + + # Create base surfaces if not None. + # If None, widget is expected to call this function later. + if surf is not None: + self._create_base_images(surf) + + self.config(init=None, **kwargs) + + + def config(self, **kwargs): + """ + Update widget configuration and redraw the widget. + + Keyword Args: + pos: ``tuple`` (x,y) Position to set widget to. + + """ + if "pos" in kwargs: + self.rect.topleft = kwargs["pos"] + self._config(**kwargs) + self._draw() + + def _config(self, **kwargs): + """Widgets should overload for custom widget configuration.""" + pass + + def add(self, order=None, fade=True): + """ + Add widget to screen. + + Args: + order: Integer representing the order widget should receive focus + when user presses TAB. The widget with the lowest order will + receive focus first, then moving up with increasing values. + fade: True if widget should fade in, False if not. + + """ + added = add_widget(self, order) + + # Fade widget in + if fade: + self._fade_up = True + if added and self._fade is None: self._fade = 1 + self.image.set_alpha(self._fade) + else: + self._fade = None + self.image.set_alpha(255) + + def remove(self, fade=True): + """ + Remove widget from screen. + + Args: + fade: True if widget should fade out. + + """ + if fade: # Fade widget out + self._fade_up = False + if self._fade == None: self._fade = 250 + else: # Remove widget immediately + self.kill() + remove_widget_order(self) + + def active(self): + """Return True if widget is active (onscreen).""" + return is_active(self) + + def has_focus(self): + """Return True if this widget has focus.""" + return has_focus(self) + + def get_draw(self): + """ + Return appropriate draw module for pygame or OpenGL. + + Use like: + ``draw = self.get_draw()`` + ``draw.rect(self.image, ...)`` + + """ + if not get_screen()._opengl: + return pygame.draw + else: + return opengl.draw + + def update(self, time): + """Placeholder for function that updates the widget per frame.""" + pass + + def _event(self, event): + """Placeholder for function that receives event for the widget.""" + pass + + def _draw(self, draw): + """Widgets should overload to draw default images.""" + pass + + def _create_base_images(self, surf, parent=None): + """ + Creates the base surfaces to draw on, or uses existing images. + + If self._default_size is None, widget is expected to call this + function manually when no size is given. + + """ + Image = opengl.OpenGLImage if get_screen()._opengl else pygame.Surface + def create_image(surf): + """Return a created surface.""" + if isinstance(surf, pygame.Surface): + surf.set_colorkey(0) + self._custom_image = True + return surf + elif isinstance(surf, (tuple,list)): + self._custom_image = False + if isinstance(surf[0], (tuple,list)): + surf = (self.rect.w * surf[0][0] + surf[0][1], + self.rect.h * surf[1][0] + surf[1][1]) + surf = Image(surf) + surf.set_colorkey(0) + return surf + elif isinstance(surf, str): + self._custom_image = True + return pygame.image.load(surf).convert_alpha() + + # Create base images + if isinstance(surf, dict): + for img in surf: + assert (img in self._available_images or + img in self._extra_images), "Incorrect image." + self._images[img] = create_image(surf[img]) + else: + self._images["image"] = create_image(surf) + + # Copy other images, if any have not been supplied. + assert "image" in self._images, "Must supply 'image'" + for count, name in enumerate(self._available_images): + if name not in self._images: + img = self._images[self._available_images[count-1]] + self._images[name] = img.copy() + + self.image = self._images["image"].copy() + self.rect.size = self.image.get_size() + + # Set up extra images + for name in self._extra_images: + if name not in self._images: + self._images[name] = create_image(self._extra_images[name]) + + def _change_focus(self, forward=True): + """ + Called when focus should be changed. Container widget + should override this function. + + Args: + forward: True if toggling focus forwards, False if backwards. + + Returns: + True if widget should change focus from this widget. + + """ + return True + + def _focus_enter(self, focus=0): + """ + Called when the widget gains focus. + + Args: + focus: 1 if focused by keyboard, 2 if by mouse. + + """ + pass + + def _focus_exit(self): + """Called when the widget loses focus.""" + pass + + def _dotted_rect(self, col=(255,255,255)): + """Draw a dotted rectangle to show keyboard focus.""" + self.image.lock() + for i in range(0, self.rect.w, 3): + # Draw horizontal lines + self.image.set_at((i, 0), col) + self.image.set_at((i, self.rect.h-1), col) + for i in range(0, self.rect.h, 2): + # Draw vertical lines + self.image.set_at((0, i), col) + self.image.set_at((self.rect.w-1, i), col) + self.image.unlock() + + def _set_cursor(self, size, hotspot, xormasks, andmasks): + set_cursor(self, size, hotspot, xormasks, andmasks) + + def _remove_cursor(self): + remove_cursor(self) + + + # --PROPERTIES-- + + @property + def rect_abs(self): + if self._parent is None: + return self.rect + else: + p_abs = self._parent.pos_abs + p = (self.rect.x + p_abs[0], self.rect.y + p_abs[1]) + return Rect(p, self.rect.size) + + @property + def pos(self): + return self.rect.topleft + @pos.setter + def pos(self, value): + self.rect.topleft = value + @property + def pos_abs(self): + if self._parent is None: + return self.rect.topleft + else: + p_abs = self._parent.pos_abs + return (self.rect.x + p_abs[0], self.rect.y + p_abs[1]) diff --git a/sgc/widgets/boxes.py b/sgc/widgets/boxes.py new file mode 100644 index 0000000..99187e3 --- /dev/null +++ b/sgc/widgets/boxes.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python + +# Copyright (C) 2011-2012 Sam Bull + +""" +Boxes are container widgets with automatic positioning/padding of widgets. + +""" + +from container import Container + +class VBox(Container): + + """ + VBox is a container widget which sorts widgets into a vertical + structure. + + If ``surf`` is not given, container will be the right size to fit all + widgets. + + """ + + _settings_default = {"border": 5, "spacing": 5, "col": 0, "widgets": None} + + def _config(self, **kwargs): + """ + widgets: ``list`` Contains widgets to pack into box. + The order of widgets in the list denotes order they are packed. + border: ``int`` Number of pixels to space around edges when ``surf`` + is not given. + col: ``tuple`` (r,g,b) Colour for background, 0 is transparent. + spacing: ``int`` Number of pixels to space between widgets. + + """ + if "spacing" in kwargs: + self._settings["spacing"] = kwargs["spacing"] + if "widgets" in kwargs: + pos = 0 + for w in kwargs["widgets"]: + w.pos = (0, pos) + pos += w.rect.h + self._settings["spacing"] + Container._config(self, **kwargs) + +class HBox(Container): + + """ + HBox is a container widget which sorts widgets into a horizontal + structure. + + If ``surf`` is not given, container will be the right size to fit all + widgets. + + """ + + _settings_default = {"border": 5, "spacing": 5, "col": 0, "widgets": None} + + def _config(self, **kwargs): + """ + widgets: ``list`` Contains widgets to pack into box. + The order of widgets in the list denotes order they are packed. + border: ``int`` Number of pixels to space around edges when ``surf`` + is not given. + col: ``tuple`` (r,g,b) Colour for background, 0 is transparent. + spacing: ``int`` Number of pixels to space between widgets. + + """ + if "spacing" in kwargs: + self._settings["spacing"] = kwargs["spacing"] + if "widgets" in kwargs: + pos = 0 + for w in kwargs["widgets"]: + w.pos = (pos, 0) + pos += w.rect.w + self._settings["spacing"] + Container._config(self, **kwargs) diff --git a/sgc/widgets/button.py b/sgc/widgets/button.py new file mode 100644 index 0000000..ef0e58a --- /dev/null +++ b/sgc/widgets/button.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python + +# Copyright (C) 2010-2012 Sam Bull + +""" +Button widget, allows input from the user clicking the button. + +""" + +import pygame +from pygame.locals import * + +from _locals import * +from base_widget import Simple + +class Button(Simple): + + """ + A clickable button. + + Images: + 'image': The default button state. + 'over': The image used when the cursor is hovering over the button. + 'down': The image used when the user is clicking down on the button. + + """ + + _can_focus = True + _default_size = (110, 50) + _available_images = ("over", "down") + _settings_default = {"label": ("",), "col": (127, 127, 169), + "label_col": Font.col} + + _state = None + _draw_rect = False + + def _config(self, **kwargs): + """ + label: ``str`` Text to display on the button. + col: ``tuple`` (r,g,b) The central colour used if no image is + provided. If you want to avoid the colours saturating keep the + RGB values below 200. + label_col: ``tuple`` (r,g,b) The text colour for the button's label. + + """ + # Label in middle of button + if "label" in kwargs: + # Save string as first argument + self._settings["label"] = [kwargs["label"]] + self._draw_label() + if "col" in kwargs: + self._settings["col"] = kwargs["col"] + if "label_col" in kwargs: + self._settings["label_col"] = kwargs["label_col"] + self._draw_label() + + def _draw_label(self): + # Clear previous renderings + del self._settings["label"][1:] + label = self._settings["label"][0].split("\n") + for count, line in enumerate(label): + lbl = Simple(Font["widget"].render(line, True, + self._settings["label_col"])) + self._settings["label"].append(lbl) + y = (self.rect.h - (lbl.rect.h * len(label))) / 2 + \ + (lbl.rect.h * count) + lbl.rect.midtop = (self.rect.w/2, y) + + def _draw(self, draw): + # Frames around edge of button + x = min(self.image.get_size()) / 8 + self._frame_lt = ((0,0), (self.rect.w,0), (self.rect.w-x,x), + (x,x), (x,self.rect.h-x), (0,self.rect.h)) + self._frame_rb = ((self.rect.w,self.rect.h), + (0,self.rect.h), (x,self.rect.h-x), + (self.rect.w-x,self.rect.h-x), + (self.rect.w-x,x), (self.rect.w,0)) + cols = {} + cols["image"] = self._settings["col"] + cols["over"] = [min(c*1.1, 255) for c in self._settings["col"]] + cols["down"] = [c*0.8 for c in self._settings["col"]] + for img in cols: + if not self._custom_image: + self._images[img].fill(cols[img]) + # Draw a frame around the edges of the button + frame_lt_c = [min(c*1.3,255) for c in cols[img]] + frame_rb_c = [c*0.8 for c in cols[img]] + draw.polygon(self._images[img], frame_lt_c, self._frame_lt) + draw.polygon(self._images[img], frame_rb_c, self._frame_rb) + # Blit label onto button + for line in self._settings["label"][1:]: + self._images[img].blit(line.image, line.pos) + self._draw_button() + + def activate(self): + """ + Called when the button is activated. + + Emits an event with attribute 'gui_type' == "activate". + + Override this function to use as a callback handler. + + """ + ev = pygame.event.Event(GUI, {"gui_type": "activate", + "widget_type": self.__class__, + "widget":self}) + pygame.event.post(ev) + + def update(self, time): + """Update the button each frame.""" + if self.rect_abs.collidepoint(pygame.mouse.get_pos()): + if self._state not in ("over","down"): + # Draw over state + self._state = "over" + self._draw_button() + elif self._state not in ("off","down"): + # Draw normal state + self._state = "off" + self._draw_button() + + def _event(self, event): + """Respond to events.""" + if event.type == MOUSEBUTTONDOWN and event.button == 1: + # Draw down state + self._state = "down" + self._draw_button() + elif event.type == MOUSEBUTTONUP and event.button == 1: + self._state = None + # If releasing mouse on button, call function + if self.rect_abs.collidepoint(event.pos): + self.activate() + elif event.type == KEYDOWN: + if event.key in (K_SPACE, K_RETURN): + self._state = "down" + self._draw_button() + self.activate() + elif event.type == KEYUP: + if event.key in (K_SPACE, K_RETURN): + self._state = None + + def _focus_enter(self, focus): + """Draw rectangle when focus is gained from keyboard.""" + if focus == 1: + self._draw_rect = True + self._draw_button() + + def _focus_exit(self): + """Stop drawing rectangle when focus is lost.""" + self._draw_rect = False + self._draw_button() + + def _draw_button(self): + """Draw the button.""" + if self._state == "off": + self.image = self._images["image"].copy() + elif self._state == "over": + self.image = self._images["over"].copy() + elif self._state == "down": + self.image = self._images["down"].copy() + # Draw dotted rectangle to show keyboard focus + if self._draw_rect: + self._dotted_rect() diff --git a/sgc/widgets/container.py b/sgc/widgets/container.py new file mode 100644 index 0000000..1a679e0 --- /dev/null +++ b/sgc/widgets/container.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python + +# Copyright (C) 2010-2012 Sam Bull + +""" +Container widget, can be inherited to implement more complex behaviour. + +""" + +import pygame.sprite +from pygame.locals import * + +from _locals import * +from _locals import Focus +from base_widget import Simple + +class Container(Simple): + + """ + Container widget. Handles focus and events of a group + of widgets packed into a single container. + + If ``surf`` is not given, container will be the right size to fit all + widgets. + + """ + + _can_focus = True + _settings_default = {"border": 0, "col": 0, "widgets": None} + + _focus = None + _order = None + + def _config(self, **kwargs): + """ + widgets: ``list`` Contains widgets to be added at creation time. + The order of widgets in the list denotes order they receive + focus when user hits :kbd:`TAB`. + border: ``int`` Number of pixels to space around edges when ``surf`` + is not given. + col: ``tuple`` (r,g,b) Colour for background, 0 is transparent. + + """ + if "border" in kwargs: + self._settings["border"] = kwargs["border"] + if "col" in kwargs: + self._settings["col"] = kwargs["col"] + if "widgets" in kwargs: + self._settings["widgets"] = pygame.sprite.Group() + self._focus = Focus() + self._order = [] + pad = self._settings["border"] + for w in kwargs["widgets"]: + w._parent = self + w.pos = (w.rect.x + pad, w.rect.y + pad) + self._settings["widgets"].add(w) + if w._can_focus: self._order.append(w) + if not hasattr(self, "image"): + width = max(kwargs["widgets"], key=lambda w: w.rect.right) + width = width.rect.right + pad + height = max(kwargs["widgets"], key=lambda w: w.rect.bottom) + height = height.rect.bottom + pad + self._create_base_images((width, height)) + + def update(self, time): + """Update widgets each frame.""" + self.image.fill(self._settings["col"]) + self._settings["widgets"].update(time) + for w in self._settings["widgets"]: + self.image.blit(w.image, w.pos) + + def _event(self, event): + """Handle focus and send events to sub-widgets.""" + if event.type == MOUSEBUTTONDOWN: + hit = False + for widget in self._settings["widgets"]: + # Check if user clicked a widget + if widget._can_focus: + if widget.rect_abs.collidepoint(event.pos): + self._focus.add(2, widget) + hit = True + break + # Lose focus if clicking away from widgets + if not hit: + self._focus.empty() + elif event.type == KEYDOWN and event.key == K_TAB: + # Focus number for current focused widget + if self._focus.sprite not in self._order: + curr_num = None + else: + curr_num = self._order.index(self._focus.sprite) + if not event.mod & KMOD_SHIFT: # Move focus to next widget + # Next focus number in the list + if curr_num is None: + # If nothing focused, focus first widget + new_num = 0 + elif not self._focus.sprite._change_focus(True): + # Test for container widgets + new_num = curr_num + elif curr_num >= len(self._order)-1: + new_num = 0 + else: + new_num = curr_num + 1 + else: # Shift key - move focus to previous widget + if curr_num is None: + new_num = -1 + elif not self._focus.sprite._change_focus(False): + new_num = curr_num + elif curr_num <= 0: + new_num = -1 + else: + new_num = curr_num - 1 + if curr_num != new_num: + self._focus.add(1, self._order[new_num]) + if self._focus: + self._focus.sprite._event(event) + + def _change_focus(self, forward=True): + """Override Simple and check if focus should leave yet.""" + if self._focus and not self._focus.sprite._change_focus(forward): + return False + if not self._focus: + return False + num = self._order.index(self._focus.sprite) + if forward and num < len(self._order)-1: + return False + if not forward and num > 0: + return False + return True + + def _focus_exit(self): + self._focus.empty() diff --git a/sgc/widgets/dialog.py b/sgc/widgets/dialog.py new file mode 100644 index 0000000..85d9083 --- /dev/null +++ b/sgc/widgets/dialog.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python + +# Copyright (C) 2010-2012 Sam Bull + +""" +Dialog window, creates a popup window. + +""" + +import pygame.mouse +from pygame.locals import * + +from _locals import * +from base_widget import Simple + +class Dialog(Simple): + + """ + Dialog Window + + If ``surf`` is not given, window will be large enough to fit the + given widget. + + """ + + _can_focus = True + _modal = True + _layered = True + _settings_default = {"title": None, "widget": None, "col_bg": (240,240,240), + "col_border": (50,40,90)} + + _drag = _over = False + + def _config(self, **kwargs): + """ + widget: Widget that should be displayed in the dialog window. + title: ``str`` Text to display in the title bar. + col_border: ``tuple`` (r,g,b) Window decoration colour. + col_bg: ``tuple`` (r,g,b) Background colour. + modal: ``bool`` ``True`` if window should be modal. + Defaults to ``True``. + + """ + if "widget" in kwargs: + self._settings["widget"] = kwargs["widget"] + self._settings["widget"]._parent = self + self._settings["widget"].pos = (2, 20) + if not hasattr(self, "image"): + r = self._settings["widget"].rect + self._create_base_images((r.w + 4, r.h + 22)) + if "title" in kwargs: + self._settings["title"] = kwargs["title"] + if "col_border" in kwargs: + self._settings["col_border"] = kwargs["col_border"] + if "col_bg" in kwargs: + self._settings["col_bg"] = kwargs["col_bg"] + if "modal" in kwargs: + self._modal = kwargs["modal"] + + def _draw(self, draw): + # Draw window + inner_rect = Rect((2,20), (self.rect.w-4,self.rect.h-22)) + self._images["image"].fill(self._settings["col_border"]) + self._images["image"].fill(self._settings["col_bg"], inner_rect) + if self._settings["title"]: + t = Simple(Font["widget"].render( + self._settings["title"], True, Font.col), pos = (22,0)) + self._images["image"].blit(t.image, t.pos) + # Close button + self._close_off = Simple((16,16), parent=self) + self._close_off.image.fill(self._settings["col_border"]) + draw.circle(self._close_off.image, (140,6,15), (8,8), 8) + draw.line(self._close_off.image, (0,0,1), (5,5), (11,11), 3) + draw.line(self._close_off.image, (0,0,1), (5,11), (11,5), 3) + self._close_over = Simple((16,16), parent=self) + self._close_over.image.fill(self._settings["col_border"]) + draw.circle(self._close_over.image, (234,14,50), (8,8), 8) + draw.line(self._close_over.image, (0,0,1), (5,5), (11,11), 5) + draw.line(self._close_over.image, (0,0,1), (5,11), (11,5), 5) + self._close_off.pos = self._close_over.pos = (1,1) + + self.image = self._images["image"].copy() + self.image.blit(self._close_off.image, self._close_off.pos) + + def update(self, time): + """Update dialog window each frame.""" + r = self._close_off.rect_abs + if not self._over and r.collidepoint(pygame.mouse.get_pos()): + # Display over button + self.image = self._images["image"].copy() + self.image.blit(self._close_over.image, self._close_over.pos) + self._over = True + elif self._over and not r.collidepoint(pygame.mouse.get_pos()): + # Display normal button + self.image = self._images["image"].copy() + self.image.blit(self._close_off.image, self._close_off.pos) + self._over = False + + self._settings["widget"].update(time) + self.image.blit(self._settings["widget"].image, + self._settings["widget"].pos) + + def _event(self, event): + """Respond to events.""" + minus_pos = lambda p1, p2: (p1[0] - p2[0], p1[1] - p2[1]) + + if event.type == MOUSEBUTTONDOWN and event.button == 1 and \ + self.rect.collidepoint(event.pos) and event.pos[1] < self.rect.y + 20: + # Clicking title bar of window + if self._close_off.rect_abs.collidepoint(event.pos): + # Close button + self.remove() + else: + # Initialise window drag + self._offset = minus_pos(event.pos, self.pos) + self._drag = True + elif event.type == MOUSEMOTION and self._drag: + # Move window + self.pos = minus_pos(event.pos, self._offset) + elif event.type == MOUSEBUTTONUP and event.button == 1 and self._drag: + # Stop moving window + self.pos = minus_pos(event.pos, self._offset) + self._drag = False + else: + self._settings["widget"]._event(event) diff --git a/sgc/widgets/fps_counter.py b/sgc/widgets/fps_counter.py new file mode 100644 index 0000000..7492ab0 --- /dev/null +++ b/sgc/widgets/fps_counter.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python + +# Copyright (C) 2010-2012 Sam Bull + +""" +FPS counter, display current FPS performance to the user. + +""" + +from _locals import * +from base_widget import Simple + +class FPSCounter(Simple): + + """ + FPS counter + + """ + + _default_size = (80, 30) + _settings_default = {"label": "", "clock": None} + + def _config(self, **kwargs): + """ + clock: ``pygame.time.Clock`` Clock used to time the game loop. + label: ``str`` Text to display in front of the value. + + """ + if "clock" in kwargs: + self._settings["clock"] = kwargs["clock"] + if "label" in kwargs: + self._settings["label"] = kwargs["label"] + + def toggle(self): + """Toggle the FPS counter, adding or removing this widget.""" + if self.active(): + if self._fade is not None: + if self._fade_up: + self.remove() + else: + self.add() + else: + self.remove() + else: + self.add() + + def update(self, time): + """Update counter each frame.""" + text = Simple(Font["widget"].render( + self._settings["label"] + + str(round(self._settings["clock"].get_fps(), 1)), + True, Font.col)) + text.rect.center = (self.rect.w/2, self.rect.h/2) + self.image.fill(0) + self.image.blit(text.image, text.pos) diff --git a/sgc/widgets/input_box.py b/sgc/widgets/input_box.py new file mode 100644 index 0000000..624de6f --- /dev/null +++ b/sgc/widgets/input_box.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python + +# Copyright (C) 2010-2012 Sam Bull + +""" +Input Box for receiving text input. + +""" + +import pygame +from pygame.locals import * + +from _locals import * +from base_widget import Simple + +class InputBox(Simple): + + """ + Input box + + Attributes: + text: Text entered in input box. Can be set or retrieved directly. + + Images: + 'image': The background of the input box when focused. + 'inactive': The background of the input box when not focused. + + """ + + _can_focus = True # Override Simple + _default_size = (240, 30) + _available_images = ("inactive",) + _settings_default = {"label": "", "default": "", "blink_interval": 600, + "col_selection": (118, 45, 215), + "col_focus": (255,255,255), + "col_focus_not": (200,200,200), "max_chars": 80, + "repeat_begin": 300, "repeat_interval": 30} + + _blink_time = 0 + _blink = True + __cursor_pos = 0 + _r = None # Rect for position and size of input box + _repeat_key = None + _repeat_time = 0 + _select = None # Starting point of selection + _offset = 6 # Offset to render text in the input box + + def _config(self, **kwargs): + """ + default: ``str`` Contains the default text displayed when nothing + has been entered and input box does not have focus. + label: ``str`` Contains text to be rendered to left of widget. + blink_interval: ``int`` Milliseconds between cursor blink. + col_focus: ``tuple`` (r,g,b) Background colour when focused. + col_focus_not: ``tuple`` (r,g,b) Background colour when not focused. + col_selection: ``tuple`` (r,g,b) Colour of selection rectangle. + max_chars: ``int`` Maximum number of characters. + repeat_begin: ``int`` Milliseconds key is held down before repeating. + repeat_interval: ``int`` Milliseconds between key repeats. + text: ``str`` Set the text entered in input box. + + """ + if "init" in kwargs: + self._text = [] + if "default" in kwargs: + self._settings["default"] = kwargs["default"] + if "label" in kwargs: + self._settings["label"] = kwargs["label"] + if hasattr(self, "_label"): + self._images["image"].fill(0, self._label.rect) + # Label to left of input box + self._label = Simple(Font["widget"].render(self._settings["label"], + True, Font.col)) + self._label.rect.centery = self.rect.h/2 + # Update input rect + self._r = Rect((self._label.rect.w+5, 0), + (self.rect.w-(self._label.rect.w+5), self.rect.h)) + if "blink_interval" in kwargs: + self._settings["blink_interval"] = kwargs["blink_interval"] + if "col_focus" in kwargs: + self._settings["col_focus"] = kwargs["col_focus"] + if "col_focus_not" in kwargs: + self._settings["col_focus_not"] = kwargs["col_focus_not"] + if "col_selection" in kwargs: + self._settings["col_selection"] = kwargs["col_selection"] + if "max_chars" in kwargs: + self._settings["max_chars"] = kwargs["max_chars"] + if "repeat_begin" in kwargs: + self._settings["repeat_begin"] = kwargs["repeat_begin"] + if "repeat_interval" in kwargs: + self._settings["repeat_interval"] = kwargs["repeat_interval"] + if "text" in kwargs: + self._text = [unicode(char) for char in kwargs["text"]] + + def _draw(self, draw): + # Active state background + self._images["image"].fill(self._settings["col_focus"], self._r) + draw.rect(self._images["image"], (0,0,1), self._r, 4) + self._images["image"].blit(self._label.image, self._label.pos) + + # Inactive state background + self._images["inactive"].fill(self._settings["col_focus_not"], self._r) + draw.rect(self._images["inactive"], (0,0,1), self._r, 4) + self._images["inactive"].blit(self._label.image, self._label.pos) + + # Draw image in non-focus state + self._focus_exit() + + # Store the input text as a list + @property + def text(self): + return "".join(self._text) + @text.setter + def text(self, txt): + self._text = [unicode(char) for char in txt] + # Re-evaluate cursor position. + self._cursor_pos = self._cursor_pos + + def activate(self): + """ + Called when the user hits the enter key. + + Emits an event with attribute 'gui_type' == "activate". + + Override this function to use as a callback handler. + + """ + ev = pygame.event.Event(GUI, {"gui_type": "activate", + "widget_type": self.__class__, + "widget": self}) + pygame.event.post(ev) + + def update(self, time): + """Update the input box each frame.""" + if self.has_focus(): + draw = self.get_draw() + # Repeat key if held down + if self._repeat_key: + self._repeat_time += time + while self._repeat_time > self._settings["repeat_begin"]: + self._repeat_time -= self._settings["repeat_interval"] + self._event(self._repeat_key) + # Draw input box + text = Font["mono"].render(self.text, True, (0,0,0)) + y = (self._r.h - text.get_height()) / 2 + self.image = self._images["image"].copy() + area = ((6-self._offset,0), (self._r.w-8, self._r.h)) + self.image.blit(text, (self._r.x+6, y), area) + # If enough time has passed, blink cursor + self._blink_time += time + if self._blink_time > self._settings["blink_interval"]: + self._blink_time -= self._settings["blink_interval"] + self._blink = not self._blink + # Draw cursor in box + if self._blink and self._select is None: + x = self._cursor_pos*Font.mono_w + self._r.x + self._offset + draw.line(self.image, (0,0,1), (x, 6), (x, self._r.h-6)) + # Draw selection highlighting + if self._select is not None: + select = self._select_fix() + # Semi-transparent selection rectangle + w = ((select[1]*Font.mono_w + self._offset) - + max(4, (select[0]*Font.mono_w + self._offset))) + selection = Simple((w, self._r.h - 11)) + selection.pos = (self._r.x + select[0] * Font.mono_w + + self._offset, 6) + selection.image.fill(self._settings["col_selection"]) + selection.image.set_alpha(100) + # Border around selection rectangle + selection_b = Simple((selection.rect.w+2, selection.rect.h+2)) + draw.rect(selection_b.image, self._settings["col_selection"], + selection_b.rect, 1) + pos = (max(self._r.x+4, selection.rect.x), selection.rect.y) + self.image.blit(selection.image, pos) + self.image.blit(selection_b.image, (pos[0]-1, pos[1]-1)) + + def _event(self, event): + """Update text field based on input.""" + if event.type == KEYDOWN: + # Reset cursor blink when typing + self._blink_time = 0 + self._blink = True + # Save last key press for repeat + if self._repeat_key != event: + self._repeat_key = event + self._repeat_time = 0 + if event.key in (9,): # Keys to ignore + pass + elif event.key == K_ESCAPE: + self._select = None + elif event.key == K_RETURN: + self.activate() + elif event.key == K_BACKSPACE: + if self._select is not None: + self._delete_selection() + elif self._cursor_pos > 0: + self._cursor_pos -= 1 + self._text.pop(self._cursor_pos) + elif event.key == K_DELETE: + if self._select is not None: + self._delete_selection() + elif self._cursor_pos < len(self._text): + self._text.pop(self._cursor_pos) + elif event.key == K_LEFT: + if not event.mod & KMOD_SHIFT: + self._select = None # Break selection + elif self._select is None: + # Reset selection if not selecting + self._select = self._cursor_pos + self._cursor_pos -= 1 + # Remove selection when cursor is at same position + if self._select == self._cursor_pos: + self._select = None + elif event.key == K_RIGHT: + if not event.mod & KMOD_SHIFT: + self._select = None # Break selection + elif self._select is None: + self._select = self._cursor_pos + self._cursor_pos += 1 + if self._select == self._cursor_pos: + self._select = None + elif event.unicode: + if event.mod & KMOD_CTRL: + if event.key == K_a: # Select all + self._select = 0 + self._cursor_pos = len(self._text) + elif event.key == K_c and self._select is not None: # Copy + select = self._select_fix() + string = "".join(self._text[select[0]:select[1]]) + try: + pygame.scrap.put(SCRAP_TEXT, string) + except pygame.error: + print "Please run 'pygame.scrap.init()'" \ + " to use the clipboard." + elif event.key == K_v: # Paste + text = pygame.scrap.get(SCRAP_TEXT) + if text: + if self._select is not None: + self._delete_selection() + # Get list of text to insert into input_text + text = [unicode(char) for char in text] + self._text[self._cursor_pos:self._cursor_pos] = text + self._cursor_pos += len(text) + elif event.key == K_x and self._select is not None: # Cut + select = self._select_fix() + string = "".join(self._text[select[0]:select[1]]) + try: + pygame.scrap.put(SCRAP_TEXT, string) + except pygame.error: + print "Please run 'pygame.scrap.init()'" \ + " to use the clipboard" + self._delete_selection() + else: + # Delete selection + if self._select is not None: + self._delete_selection() + # Insert new character + if len(self._text) < self._settings["max_chars"]: + self._text.insert(self._cursor_pos, event.unicode) + self._cursor_pos += 1 + elif event.type == KEYUP: + if self._repeat_key and self._repeat_key.key == event.key: + self._repeat_key = None # Stop repeat + elif event.type == MOUSEBUTTONDOWN: + # Begin drawing selection + if pygame.key.get_mods() & KMOD_SHIFT and self._select is None: + self._select = self._cursor_pos + self._cursor_pos = self._mouse_cursor(event.pos) + if not pygame.key.get_mods() & KMOD_SHIFT: + self._select = self._cursor_pos + elif event.type == MOUSEMOTION and event.buttons[0]: + # Continue drawing selection while mouse held down + self._cursor_pos = self._mouse_cursor(event.pos) + elif event.type == MOUSEBUTTONUP: + # Set cursor position with mouse click + self._cursor_pos = self._mouse_cursor(event.pos) + if self._select == self._cursor_pos: + self._select = None + + def _focus_exit(self): + """Draw non-focused input box when focus is lost.""" + self.image = self._images["inactive"].copy() + if self._text: # Blit input text into box... + text = Simple(Font["mono"].render(self.text, True, (70,70,70))) + else: # ...or default text if empty. + text = Simple(Font["mono"].render(self._settings["default"], True, + (70,70,70))) + text.rect.midleft = (self._r.x + self._offset, self._r.h / 2) + self.image.blit(text.image, text.pos) + # Stop repeat key + self._repeat_key = None + + def _mouse_cursor(self, mouse_pos): + """Return the text cursor position of the mouse.""" + pos = mouse_pos[0] - self.rect_abs.x - self._offset - self._r.x + pos = int(round(float(pos) / Font.mono_w)) + return max(min(pos, len(self._text)), 0) + + def _select_fix(self): + """If selection is right-to-left then reverse positions.""" + if self._select > self._cursor_pos: + return (self._cursor_pos, self._select) + else: + return (self._select, self._cursor_pos) + + def _delete_selection(self): + """Delete the current selection of text.""" + select = self._select_fix() + del self._text[select[0]:select[1]] + self._select = None + self._cursor_pos = select[0] + + @property + def _cursor_pos(self): + return self.__cursor_pos + @_cursor_pos.setter + def _cursor_pos(self, value): + # Keep cursor position within text + self.__cursor_pos = min(max(value, 0), len(self._text)) + # Scroll text in input box when it's too long + pos = self._cursor_pos * Font.mono_w + if pos > (self._r.w - self._offset): + self._offset = -(pos - self._r.w + 6) + elif pos < (6 - self._offset): + self._offset = 6 - pos diff --git a/sgc/widgets/label.py b/sgc/widgets/label.py new file mode 100644 index 0000000..a7a4863 --- /dev/null +++ b/sgc/widgets/label.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python + +# Copyright (C) 2010-2012 Sam Bull, Michael Rochester + +""" +Label to display information to the user. + +""" + +import pygame.mouse +from pygame.locals import * + +from _locals import * +from base_widget import Simple + +class Label(Simple): + + """ + Label + + Attributes: + text: ``str`` displayed in label. Can be assigned as a shortcut for + ``config(label=)`` with no second paramenter. + """ + + _settings_default = {"text": "", "col": Font.col, "font": Font["widget"], + "col_selection": (118, 45, 215)} + + _over = False + _select = None # Starting point of selection + __cursor_pos = 0 + + def _config(self, **kwargs): + """ + text: Either ``str`` containing text to be displayed or + ``tuple`` containing two strings. First string is text to + be displayed, second string is rect attribute to be used + for position. Defaults to 'topleft' if not passing a tuple. + col: ``tuple`` (r,g,b) Text colour. + font: Font object the label will render with. + selectable: ``bool`` True if the text should be selectable. + col_selection: ``tuple`` (r,g,b) Colour of selection rectangle. + + """ + if "init" in kwargs: + strings = pygame.cursors.textmarker_strings + cursor = pygame.cursors.compile(strings) + size = (len(strings[0]), len(strings)) + hotspot = (size[0]/2, size[1]/2) + self._cursor = (size, hotspot) + cursor + if "text" in kwargs: + if isinstance(kwargs["text"], str): + self._settings["text"] = kwargs["text"] + else: + self._settings["text"] = kwargs["text"][0] + self._temp_pos = kwargs["text"][1] + if "col" in kwargs: + self._settings["col"] = kwargs["col"] + if "font" in kwargs: + self._settings["font"] = kwargs["font"] + if "selectable" in kwargs: + self._can_focus = kwargs["selectable"] + if "col_selection" in kwargs: + self._settings["col_selection"] = kwargs["col_selection"] + + def _draw(self, draw): + if hasattr(self, "_temp_pos"): + pos = getattr(self.rect, self._temp_pos) + + # Split into lines + text = [] + for line in self._settings["text"].split("\n"): + text.append(self._settings["font"].render(line, True, + self._settings["col"])) + + # Dynamically set size + h = 0 + for line in text: + h += line.get_size()[1] + w = max(text, key=lambda x: x.get_size()[0]) + self._create_base_images((w.get_size()[0], h)) + + # Blit each line + y = 0 + for line in text: + self._images["image"].blit(line, (0,y)) + y += line.get_size()[1] + + # Copy position attribute over + if hasattr(self, "_temp_pos"): + setattr(self.rect, self._temp_pos, pos) + del self._temp_pos + + self.image = self._images["image"].copy() + + # Store as tuple of (pos, width) tuples. + if self._can_focus: + chars = [] + p = 0 + for c in range(1, len(self._settings["text"])+1): + char = self._settings["font"].render(self._settings["text"][:c], + True, (0,0,0)) + chars.append((p, char.get_size()[0] - p)) + p = char.get_size()[0] + chars.append((p, 0)) + self._chars = tuple(chars) + + def _event(self, event): + if event.type == MOUSEBUTTONDOWN and event.button == 1: + # Begin drawing selection + if pygame.key.get_mods() & KMOD_SHIFT and self._select is None: + self._select = self._cursor_pos + self._cursor_pos = self._mouse_cursor(event.pos) + if not pygame.key.get_mods() & KMOD_SHIFT: + self._select = self._cursor_pos + elif event.type == MOUSEMOTION and event.buttons[0]: + # Continue drawing selection while mouse held down + self._cursor_pos = self._mouse_cursor(event.pos) + elif event.type == MOUSEBUTTONUP: + # Set cursor position with mouse click + self._cursor_pos = self._mouse_cursor(event.pos) + if self._select == self._cursor_pos: + self._select = None + elif event.type == KEYDOWN: + if event.key == K_ESCAPE: + self._select = None + elif event.key == K_LEFT: + if not event.mod & KMOD_SHIFT: + self._select = None # Break selection + elif self._select is None: + # Reset selection if not selecting + self._select = self._cursor_pos + self._cursor_pos -= 1 + # Remove selection when cursor is at same position + if self._select == self._cursor_pos: + self._select = None + elif event.key == K_RIGHT: + if not event.mod & KMOD_SHIFT: + self._select = None # Break selection + elif self._select is None: + self._select = self._cursor_pos + self._cursor_pos += 1 + if self._select == self._cursor_pos: + self._select = None + elif event.mod & KMOD_CTRL: + if event.key == K_a: # Select all + self._select = 0 + self._cursor_pos = len(self._settings["text"]) + elif event.key == K_c and self._select is not None: # Copy + select = self._select_fix() + string = "".join( + self._settings["text"][select[0]:select[1]]) + try: + pygame.scrap.put(SCRAP_TEXT, string) + except pygame.error: + print "Please run 'pygame.scrap.init()'" \ + " to use the clipboard." + + def update(self, time): + if self._can_focus: + # Change cursor when mouse not held down + if not pygame.mouse.get_pressed()[0]: + if not self._over and \ + self.rect_abs.collidepoint(pygame.mouse.get_pos()): + self._over = True + self._set_cursor(*self._cursor) + elif self._over and \ + not self.rect_abs.collidepoint(pygame.mouse.get_pos()): + self._over = False + self._remove_cursor() + if self.has_focus(): + self.image = self._images["image"].copy() + draw = self.get_draw() + if self._select is None: + # Draw cursor in box + x = self._chars[self._cursor_pos][0] - 1 + draw.line(self.image, (0,0,1), (x, 2), (x, self.rect.h-2)) + else: + select = self._select_fix() + # Semi-transparent selection rectangle + w = (self._chars[select[1]][0] - self._chars[select[0]][0]) + selection = Simple((w, self.rect.h - 2)) + selection.pos = (self._chars[select[0]][0], 1) + selection.image.fill(self._settings["col_selection"]) + selection.image.set_alpha(100) + # Border around selection rectangle + selection_b = Simple((selection.rect.w+2, + selection.rect.h+2)) + draw.rect(selection_b.image, + self._settings["col_selection"], + selection_b.rect, 1) + self.image.blit(selection.image, selection.pos) + self.image.blit(selection_b.image, (selection.rect.x-1, + selection.rect.y-1)) + + @property + def text(self): + return self._settings["text"] + @text.setter + def text(self, value): + self._settings["text"] = value + self._draw() + + def _mouse_cursor(self, mouse_pos): + """Return the text cursor position of the mouse.""" + pos = mouse_pos[0] - self.rect_abs.x + for index, (p,w) in enumerate(self._chars): + if pos <= p + w/2: + break + return index + + def _select_fix(self): + """If selection is right-to-left then reverse positions.""" + if self._select > self._cursor_pos: + return (self._cursor_pos, self._select) + else: + return (self._select, self._cursor_pos) + + def _focus_exit(self): + """Cancel any selection when focus is lost.""" + self.image = self._images["image"].copy() + + @property + def _cursor_pos(self): + return self.__cursor_pos + @_cursor_pos.setter + def _cursor_pos(self, value): + # Keep cursor position within text + self.__cursor_pos = min(max(value, 0), len(self._settings["text"])) diff --git a/sgc/widgets/menu.py b/sgc/widgets/menu.py new file mode 100644 index 0000000..608f4b2 --- /dev/null +++ b/sgc/widgets/menu.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python + +# Copyright (C) 2010-2012 Sam Bull + +""" +Menu widget. Creates a menu for a game. + +""" + +from _locals import * +from boxes import VBox +from scroll_box import ScrollBox +from . import * + +class Menu(Simple): + + """ + Menu + + Can be indexed to access widgets by name. + + Attributes: + func_dict: Assign a lambda to return a dictionary of functions for + config file to utilise. + + """ + + _modal = True + _layered = True + _settings_default = {"offset": (100, 50), "col": (0,0,1), "apply": False} + + _menus = [] + _dict = {} # Dict for indexing widgets + _old_menu = None + _curr_menu = 0 + func_dict = lambda self: {} + + def _config(self, **kwargs): + """ + menu: Either tuple containing menu data, to be documented. + Or file object to read config data from, in the same format. + apply: TODO ``bool`` True if an apply button should be added. + col: ``tuple`` (r,g,b), Colour used for the background. + offset: ``tuple`` (x,y) Contains position of menu widgets. y is + added to bottom of title. + + """ + if "apply" in kwargs: + self._settings["apply"] = kwargs["apply"] + if "col" in kwargs: + self._settings["col"] = kwargs["col"] + if "offset" in kwargs: + self._settings["offset"] = kwargs["offset"] + if "menu" in kwargs: + self._menus = [] # Remove previous menus + self._dict = {} + # Grab function dictionary + self._funcs = self.func_dict() + + # Set size to screen size + if not hasattr(self, "image"): + self._create_base_images(get_screen().rect.size) + + menu = kwargs["menu"] + # If file, read config tuple + if isinstance(menu, file): + menu_data = "".join(menu.readlines()) + menu = eval(menu_data) + assert isinstance(menu, tuple) + + menu_data = [(menu, None)] # (data, parent) + # Create each submenu, by iterating through the menu data + while menu_data: + self._menus.append(_SubMenu(self.rect.size, + col=self._settings["col"])) + self._config_menu(menu_data, self._menus[-1]) + + def _config_menu(self, data_queue, menu): + """ + Configure the passed in menu, using the information from the first + item in data_queue. + + New sub-menus discovered in the data will be appended to the data_queue + for later processing. + + """ + data, parent = data_queue.pop(0) + widgets = [] + # Parse menu data + for item in data: + # Title + if isinstance(item, str): + menu._title = Simple(Font["title"].render(item[2:], True, + Font.col)) + menu._title.rect.midtop = (self.rect.centerx, 40) + # Sub-menu + elif item[0].startswith("m:"): + data_queue.append((item, len(self._menus)-1)) + surf = Font["widget"].render(item[0][2:], True, Font.col) + widgets.append(Button(surf)) + # Change menu on button activate + num = len(self._menus)-1 + len(data_queue) + widgets[-1].activate = lambda n=num: self.change_menu(n) + # Category/divider + elif item[0].startswith("c:"): + div = Simple(Font["widget"].render(item[0][2:], True, + Font.col)) + self.get_draw().line(div.image, Font.col, (0, div.rect.h-1), + (div.rect.w, div.rect.h-1)) + widgets.append(div) + # Widget + elif item[0].startswith("w:"): + args = self._get_args(item[1:]) + name = args.pop("name") + f = args.pop("func") if ("func" in args) else None + if item[0].endswith("input_box"): + widget = input_box.InputBox(**args) + elif item[0].endswith("button"): + for key in args: + if key == "surf": + args[key] = eval(args[key]) + widget = button.Button(**args) + elif item[0].endswith("label"): + widget = label.Label(**args) + self._dict[name] = widget + if f: widget.activate = self._funcs[f] + widgets.append(widget) + # Function + elif item[0].startswith("f:"): + surf = Font["widget"].render(item[1], True, Font.col) + widgets.append(button.Button(surf=surf)) + widgets[-1].activate = self._funcs[item[0][2:]] + + # Draw a back menu item + if parent is not None: + surf = Font["widget"].render("Back", True, Font.col) + widgets.append(button.Button(surf=surf)) + widgets[-1].activate = lambda n=parent: self.change_menu(n) + + menu._widgets = tuple(widgets) + + def _draw(self, draw): + for menu in self._menus: + # Pack all widgets into a VBox + box = VBox(widgets=menu._widgets, spacing=15) + pos = (self._settings["offset"][0], + self._settings["offset"][1] + menu._title.rect.bottom) + box = ScrollBox((self.rect.w - pos[0], self.rect.h - pos[1]), + widget=box, pos=pos) + menu.config(col=self._settings["col"], menu=box) + + def change_menu(self, menu_num): + """ + Change the currently displayed menu. + + Args: + menu_num: ``int`` The number representing the menu. + + """ + self._old_menu = self._curr_menu + self._curr_menu = menu_num + self._menus[self._curr_menu]._fade = 0 + + def update(self, time): + menu = self._menus[self._curr_menu] + menu.update(time) + if self._old_menu is not None: + self.image.blit(self._menus[self._old_menu].image, (0,0)) + menu.image.set_alpha(menu._fade) + menu._fade += time / 3. + if menu._fade >= 255: + menu._fade = None + self._old_menu = None + menu.image.set_alpha(255) + self.image.blit(menu.image, (0,0)) + + def _event(self, event): + self._menus[self._curr_menu]._event(event) + + def _get_args(self, args): + """Get the arguments passed in, saving them into a dictionary.""" + return {arg.split("=")[0]: arg.split("=")[1] for arg in args} + + def __getitem__(self, key): + """Return widgets by name.""" + return self._dict[key] + +class _SubMenu(Simple): + + """ + A single menu object to be created and managed by the Menu class. + + """ + _settings_default = {"col": (0,0,1), "menu": None} + + _title = None + _widgets = () + + def _config(self, **kwargs): + if "col" in kwargs: + self._settings["col"] = kwargs["col"] + if "menu" in kwargs: + self._settings["menu"] = kwargs["menu"] + + def _draw(self, draw): + self._images["image"].fill(self._settings["col"]) + if self._title: + self._images["image"].blit(self._title.image, self._title.pos) + + def update(self, time): + self.image = self._images["image"].copy() + self._settings["menu"].update(time) + self.image.blit(self._settings["menu"].image,self._settings["menu"].pos) + + def _event(self, event): + """Send events to container.""" + self._settings["menu"]._event(event) diff --git a/sgc/widgets/opengl.py b/sgc/widgets/opengl.py new file mode 100644 index 0000000..c5ae17e --- /dev/null +++ b/sgc/widgets/opengl.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python + +# Copyright (C) 2011 Sam Bull + +""" +Base widget, all widgets inherit from this. + +""" + +from pygame.locals import Rect +from OpenGL.GL import * +import FTGL + +from ..surface import SurfaceBase + + +class OpenGLImage(SurfaceBase): + """ + Class used to emulate the interface of Surface for OpenGL drawing. + + Functions should be used in the same manner as pygame.Surface. + Differences are shown in documentation, otherwise assume it to + function the same as the equivalent pygame.Surface function. + + """ + def __init__(self, surf, parent=None, **kwargs): + self._a = None + self._lock = False + self.display_list = [] + self._children = [] + if parent: + self._parent = parent # Parent surface used for _abs + else: + self._parent = self._default_screen + + self._rect = Rect((0,0), (0,0)) + if isinstance(surf, (tuple,list)): + self._rect.size = surf + elif isinstance(surf, OpenGLFont): + self._rect.size = (surf.size) + self.display_list.extend(surf.display_list) + elif isinstance(surf, OpenGLImage): + self._rect = surf._rect + self._a = surf._a + self.display_list = surf.display_list + self._children = surf._children + + def blit(self, surf, pos=None): + assert isinstance(surf, OpenGLImage) + if surf not in self._children: + if pos is not None: surf.pos = pos + surf._parent = self + self._children.append(surf) + + def draw(self): + glLoadIdentity() + if self.rect.w <= self._parent.rect.w and \ + self.rect.h <= self._parent.rect.h: + # Mask the area, so nothing is drawn outside surface area. + bottom = self._default_screen.h - self.rect_abs.bottom + glScissor(self.rect_abs.x, bottom, + self.rect.w+1, self.rect.h+1) + glTranslatef(self.pos_abs[0], self.pos_abs[1], 0) + for dl,col in self.display_list: + if col is not None: glColor(col[0], col[1], col[2], self.a) + glCallList(dl) + for child in self._children: + child.draw() + + def fill(self, col=0, rect=None, special_flags=0): + """If col == 0 and rect is None, clears image with no fill.""" + if rect is None: + # Clear the surface + self.display_list = [] + self._children = [] + rect = Rect((0,0), self.size) + if col != 0: + col = [c/255. for c in col] + dl = glGenLists(1) + glNewList(dl, GL_COMPILE) + glRectfv(rect.topleft, rect.bottomright) + glEndList() + self.display_list.append((dl, col)) + + def copy(self): + return OpenGLImage(self) + + def get_size(self): + return self.size + + def set_alpha(self, alpha): + self._a = alpha/255. + + def set_at(self, vertex, col): + col = [c/255. for c in col] + + if self._lock: + glColor(*col) + else: + dl = glGenLists(1) + glNewList(dl, GL_COMPILE) + glBegin(GL_POINTS) + + glVertex(vertex) + + if not self._lock: + glEnd() + glEndList() + surf.display_list.append((dl, col)) + + def lock(self): + """ + Should only be used for multiple set_at() calls. + Should not be used in conjuction with the draw functions. + + """ + self._lock = True + self._lock_dl = glGenLists(1) + glNewList(self._lock_dl, GL_COMPILE) + glBegin(GL_POINTS) + + def unlock(self): + glEnd() + glEndList() + self.display_list.append((self._lock_dl, None)) + del self._lock_dl + self._lock = False + + def replace(self, surf, **kwargs): + self._rect.size = surf.size + self.display_list = surf.display_list[:] + self._children = surf._children[:] + + @property + def a(self): + if self._a is not None: + return self._a + else: + return self._parent.a + + # --- Dummy methods. Ignore. --- + + def __call__(self): + return self + + def set_colorkey(self, col): + pass + +class OpenGLFont(): + """ + Wraps the FTGL.TextureFont to allow it to be added + to an OpenGLImage object in the same manner as pygame.Font. + + """ + def __init__(self, font, size): + self._children = [] + self.font = FTGL.TextureFont(font) + self.font.FaceSize(16) + self.y_offset = self.font.line_height * .75 + + def render(self, text, antialias, color, background=None): + text = text.encode() + col = [c/255. for c in color] + dl = glGenLists(1) + self.size = (self.font.Advance(text), self.font.line_height) + glNewList(dl, GL_COMPILE) + glPushMatrix() + # Flip text right way up + glMultMatrixf((1, 0, 0, 0, + 0,-1, 0, 0, + 0, 0, 1, 0, + 0, self.y_offset, 0, 1)) + self.font.Render(text) + glPopMatrix() + glEndList() + self.display_list = [(dl, col)] + return self + +class Draw(): + """ + Class to emulate the pygame.draw module. + Functions should work in the same manner. + + """ + def rect(self, surf, col, rect, width=0): + col = [c/255. for c in col] + dl = glGenLists(1) + glNewList(dl, GL_COMPILE) + if not width: + glRectfv(rect.topleft, rect.bottomright) + else: + if width > 1: width -= 1 + hw = width/2. + glLineWidth(width) + glBegin(GL_LINES) + glVertex(rect.x, rect.y + hw) + glVertex(rect.right - width, rect.y + hw) + # (hw%1) fixes line rendering off by a pixel + glVertex(rect.right - hw, rect.y + (hw%1)) + glVertex(rect.right - hw, rect.bottom - width + (hw%1)) + glVertex(rect.right, rect.bottom - hw) + glVertex(rect.x + width, rect.bottom - hw) + glVertex(rect.x + hw, rect.bottom) + glVertex(rect.x + hw, rect.y + width) + glEnd() + glEndList() + surf.display_list.append((dl, col)) + + def polygon(self, surf, col, pointlist, width=0): + """ + With width == 0, can only draw convex polygons. Be careful of + the order of vertices if it draws differently than pygame. + + """ + col = [c/255. for c in col] + dl = glGenLists(1) + glNewList(dl, GL_COMPILE) + if not width: + glBegin(GL_POLYGON) + for v in pointlist: + glVertex(v) + glEnd() + glEndList() + surf.display_list.append((dl, col)) + + def circle(self, surf, col, pos, radius, width=0): + col = [c/255. for c in col] + dl = glGenLists(1) + glNewList(dl, GL_COMPILE) + if not width: + glEnable(GL_POINT_SMOOTH) + glHint(GL_POINT_SMOOTH_HINT, GL_NICEST) + glPointSize(radius*2) + glBegin(GL_POINTS) + glVertex(pos) + glEnd() + glDisable(GL_POINT_SMOOTH) + glPointSize() + glEndList() + surf.display_list.append((dl, col)) + + def line(self, surf, col, start_pos, end_pos, width=1): + col = [c/255. for c in col] + dl = glGenLists(1) + glNewList(dl, GL_COMPILE) + glLineWidth(width) + glBegin(GL_LINES) + glVertex(start_pos) + glVertex(end_pos) + glEnd() + glEndList() + surf.display_list.append((dl, col)) + +# Export Draw functions +draw = Draw() diff --git a/sgc/widgets/radio_button.py b/sgc/widgets/radio_button.py new file mode 100644 index 0000000..ed56db9 --- /dev/null +++ b/sgc/widgets/radio_button.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python + +# Copyright (C) 2012 Michael Rochester, Sam Bull + +""" +Radio Button, allows the user to select a single option from a group. + +""" + +import pygame.mouse +from pygame.locals import * + +from _locals import * +from _locals import focus +from base_widget import Simple + +class Radio(Simple): + + """ + A selectable radio button. + + Attributes: + groups: A dictionary containing the active radio button or ``None`` for + each radio group. Key is ``str`` containing the name of the group. + selected: True if widget is the currently selected radio button in + it's group. + + Images: + 'image': The default, inactive button state. + 'over': The image used when the cursor is hovering over the button. + 'active': The image used for the active button in a group + (if applicable). + + """ + + _can_focus = True + _available_images = ("over", "active") + _settings_default = {"group": None, "label": "", "col": (118, 45, 215), + "label_col": Font.col, "radius": 7} + + _over_state = False + _draw_rect = False + + groups = {} + _order = {} + + def _config(self, **kwargs): + """ + group: ``str`` Name of the group for widget to be added to. + label: ``str`` Text to be displayed to the right of the widget. + active: ``True`` Makes this the active radio button for it's group. + col: ``tuple`` (r,g,b) The colour to be used for the 'over' image + if not using a custom image. + label_col: ``tuple`` (r,g,b) The colour for the label text. + radius: ``int`` Radius of the button if not using a custom image. + + """ + if "group" in kwargs: + if kwargs["group"] not in self.groups: + self.groups[kwargs["group"]] = None + self._order[kwargs["group"]] = [] + self._settings["group"] = kwargs["group"] + self._order[self._settings["group"]].append(self) + if "label" in kwargs: + self._settings["label"] = kwargs["label"] + if "col" in kwargs: + self._settings["col"] = kwargs["col"] + if "label_col" in kwargs: + self._settings["label_col"] = kwargs["label_col"] + if "radius" in kwargs: + self._settings["radius"] = kwargs["radius"] + assert self._settings["group"] is not None + if "active" in kwargs: + self._draw() + self._activate() + + def _draw(self, draw): + r = self._settings["radius"] + # Render text + label = Simple(Font["widget"].render(self._settings["label"], True, + self._settings["label_col"])) + if not hasattr(self, "image"): + self._create_base_images((r*2 + 10 + label.rect.w, + max(label.rect.height, r*2))) + + pos = (r, self.rect.h/2) + # Background circles + draw.circle(self._images["image"], (255,255,255), pos, r) + draw.circle(self._images["over"], self._settings["col"], pos, r) + # Border circles + draw.circle(self._images["image"], (0,0,1), pos, r, 1) + draw.circle(self._images["over"], (0,0,1), pos, r, 1) + # Central dot for 'active' state + draw.circle(self._images["active"],(0,0,1), pos, int(r/1.5)) + + label.rect.midleft = (r*2 + 10, pos[1]) + self._images["image"].blit(label.image, label.pos) + self._images["over"].blit(label.image, label.pos) + self._draw_button() + + def update(self, time): + """Update the radio button each frame.""" + if self.rect_abs.collidepoint(pygame.mouse.get_pos()): + if not self._over_state: + # Draw over state + self._over_state = True + self._draw_button() + elif self._over_state: + # Draw normal state + self._over_state = False + self._draw_button() + + def _event(self, event): + if event.type == MOUSEBUTTONUP and event.button == 1: + if self.rect_abs.collidepoint(event.pos): + self._activate() + elif event.type == KEYDOWN: + def focus_change(diff): + next_widget = order[order.index(widget) + diff] + next_widget._activate() + if self._parent: + self._parent._focus.add(1, next_widget) + else: + focus.add(1, next_widget) + order = self._order[self._settings["group"]] + widget = self.groups[self._settings["group"]] + if event.key == K_UP and order.index(widget) > 0: + focus_change(-1) + elif event.key == K_DOWN and order.index(widget) < len(order)-1: + focus_change(1) + elif event.type == KEYUP: + if event.key in (K_SPACE, K_RETURN): + self._activate() + + def _focus_enter(self, focus): + """Draw rectangle when focus is gained from keyboard.""" + if focus == 1: + self._draw_rect = True + self._draw_button() + + def _focus_exit(self): + """Stop drawing rectangle when focus is lost.""" + self._draw_rect = False + self._draw_button() + + def _draw_button(self): + """Draw the button.""" + if not self._over_state: + self.image = self._images["image"].copy() + else: + self.image = self._images["over"].copy() + if self.groups[self._settings["group"]] is self: + self.image.blit(self._images["active"], (0,0)) + # Draw dotted rectangle to show keyboard focus + if self._draw_rect: + self._dotted_rect() + + def _activate(self): + """Reset drawing of new and previous widget.""" + old = self.groups[self._settings["group"]] + self.groups[self._settings["group"]] = self + if old is not None: old._draw_button() + self._draw_button() + + def clear(self, group=None): + """ + Clear a group so no radio button is selected. + + Args: + group: ``str`` Group name to clear. Clear this widget's group if None. + + """ + if group is None: group = self._settings["group"] + old = self.groups[group] + self.groups[group] = None + if old is not None: old._draw_button() + + @property + def selected(self): + return self is self.groups[self._settings["group"]] diff --git a/sgc/widgets/scroll_box.py b/sgc/widgets/scroll_box.py new file mode 100644 index 0000000..e28ef51 --- /dev/null +++ b/sgc/widgets/scroll_box.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python + +# Copyright (C) 2011-2012 Sam Bull + +""" +Scroll box. A container widget that provides scroll bars to be able to +view a larger widget. + +BUG: Scrolling gives focus. Can't scroll without focus. +BUG: Scroll bar pops up over a modal widget. +BUG: Scroll bars don't work in a modal widget. + +""" + +import pygame.mouse +from pygame.locals import * + +from _locals import * +from base_widget import Simple + +class ScrollBox(Simple): + + """ + Scroll Box + + """ + + _can_focus = True + _default_size = (300, 200) + _settings_default = {"widget": None, "col": (118, 45, 215)} + + _scroll_x = _scroll_y = None + _handle_x = _handle_y = None + + def _config(self, **kwargs): + """ + widget: Widget that should be displayed in scroll box. + col: ``tuple`` (r,g,b) Colour used for scroll bars and handles. + + """ + if "widget" in kwargs: + self._settings["widget"] = kwargs["widget"] + self._settings["widget"]._parent = self + self._settings["widget"].pos = (0,0) + if "col" in kwargs: + self._settings["col"] = kwargs["col"] + + def _draw(self, draw): + # Create scroll bars and handles + if self._settings["widget"].rect.w > self.rect.w: + ratio = float(self.rect.w) / self._settings["widget"].rect.w + self._scroll_x = Simple((self.rect.w * ratio, 3)) + self._scroll_x._parent = self + self._scroll_x.image.fill(self._settings["col"]) + self._scroll_x.pos = (0, self.rect.h - 3) + self._handle_x = _ScrollHandleH(widget=self) + if self._settings["widget"].rect.h > self.rect.h: + ratio = float(self.rect.h) / self._settings["widget"].rect.h + self._scroll_y = Simple((3, self.rect.h * ratio)) + self._scroll_y._parent = self + self._scroll_y.image.fill(self._settings["col"]) + self._scroll_y.pos = (self.rect.w - 3, 0) + self._handle_y = _ScrollHandleV(widget=self) + + def update(self, time): + """Update scroll box each frame.""" + self._settings["widget"].update(time) + + self.image.fill(0) + self.image.blit(self._settings["widget"].image, + self._settings["widget"].pos) + + pos = pygame.mouse.get_pos() + if self._scroll_y is not None: + self.image.blit(self._scroll_y.image, self._scroll_y.pos) + r = self._scroll_y.rect_abs + # Add scroll handles when cursor moves near scroll bar + if not self._handle_y.active() and \ + r.inflate(20, 5).collidepoint(pos): + # Position to left if handle would be off-screen. + edge = (r.right + self._handle_y.rect.w) + if edge < get_screen().rect.w: + self._handle_y.rect.x = r.right + else: + self._handle_y.rect.right = r.left + self._handle_y.update_pos(pos[1]) + self._handle_y.add() + + if self._scroll_x is not None: + self.image.blit(self._scroll_x.image, self._scroll_x.pos) + r = self._scroll_x.rect_abs + if not self._handle_x.active() and \ + r.inflate(5, 20).collidepoint(pos): + edge = (r.bottom + self._handle_x.rect.h) + if edge < get_screen().rect.h: + self._handle_x.rect.y = r.bottom + else: + self._handle_x.rect.bottom = r.top + self._handle_x.update_pos(pos[0]) + self._handle_x.add() + + def _event(self, event): + """Respond to events.""" + self._settings["widget"]._event(event) + if event.type == MOUSEBUTTONDOWN: + if event.button == 4: # Scroll up + self.scroll(y=-10) + elif event.button == 5: # Scroll down + self.scroll(y=10) + elif event.button == 6: # Scroll left + self.scroll(x=-10) + elif event.button == 7: # Scroll right + self.scroll(x=10) + + def scroll(self, x=None, y=None): + """Scroll by x and y coordinates.""" + if x is not None and self._scroll_x is not None: + # Set scroll bar position + r = self._scroll_x.rect + r.x = max(min(r.x + x, self.rect.w - r.w), 0) + # Set widget's position + ratio = r.x / float(self.rect.w - r.w) + max_w = self._settings["widget"].rect.w - self.rect.w + self._settings["widget"].rect.x = -max_w * ratio + if y is not None and self._scroll_y is not None: + r = self._scroll_y.rect + r.y = max(min(r.y + y, self.rect.h - r.h), 0) + ratio = r.y / float(self.rect.h - r.h) + max_h = self._settings["widget"].rect.h - self.rect.h + self._settings["widget"].rect.y = -max_h * ratio + + def _change_focus(self, forward=True): + return self._settings["widget"]._change_focus(forward) + + def _focus_exit(self): + self._settings["widget"]._focus_exit() + + + +class _ScrollHandle(Simple): + + """ + Scroll bar to manipulate scroll box. + + To be inherited from by _ScrollHandle[V/H], not to be used directly. + + Uses lots of getattr() and other tricks to provide inheritable functions. + + """ + + _can_focus = True + _layered = True + _settings_default = {"widget": None} + + _drag = None + + def _config(self, **kwargs): + """ + widget: Scroll box that this handle should be synced to. + + """ + if "init" in kwargs: + self._rect2 = self.rect_abs.inflate(20, 20) + if "widget" in kwargs: + self._settings["widget"] = kwargs["widget"] + + def _draw(self, draw): + self.image.fill(self._settings["widget"]._settings["col"]) + self.image.fill((200,200,200), self.rect.inflate(-4, -4)) + # Draw line in center + r = self.rect + start_pos = (3, r.centery) if self.xy == "y" else (r.centerx, 3) + end_pos = (r.w-4, r.centery) if self.xy == "y" else (r.centerx, r.h-4) + draw.line(self.image, (100,100,100), start_pos, end_pos) + # Draw arrows + if self.xy == "y": + points1 = ((3, r.h/4), (r.centerx, r.h/5-1), (r.w-3, r.h/4)) + points2 = ((3, r.h*.75), (r.centerx, r.h*.8), (r.w-3, r.h*.75)) + else: + points1 = ((r.w/4, 3), (r.w/5-1, r.centery), (r.w/4, r.h-3)) + points2 = ((r.w*.75, 3), (r.w*.8, r.centery), (r.w*.75, r.h-3)) + draw.polygon(self.image, (50,50,50), points1) + draw.polygon(self.image, (50,50,50), points2) + + def update_pos(self, xy): + """ + Change position of scroll handle. + + Args: + xy: Integer to move the scroll handle to, along the correct axis. + + """ + scroll_bar = getattr(self._settings["widget"], "_scroll_%s" % self.xy) + if scroll_bar is not None: + r = scroll_bar.rect_abs + a,b = (r.bottom, r.top) if self.xy == "y" else (r.right, r.left) + xy = min(a, max(xy, b)) + setattr(self.rect, "center%s" % self.xy, xy) + self._rect2.center = self.rect.center + + def update(self, time): + # Move handle to cursor when cursor not hovering over. + if not self.rect.collidepoint(pygame.mouse.get_pos()): + self.update_pos(pygame.mouse.get_pos()[0 if self.xy == "x" else 1]) + # Hide handle when cursor moves too far. + if self._drag is None and \ + not self._rect2.collidepoint(pygame.mouse.get_pos()): + self.remove() + + def _event(self, event): + index = 1 if self.xy == "y" else 0 + if event.type == MOUSEBUTTONDOWN and event.button == 1 and \ + self.rect.collidepoint(event.pos): + # Initialise drag + center = getattr(self.rect_abs, "center%s" % self.xy) + self._offset = event.pos[index] - center + self._drag = event.pos[index] + elif self._drag is not None: + if event.type == MOUSEMOTION: + # Move scroll handle and bar + self.update_pos(event.pos[index] - self._offset) + kwarg = {self.xy: event.rel[index]} + self._settings["widget"].scroll(**kwarg) + elif event.type == MOUSEBUTTONUP and event.button == 1: + # Move scroll box up when clicked + if -5 < (self._drag - event.pos[index]) < 5: + center = getattr(self.rect_abs, "center%s" % self.xy) + if event.pos[index] < center: + kwarg = {self.xy: -40} + self._settings["widget"].scroll(**kwarg) + else: + kwarg = {self.xy: 40} + self._settings["widget"].scroll(**kwarg) + # Or stop moving and set final position after drag + else: + self.update_pos(event.pos[index] - self._offset) + self._drag = None + +class _ScrollHandleV(_ScrollHandle): + _default_size = (12,50) + xy = "y" + +class _ScrollHandleH(_ScrollHandle): + _default_size = (50,12) + xy = "x" diff --git a/sgc/widgets/settings.py b/sgc/widgets/settings.py new file mode 100644 index 0000000..7e4ee3a --- /dev/null +++ b/sgc/widgets/settings.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python + +# Copyright (C) 2010 Sam Bull + +""" +Settings for games, these include: +CONTROLS + Keymap + Mouse Sensitivity (speed) TODO +DISPLAY + Resolution (width, height) TODO + Fullscreen (bool) TODO + +""" + +import pygame +from pygame.locals import * + +from ..locals import * +from _locals import * +from base_widget import Simple + + +class Keys(Simple): + + _can_focus = True # Override Simple + + """ + Screen used to change keymap settings. + + Keys is a special widget that will fill the screen and take + over the game loop for maximum effiencency. + + """ + + def __init__(self, keymap_file, parent=None, **kwargs): + """ + Extend Simple and prepare the key order. + + keymap_file -- String containing filename containing keymap. + Key order should be on second line. + parent,kwargs -- Pass through to Simple + + """ + size = self._default_screen.size + Simple.__init__(self, size, parent, **kwargs) + # Load key order + with open(keymap_file) as f: + f.readline() + self._key_order = eval(f.readline()) + assert isinstance(self._key_order, list) + + def add(self): + """ + Display the settings for the keymap to the player. + + """ + # Display title + message = Surface(font_title.render("Keymap Settings", + True, font_col)) + message.y = 30 + message.x = (self._parent.w - message.w)/2 + self._parent().blit(message(), message.pos) + # Display settings + positions = {} + temp_y = 100 + row = 0 + for key in self._key_order: + if temp_y > (self._parent.h - message.h*2): + row += 1 + temp_y = 100 + # Render name + message = Surface(font_widget.render(key.title(), + True, font_col)) + message.y = temp_y + message.x = 30 + ((self._parent.w-30)/3) * row + self._parent().blit(message(), message.pos) + # Render keymap + message = Surface(font_widget.render( + pygame.key.name(keymap[key]), + True, font_col)) + message.y = temp_y + message.x = ((((self._parent.w-30)/3) * (row+1) - 30) - + message.w/2) + self._parent().blit(message(), message.pos) + positions[key] = message + temp_y += message.h*2 + + # Event loop + keypress_wait = False + while True: + event = pygame.event.wait() + if event.type == QUIT: + exit() #TODO exit to menu + + elif event.type == MOUSEBUTTONDOWN and not keypress_wait: + # If clicking a key, then prepare to change keymap + for key in positions: + if pygame.mouse.get_pos()[0] >= positions[key].x and \ + pygame.mouse.get_pos()[1] >= positions[key].y and \ + pygame.mouse.get_pos()[0] <= positions[key].x + \ + positions[key].w and \ + pygame.mouse.get_pos()[1] <= positions[key].y + \ + positions[key].h: + + # Replace key with 'press key...' message + self._parent().fill((0,0,0), positions[key].rect) + message = Surface(font_widget.render( + "press key...", True, font_col)) + message.y = positions[key].y + message.x = positions[key].x - \ + (message.w - positions[key].w)/2 + positions[key] = message + self._parent().blit(message(), message.pos) + keypress_wait = key + + elif event.type == KEYDOWN and keypress_wait: + # When waiting for new key, replace text with new key + if event.key != K_ESCAPE: + keymap[keypress_wait] = event.key + self._parent().fill((0,0,0), positions[keypress_wait].rect) + message = Surface(font_widget.render( + pygame.key.name(keymap[keypress_wait]), + True, font_col)) + message.y = positions[keypress_wait].y + message.x = positions[keypress_wait].x - \ + (message.w - positions[keypress_wait].w)/2 + positions[keypress_wait] = message + self._parent().blit(message(), message.pos) + keypress_wait = None + pygame.display.update() diff --git a/sgc/widgets/toggle.py b/sgc/widgets/toggle.py new file mode 100644 index 0000000..3a77549 --- /dev/null +++ b/sgc/widgets/toggle.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python + +# Copyright (C) 2012 Michael Rochester, Sam Bull + +""" +Toggle button, allows the user to change a boolean setting. + +""" + +import pygame +from pygame.locals import * + +from _locals import * +from _locals import focus +from base_widget import Simple + +class Toggle(Simple): + + """ + A toggle button, allowing the user to select between two states. + + Attributes: + state: True if toggle button is switched on. + + Images: + 'image': The background when the button is set to off. + 'active': The background when the button is set to on. + 'handle': The image used for the slider. + + """ + + _can_focus = True + _default_size = (130,30) + _available_images = ("active", "handle") + _settings_default = {"state": False, "label": "", "label_col": Font.col, + "on_col": (88, 158, 232), "off_col": (191, 191, 186), + "on_label_col": (255,255,255), + "off_label_col": (93,82,80)} + + _draw_rect = False + _drag = None + _handle_rect = None + + def _config(self, **kwargs): + """ + state: ``bool`` Sets the state of the button (False by default). + label: ``str`` Text to be displayed next to the button. + label_col: ``tuple`` (r,g,b) The colour for the label. + on_col: ``tuple`` (r,g,b) The background colour when the button is + set to the 'on' state. + off_col: ``tuple`` (r,g,b) The background colour when the button is + set to the 'off' state. + on_label_col: ``tuple`` (r,g,b) The on/off text colour when the + button is set to the 'on' state. + off_label_col: ``tuple`` (r,g,b) The on/off text colour when the + button is set to the 'off' state. + + """ + if "init" in kwargs: + self._handle_rect = Rect(0,0,0,0) + if "state" in kwargs: + self._settings["state"] = kwargs["state"] + if "label" in kwargs: + self._settings["label"] = kwargs["label"] + if "label_col" in kwargs: + self._settings["label_col"] = kwargs["label_col"] + if "on_col" in kwargs: + self._settings["on_col"] = kwargs["on_col"] + if "off_col" in kwargs: + self._settings["off_col"] = kwargs["off_col"] + if "on_label_col" in kwargs: + self._settings["on_label_col"] = kwargs["on_label_col"] + if "off_label_col" in kwargs: + self._settings["off_label_col"] = kwargs["off_label_col"] + + def _draw(self, draw): + label = Simple(Font["widget"].render(self._settings["label"], True, + self._settings["label_col"])) + label.rect.centery = self.rect.h/2 + + # Calculate widget and handle rects + self.box = Rect((label.rect.w + 10, 0), + (self.rect.w - label.rect.w - 10, self.rect.h)) + self._handle_rect.size = ((self.box.w/2) - 4, self.rect.h - 4) + + # Draw handle + draw.rect(self._images["handle"], (245,245,244), + ((0,2), self._handle_rect.size)) + for x in range(2,5): # Grips + draw.line(self._images["handle"], (232,232,229), + ((self._handle_rect.w/6)*x, 10), + ((self._handle_rect.w/6)*x, self.rect.h-10), 3) + + # Draw main images + for img in ("off", "on"): + image = "image" if (img == "off") else "active" + draw.rect(self._images[image], self._settings[img+"_col"], self.box) + + # Render the labels + col = self._settings[img+"_label_col"] + on = Simple(Font["widget"].render("ON", True, col)) + off = Simple(Font["widget"].render("OFF", True, col)) + on.rect.center = (label.rect.w + 10 + self.box.w*.25 - 1, + self.rect.h/2) + off.rect.center = (label.rect.w + 10 + self.box.w*.75 + 1, + self.rect.h/2) + + # Blit all text + self._images[image].blit(label.image, label.pos) + self._images[image].blit(on.image, on.pos) + self._images[image].blit(off.image, off.pos) + + self._draw_button() + + def _event(self, event): + if event.type == MOUSEBUTTONDOWN and event.button == 1: + # If clicking handle + if self._handle_rect.collidepoint( + (event.pos[0]-self.pos_abs[0], + event.pos[1]-self.pos_abs[1])): + self._drag = (event.pos[0], event.pos[0] - self._handle_rect.x) + elif event.type == MOUSEMOTION and event.buttons[0]: + if self._drag is not None: + # Move handle + self._handle_rect.x = max(min(self.box.centerx + 3, + event.pos[0] - self._drag[1]), + self.box.x + 2) + self._draw_button() + elif event.type == MOUSEBUTTONUP and event.button == 1: + if self._drag is not None: + if abs(self._drag[0] - event.pos[0]) < 5: # Clicked + self._settings["state"] = not self._settings["state"] + else: # Dragged + # Determine if dropped in on/off position + if self._handle_rect.centerx < self.box.centerx: + self._settings["state"] = False + else: + self._settings["state"] = True + self._drag = None + self._draw_button() + elif self.rect_abs.collidepoint(event.pos): + # Clicked outside of handle + self._settings["state"] = not self._settings["state"] + self._draw_button() + elif event.type == KEYUP: + if event.key in (K_RETURN, K_SPACE): + self._settings["state"] = not self._settings["state"] + self._draw_button() + + def _focus_enter(self, focus): + """Draw dotted rect when focus is gained from keyboard.""" + if focus == 1: + self._draw_rect = True + self._draw_button() + + def _focus_exit(self): + """Stop drawing dotted rect when focus is lost.""" + self._draw_rect = False + self._draw_button() + + def _draw_button(self): + """Render the widget and blit the handle in the correct place.""" + if self._settings["state"] is False: + self.image = self._images["image"].copy() + else: + self.image = self._images["active"].copy() + + if self._drag is None: + # Fix handle in place when not dragging + if self._settings["state"] is False: + self._handle_rect.x = self.box.x + 2 + else: + self._handle_rect.x = self.box.centerx + 3 + + self.image.blit(self._images["handle"], self._handle_rect) + + # Draw dotted rectangle to show keyboard focus + if self._draw_rect: + self._dotted_rect() + + @property + def state(self): + return self._settings["state"]