summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid Thompson <dthompson2@worcester.edu>2021-04-16 08:45:34 -0400
committerDavid Thompson <dthompson2@worcester.edu>2021-04-16 08:45:34 -0400
commit960336af94a4e438de99f2893160735fed55f532 (patch)
tree9ace6690949f1ffbe1a12e5e7bf377ccbd3745b4
parentb229ca95ce29eb9b70dc10ac79536788f0d90f9d (diff)
Add beginnings of a graphical user interface widget module.
-rw-r--r--Makefile.am1
-rw-r--r--starling/gui.scm485
2 files changed, 486 insertions, 0 deletions
diff --git a/Makefile.am b/Makefile.am
index aa89d2c..22c997f 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -50,6 +50,7 @@ SOURCES = \
starling/minibuffer.scm \
starling/kernel.scm \
starling/node-2d.scm \
+ starling/gui.scm \
starling/transition.scm
EXTRA_DIST += \
diff --git a/starling/gui.scm b/starling/gui.scm
new file mode 100644
index 0000000..614dfc0
--- /dev/null
+++ b/starling/gui.scm
@@ -0,0 +1,485 @@
+;;; Starling Game Engine
+;;; Copyright © 2019 David Thompson <davet@gnu.org>
+;;;
+;;; This program is free software: you can redistribute it and/or
+;;; modify it under the terms of the GNU General Public License as
+;;; published by the Free Software Foundation, either version 3 of the
+;;; License, or (at your option) any later version.
+;;;
+;;; This program is distributed in the hope that it will be useful,
+;;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+;;; General Public License for more details.
+;;;
+;;; You should have received a copy of the GNU General Public License
+;;; along with Starling. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+;;
+;; 2D Graphical User Interface
+;;
+;;; Code:
+
+(define-module (starling gui)
+ #:use-module (chickadee config)
+ #:use-module (chickadee graphics color)
+ #:use-module (chickadee graphics font)
+ #:use-module (chickadee graphics texture)
+ #:use-module (chickadee math vector)
+ #:use-module (ice-9 match)
+ #:use-module (oop goops)
+ #:use-module (srfi srfi-1)
+ #:use-module (starling asset)
+ #:use-module (starling config)
+ #:use-module (starling node)
+ #:use-module (starling node-2d)
+ #:use-module (starling scene)
+ #:export (<theme>
+ settings
+ theme-ref
+ define-theme
+ current-theme
+ <widget>
+ focused?
+ hover?
+ left-pressed?
+ right-pressed?
+ middle-pressed?
+ add-listener
+ remove-listener
+ notify
+ mouse-enter
+ mouse-exit
+ mouse-press
+ mouse-click
+ <gui-scene>
+ <container>
+ <horizontal-container>
+ <vertical-container>
+ <margin-container>
+ left-margin
+ right-margin
+ bottom-margin
+ top-margin
+ <button>
+ text))
+
+
+;;;
+;;; Meta Widget
+;;;
+
+(define-class <meta-widget>
+ ((if developer-mode? <meta-node> <developer-meta-node>))
+ (themeables #:allocation #:class #:init-thunk make-hash-table))
+
+(define (themeables)
+ (class-slot-ref <meta-widget> 'themeables))
+
+(define-method (themeable-slot? (slot <slot>))
+ (get-keyword #:themeable? (slot-definition-options slot)))
+
+(define-method (compute-slots (class <meta-widget>))
+ (fold (lambda (slot memo)
+ (cons (if (themeable-slot? slot)
+ (let ((name (slot-definition-name slot))
+ (default (if (slot-definition-init-thunk slot)
+ ((slot-definition-init-thunk slot))
+ (slot-definition-init-value slot))))
+ (apply make (class-of slot)
+ #:allocation #:virtual
+ #:slot-ref (lambda (obj)
+ (theme-ref (theme obj) class name default))
+ #:slot-set! (lambda (obj v) #t)
+ (let loop ((options (slot-definition-options slot)))
+ (match options
+ (() '())
+ (((or #:allocation #:slot-ref #:slot-set!) _ . rest)
+ (loop rest))
+ ((kw arg . rest)
+ (cons* kw arg (loop rest)))))))
+ slot)
+ memo))
+ '()
+ (next-method)))
+
+(define-method (initialize (class <meta-widget>) initargs)
+ (next-method)
+ (hashq-set! (themeables)
+ class
+ (fold (lambda (slot memo)
+ (if (themeable-slot? slot)
+ (cons (slot-definition-name slot) memo)
+ memo))
+ '()
+ (class-slots class))))
+
+
+;;;
+;;; Base Widget
+;;;
+
+(define-asset default-font
+ (load-font (scope-datadir "fonts/Inconsolata-Regular.otf") 12))
+
+(define current-theme (make-parameter #f))
+
+(define-class <widget> (<node-2d>)
+ (focused? #:accessor focused? #:init-value #f #:watch? #t)
+ (hover? #:accessor hover? #:init-value #f #:watch? #t)
+ (left-pressed? #:accessor left-pressed? #:init-value #f #:watch? #t)
+ (right-pressed? #:accessor right-pressed? #:init-value #f #:watch? #t)
+ (middle-pressed? #:accessor middle-pressed? #:init-value #f #:watch? #t)
+ (listeners #:getter listeners #:init-thunk make-hash-table)
+ (theme #:accessor theme #:init-keyword #:theme #:init-thunk current-theme
+ #:watch? #t)
+ (background #:getter background #:init-value #f #:themeable? #t)
+ (font #:getter font #:init-value default-font #:themeable? #t)
+ #:metaclass <meta-widget>)
+
+(define-method (initialize (widget <widget>) initargs)
+ (next-method)
+ (for-each (match-lambda
+ ((name . proc)
+ (add-listener widget name proc)))
+ (get-keyword #:listeners initargs '())))
+
+(define-method (add-listener (widget <widget>) name proc)
+ (let ((t (listeners widget)))
+ (hashq-set! t name (cons proc (hashq-ref t name '())))))
+
+(define-method (remove-listener (widget <widget>) name proc)
+ (let ((t (listeners widget)))
+ (hashq-set! t name (delq proc (hashq-ref t name '())))))
+
+(define-method (notify (widget <widget>) event-name . args)
+ (let loop ((procs (hashq-ref (listeners widget) event-name '())))
+ (match procs
+ (()
+ ;; No listener handled this event, send event to parent widget.
+ (let ((p (parent widget)))
+ (and p (is-a? p <widget>) (apply notify p event-name args))))
+ ((proc . rest)
+ ;; If an event handler returns a value other than #f, we
+ ;; consider the event to be handled and stop.
+ (or (apply proc widget args)
+ (loop rest))))))
+
+(define-method (mouse-press (widget <widget>) button)
+ (match button
+ ('left (set! (left-pressed? widget) #t))
+ ('right (set! (right-pressed? widget) #t))
+ ('middle (set! (middle-pressed? widget) #t)))
+ (notify widget 'press button))
+
+(define-method (mouse-click (widget <widget>) button)
+ (match button
+ ('left (set! (left-pressed? widget) #f))
+ ('right (set! (right-pressed? widget) #f))
+ ('middle (set! (middle-pressed? widget) #f)))
+ (notify widget 'click button))
+
+(define-method (mouse-enter (widget <widget>) buttons)
+ (for-each (match-lambda
+ ('left (set! (left-pressed? widget) #t))
+ ('right (set! (right-pressed? widget) #t))
+ ('middle (set! (middle-pressed? widget) #t)))
+ buttons)
+ (set! (hover? widget) #t)
+ (notify widget 'enter))
+
+(define-method (mouse-exit (widget <widget>))
+ (set! (left-pressed? widget) #f)
+ (set! (right-pressed? widget) #f)
+ (set! (middle-pressed? widget) #f)
+ (set! (hover? widget) #f)
+ (notify widget 'exit))
+
+;; Implementors of this method need to be idempotent so that themes
+;; can be changed during runtime.
+(define-method (apply-theme (widget <widget>))
+ (when (background widget)
+ (replace widget
+ (make <9-patch>
+ #:name 'background
+ #:texture (background widget)
+ #:margin 4.0
+ #:width (width widget)
+ #:height (height widget)))))
+
+(define-method (refresh-background-size (widget <widget>))
+ (let ((bg (& widget background)))
+ (when bg
+ (set! (width bg) (width widget))
+ (set! (height bg) (height widget)))))
+
+(define-method (on-change (widget <widget>) slot-name old new)
+ (case slot-name
+ ((theme)
+ (apply-theme widget))
+ ((width height)
+ (refresh-background-size widget)
+ (next-method))
+ (else
+ (next-method))))
+
+(define-method (on-boot (widget <widget>))
+ (apply-theme widget))
+
+
+;;;
+;;; Theme
+;;;
+
+(define-class <theme> ()
+ (settings #:getter settings #:init-keyword #:settings #:init-form '()))
+
+(define-method (validate-settings (theme <theme>))
+ (let ((t (themeables)))
+ (for-each (match-lambda
+ ((class . class-settings)
+ (let ((slot-names (hashq-ref t class '())))
+ (for-each (match-lambda
+ ((key . _)
+ (unless (memq key slot-names)
+ (error "non-themeable slot for class"
+ class key))))
+ class-settings))))
+ (settings theme))))
+
+(define-method (initialize (theme <theme>) initargs)
+ (next-method)
+ (validate-settings theme))
+
+(define %unthemed-value (cons 'unthemed 'value))
+
+(define (unthemed-value? obj)
+ (eq? obj %unthemed-value))
+
+(define-method (theme-ref theme class name)
+ %unthemed-value)
+
+(define-method (theme-ref theme class name default)
+ default)
+
+(define-method (theme-ref (theme <theme>) (class <meta-widget>) name)
+ (match (assq name (or (assq-ref (settings theme) class) '()))
+ (#f
+ (let loop ((supers (class-direct-supers class)))
+ (match supers
+ (() %unthemed-value)
+ ((super . rest)
+ (let ((val (theme-ref theme super name)))
+ (if (unthemed-value? val)
+ (loop rest)
+ val))))))
+ ((key . value)
+ value)))
+
+(define-method (theme-ref (theme <theme>) class name default)
+ (let ((val (theme-ref theme class name)))
+ (if (unthemed-value? val)
+ default
+ val)))
+
+(define-syntax-rule (define-theme name (class (slot-name value) ...) ...)
+ (define name
+ (make <theme>
+ #:settings `((,class . ((slot-name . ,value) ...)) ...))))
+
+
+;;;
+;;; GUI Scene
+;;;
+
+;; A special scene that manages who the mouse is over, who has
+;; keyboard focus, etc.
+(define-class <gui-scene> (<scene-2d>)
+ (mouse-focus #:accessor mouse-focus #:init-value #f)
+ (keyboard-focus #:accessor keyboard-focus #:init-value #f))
+
+(define-method (pick-widget (scene <gui-scene>) p)
+ (pick scene p (lambda (node) (is-a? node <widget>))))
+
+(define-method (on-mouse-move (scene <gui-scene>) x y x-rel y-rel buttons)
+ (let ((focused (mouse-focus scene))
+ (picked (pick-widget scene (vec2 x y))))
+ (unless (eq? focused picked)
+ (set! (mouse-focus scene) picked)
+ (when focused
+ (mouse-exit focused))
+ (when picked
+ (mouse-enter picked buttons)))))
+
+(define-method (on-mouse-release (scene <gui-scene>) button x y)
+ (when (mouse-focus scene)
+ (mouse-click (mouse-focus scene) button)))
+
+(define-method (on-mouse-press (scene <gui-scene>) button clicks x y)
+ (when (mouse-focus scene)
+ (mouse-press (mouse-focus scene) button)))
+
+
+;;;
+;;; Container
+;;;
+
+(define-class <container> (<widget>)
+ (refresh-layout? #:accessor refresh-layout? #:init-form #t))
+
+(define-method (refresh-layout (container <container>))
+ #t)
+
+(define-method (on-child-resize (container <container>) child)
+ (set! (refresh-layout? container) #t))
+
+(define-method (on-attach (container <container>) child)
+ (set! (refresh-layout? container) #t))
+
+(define-method (on-detach (container <container>) child)
+ (set! (refresh-layout? container) #t))
+
+(define-method (update (container <container>) dt)
+ (next-method)
+ (when (refresh-layout? container)
+ (refresh-layout container)
+ (set! (refresh-layout? container) #f)))
+
+
+
+
+;;;
+;;; Horizontal Container
+;;;
+
+(define-class <horizontal-container> (<container>))
+
+(define-method (refresh-layout (container <horizontal-container>))
+ (let loop ((kids (children container))
+ (w 0.0)
+ (h 0.0))
+ (match kids
+ (()
+ (set! (width container) w)
+ (set! (height container) h))
+ ((child . rest)
+ (set! (position-x child) (- w (origin-x child)))
+ (set! (position-y child) (- (origin-x child)))
+ (loop rest
+ (+ w (width child))
+ (max h (height child)))))))
+
+
+;;;
+;;; Vertical Container
+;;;
+
+(define-class <vertical-container> (<container>))
+
+(define-method (refresh-layout (container <vertical-container>))
+ ;; We want the nodes stacked top to bottom, but the y origin is on
+ ;; the bottom of the screen. The simple iteration used in the
+ ;; horizontal layout won't work here because it would result in a
+ ;; bottom to top stacking. To deal with this, we traverse the
+ ;; children twice. Once to find the sum of all node heights, and
+ ;; once more to stack them top to bottom.
+ (let loop ((kids (children container))
+ (w 0.0)
+ (h (fold (lambda (child sum)
+ (+ (height child) sum))
+ 0.0 (children container))))
+ (set! (height container) h)
+ (match kids
+ (()
+ (set! (width container) w))
+ ((child . rest)
+ (set! (position-x child) (- (origin-x child)))
+ (set! (position-y child) (- h (origin-y child) (height child)))
+ (loop rest (max w (width child)) (- h (height child)))))))
+
+
+;;;
+;;; Margin Container
+;;;
+
+(define-class <margin-container> (<container>)
+ (left-margin #:accessor left-margin #:init-keyword #:left #:watch? #t)
+ (right-margin #:accessor right-margin #:init-keyword #:right #:watch? #t)
+ (bottom-margin #:accessor bottom-margin #:init-keyword #:bottom #:watch? #t)
+ (top-margin #:accessor top-margin #:init-keyword #:top #:watch? #t))
+
+(define-method (initialize (container <margin-container>) initargs)
+ (let ((default-margin (get-keyword #:margin initargs 0.0)))
+ (slot-set! container 'left-margin default-margin)
+ (slot-set! container 'right-margin default-margin)
+ (slot-set! container 'bottom-margin default-margin)
+ (slot-set! container 'top-margin default-margin))
+ (next-method))
+
+(define-method (refresh-layout (container <margin-container>))
+ (let loop ((kids (children container))
+ (w 0.0)
+ (h 0.0))
+ (match kids
+ (()
+ (set! (width container)
+ (+ w (left-margin container) (right-margin container)))
+ (set! (height container)
+ (+ h (bottom-margin container) (top-margin container))))
+ ((child . rest)
+ (set! (position-x child) (- (left-margin container) (origin-x child)))
+ (set! (position-y child) (- (bottom-margin container) (origin-y child)))
+ (loop rest (max w (width child)) (max h (height child)))))))
+
+(define-method (on-change (container <margin-container>) slot-name old new)
+ (case slot-name
+ ((left-margin right-margin bottom-margin top-margin)
+ (set! (refresh-layout? container) #t))
+ (else
+ (next-method))))
+
+
+;;;
+;;; Button
+;;;
+
+(define-class <button> (<widget>)
+ (text #:accessor text #:init-keyword #:text #:watch? #t)
+ (press-background #:getter press-background #:init-value #f #:themeable? #t))
+
+(define-method (refresh-label-position (button <button>))
+ (set! (position-x (& button label)) (/ (width button) 2.0))
+ (set! (position-y (& button label)) (/ (height button) 2.0)))
+
+(define-method (refresh-background-texture (button <button>))
+ (let ((bg (& button background)))
+ (when bg
+ (set! (texture bg)
+ (if (left-pressed? button)
+ (press-background button)
+ (background button))))))
+
+(define-method (on-change (button <button>) slot-name old new)
+ (case slot-name
+ ((width height)
+ (refresh-label-position button)
+ (next-method))
+ ((text)
+ (set! (text (& button label)) new))
+ ((left-pressed?)
+ (refresh-background-texture button))
+ (else
+ (next-method))))
+
+(define-method (apply-theme (button <button>))
+ (next-method)
+ (replace button
+ (make <label>
+ #:name 'label
+ #:rank 1
+ #:font (font button)
+ #:text (text button)
+ #:align 'center
+ #:vertical-align 'center))
+ (refresh-label-position button)
+ (refresh-background-texture button))