playscii/ui_element.py

487 lines
17 KiB
Python

import time
from math import ceil
import vector
from art import Art
from renderable import TileRenderable
class UIElement:
# size, in tiles
tile_width, tile_height = 1, 1
snap_top, snap_bottom, snap_left, snap_right = False, False, False, False
# location in tile coords; snap_* trumps these
tile_x, tile_y = 0, 0
# location in screen (GL) coords
x, y = 0, 0
visible = True
# UI calls our update() even when we're invisible
update_when_invisible = False
# cheapo drop shadow effect, draws renderable dark at a small offset
drop_shadow = False
renderables = None
can_hover = True
# always return True for clicked/unclicked, "consuming" the input
always_consume_input = False
buttons = []
# if True, use shared keyboard navigation controls
support_keyboard_navigation = False
support_scrolling = False
keyboard_nav_left_right = False
# renders in "game mode"
game_mode_visible = False
all_modes_visible = False
keyboard_nav_offset = 0
def __init__(self, ui):
self.ui = ui
self.hovered_buttons = []
# generate a unique name
art_name = "{}_{}".format(int(time.time()), self.__class__.__name__)
self.art = UIArt(
art_name,
self.ui.app,
self.ui.charset,
self.ui.palette,
self.tile_width,
self.tile_height,
)
self.renderable = UIRenderable(self.ui.app, self.art)
self.renderable.ui = self.ui
# some elements add their own renderables before calling this
# constructor, make sure we're not erasing any
if not self.renderables:
self.renderables = []
self.renderables.append(self.renderable)
self.reset_art()
self.reset_loc()
if self.support_keyboard_navigation:
self.keyboard_nav_index = 0
def is_inside(self, x, y):
"returns True if given point is inside this element's bounds"
w = self.tile_width * self.art.quad_width
h = self.tile_height * self.art.quad_height
return self.x <= x <= self.x + w and self.y >= y >= self.y - h
def is_inside_button(self, x, y, button):
"returns True if given point is inside the given button's bounds"
aqw, aqh = self.art.quad_width, self.art.quad_height
# put negative values in range
bx, by = (button.x % self.art.width) * aqw, (button.y % self.art.height) * aqh
bw, bh = button.width * aqw, button.height * aqh
bxmin, bymin = self.x + bx, self.y - by
bxmax, bymax = bxmin + bw, bymin - bh
return bxmin <= x <= bxmax and bymin >= y >= bymax
def reset_art(self):
"""
runs on init and resize, restores state.
"""
self.draw_buttons()
def draw_buttons(self):
for button in self.buttons:
if button.visible:
button.draw()
def hovered(self):
self.log_event("hovered")
def unhovered(self):
self.log_event("unhovered")
def wheel_moved(self, wheel_y):
handled = False
return handled
def clicked(self, mouse_button):
self.log_event("clicked", mouse_button)
# return if a button did something
handled = False
# tell any hovered buttons they've been clicked
for b in self.hovered_buttons:
if b.can_click:
b.click()
if b.callback:
# button callback might need extra data (cb_arg)
if b.cb_arg is not None:
# button might want to know which mouse button clicked
if b.pass_mouse_button:
b.callback(mouse_button, b.cb_arg)
else:
b.callback(b.cb_arg)
else:
if b.pass_mouse_button:
b.callback(mouse_button)
else:
b.callback()
handled = True
if self.always_consume_input:
return True
return handled
def unclicked(self, mouse_button):
self.log_event("unclicked", mouse_button)
handled = False
for b in self.hovered_buttons:
b.unclick()
handled = True
if self.always_consume_input:
return True
return handled
def log_event(self, event_type, mouse_button=None):
mouse_button = mouse_button or "[n/a]"
if self.ui.logg:
self.ui.app.log(
"UIElement: {} {} with mouse button {}".format(
self.__class__.__name__, event_type, mouse_button
)
)
def is_visible(self):
if self.all_modes_visible:
return self.visible
elif (
not self.ui.app.game_mode
and self.game_mode_visible
or self.ui.app.game_mode
and not self.game_mode_visible
):
return False
return self.visible
def reset_loc(self):
if self.snap_top:
self.y = 1
elif self.snap_bottom:
self.y = self.art.quad_height * self.tile_height - 1
elif self.tile_y:
self.y = 1 - (self.tile_y * self.art.quad_height)
if self.snap_left:
self.x = -1
elif self.snap_right:
self.x = 1 - (self.art.quad_width * self.tile_width)
elif self.tile_x:
self.x = -1 + (self.tile_x * self.art.quad_width)
self.renderable.x, self.renderable.y = self.x, self.y
def keyboard_navigate(self, move_x, move_y):
if not self.support_keyboard_navigation:
return
if self.keyboard_nav_left_right:
if move_x < 0:
self.ui.menu_bar.previous_menu()
return
elif move_x > 0:
self.ui.menu_bar.next_menu()
return
self.keyboard_nav_index += move_y
if not self.support_scrolling:
# if button list starts at >0 Y, use an offset
self.keyboard_nav_index %= len(self.buttons) + self.keyboard_nav_offset
tries = 0
# recognize two different kinds of inactive items: empty caption and dim state
while tries < len(self.buttons) and (
self.buttons[self.keyboard_nav_index].caption == ""
or self.buttons[self.keyboard_nav_index].state == "dimmed"
):
# move_y might be zero, give it a direction to avoid infinite loop
# if menu item 0 is dimmed
self.keyboard_nav_index += move_y or 1
self.keyboard_nav_index %= len(self.buttons) + self.keyboard_nav_offset
tries += 1
if tries == len(self.buttons):
return
self.post_keyboard_navigate()
self.update_keyboard_hover()
def update_keyboard_hover(self):
if not self.support_keyboard_navigation:
return
for i, button in enumerate(self.buttons):
# don't higlhight if this panel doesn't have focus
if self.keyboard_nav_index == i and self is self.ui.keyboard_focus_element:
button.set_state("hovered")
elif button.state != "dimmed":
button.set_state("normal")
def keyboard_select_item(self):
if not self.support_keyboard_navigation:
return
button = self.buttons[self.keyboard_nav_index]
# don't allow selecting dimmed buttons
if button.state == "dimmed":
return
# check for None; cb_arg could be 0
if button.cb_arg is not None:
button.callback(button.cb_arg)
else:
button.callback()
return button
def post_keyboard_navigate(self):
# subclasses can put stuff here to check scrolling etc
pass
def update(self):
"runs every frame, checks button states"
# this is very similar to UI.update, implying an alternative structure
# in which UIElements can contain other UIElements. i've seen this get
# really confusing on past projects though, so let's try a flatter
# architecture - UI w/ UIelements, UIElements w/ UIButtons - for now.
mx, my = self.ui.get_screen_coords(self.ui.app.mouse_x, self.ui.app.mouse_y)
was_hovering = self.hovered_buttons[:]
self.hovered_buttons = []
for b in self.buttons:
# element.clicked might have been set it non-hoverable, acknowledge
# its hoveredness here so it can unhover correctly
if (
b.visible
and (b.can_hover or b.state == "clicked")
and self.is_inside_button(mx, my, b)
):
self.hovered_buttons.append(b)
if b not in was_hovering:
b.hover()
for b in was_hovering:
if b not in self.hovered_buttons:
b.unhover()
# tiles might have just changed
self.art.update()
def render(self):
# ("is visible" check happens in UI.render, calls our is_visible)
# render drop shadow first
if self.drop_shadow:
# offset in X and Y, render then restore position
orig_x, orig_y = self.renderable.x, self.renderable.y
self.renderable.x += UIArt.quad_width / 10
self.renderable.y -= UIArt.quad_height / 10
self.renderable.render(brightness=0.1)
self.renderable.x, self.renderable.y = orig_x, orig_y
self.renderable.render()
def destroy(self):
for r in self.renderables:
r.destroy()
class UIArt(Art):
recalc_quad_height = False
log_creation = False
class UIRenderable(TileRenderable):
grain_strength = 0.2
def get_projection_matrix(self):
# don't use projection matrix, ie identity[0][0]=aspect;
# rather do all aspect correction in UI.set_scale when determining quad size
return self.ui.view_matrix
def get_view_matrix(self):
return self.ui.view_matrix
class FPSCounterUI(UIElement):
tile_y = 1
tile_width, tile_height = 12, 2
snap_right = True
game_mode_visible = True
all_modes_visible = True
visible = False
def update(self):
bg = 0
self.art.clear_frame_layer(0, 0, bg)
color = self.ui.colors.white
# yellow or red if framerate dips
if self.ui.app.fps < 30:
color = self.ui.colors.yellow
if self.ui.app.fps < 10:
color = self.ui.colors.red
text = "{:.1f} fps".format(self.ui.app.fps)
x = self.tile_width - 1
self.art.write_string(0, 0, x, 0, text, color, None, True)
# display last tick time; frame_time includes delay, is useless
text = "{:.1f} ms ".format(self.ui.app.frame_time)
self.art.write_string(0, 0, x, 1, text, color, None, True)
def render(self):
# always show FPS if low
if self.visible or self.ui.app.fps < 30:
self.renderable.render()
class MessageLineUI(UIElement):
"when console outputs something new, show last line here before fading out"
tile_y = 2
snap_left = True
# just info, don't bother with hover, click etc
can_hover = False
default_hold_time = 1
fade_rate = 0.025
game_mode_visible = True
all_modes_visible = True
drop_shadow = True
def __init__(self, ui):
UIElement.__init__(self, ui)
# line we're currently displaying (even after fading out)
self.line = ""
self.last_post = self.ui.app.get_elapsed_time()
self.hold_time = self.default_hold_time
self.alpha = 1
def reset_art(self):
self.tile_width = ceil(self.ui.width_tiles)
self.art.resize(self.tile_width, self.tile_height)
self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white)
UIElement.reset_loc(self)
def post_line(self, new_line, hold_time=None, error=False):
"write a line to this element (ie so as not to spam console log)"
self.hold_time = hold_time or self.default_hold_time
# use a different color if it's an error
color = self.ui.error_color_index if error else self.ui.colors.white
start_x = 1
# trim to screen width
self.line = str(new_line)[: self.tile_width - start_x - 1]
self.art.clear_frame_layer(0, 0, 0, color)
self.art.write_string(0, 0, start_x, 0, self.line)
self.alpha = 1
self.last_post = self.ui.app.get_elapsed_time()
def update(self):
if self.ui.app.get_elapsed_time() > self.last_post + (self.hold_time * 1000):
if self.alpha >= self.fade_rate:
self.alpha -= self.fade_rate
if self.alpha <= self.fade_rate:
self.alpha = 0
self.renderable.alpha = self.alpha
def render(self):
# TODO: draw if popup is visible but not obscuring message line?
if (
self.ui.popup not in self.ui.hovered_elements
and not self.ui.console.visible
):
UIElement.render(self)
class DebugTextUI(UIElement):
"simple UI element for posting debug text"
tile_x, tile_y = 1, 4
tile_height = 20
clear_lines_after_render = True
game_mode_visible = True
visible = False
def __init__(self, ui):
UIElement.__init__(self, ui)
self.lines = []
def reset_art(self):
self.tile_width = ceil(self.ui.width_tiles)
self.art.resize(self.tile_width, self.tile_height)
self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white)
UIElement.reset_loc(self)
def post_lines(self, lines):
if type(lines) is list:
self.lines += lines
else:
self.lines += [lines]
def update(self):
self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white)
for y, line in enumerate(self.lines):
self.art.write_string(0, 0, 0, y, line)
def render(self):
UIElement.render(self)
if self.clear_lines_after_render:
self.lines = []
# self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white)
class ToolTip(UIElement):
"popup text label that is invoked and controlled by a UIButton hover"
visible = False
tile_width, tile_height = 30, 1
tile_x, tile_y = 10, 5
def set_text(self, text):
self.art.write_string(
0, 0, 0, 0, text, self.ui.colors.black, self.ui.colors.white
)
# clear tiles past end of text
for x in range(len(text), self.tile_width):
self.art.set_color_at(0, 0, x, 0, 0, 0)
def reset_art(self):
UIElement.reset_art(self)
self.art.clear_frame_layer(0, 0, self.ui.colors.white, self.ui.colors.black)
class GameLabel(UIElement):
tile_width, tile_height = 50, 1
game_mode_visible = True
drop_shadow = True
update_when_invisible = True
class GameSelectionLabel(GameLabel):
multi_select_label = "[%s selected]"
def update(self):
self.visible = False
if self.ui.pulldown.visible or not self.ui.is_game_edit_ui_visible():
return
if len(self.ui.app.gw.selected_objects) == 0:
return
self.visible = True
if len(self.ui.app.gw.selected_objects) == 1:
obj = self.ui.app.gw.selected_objects[0]
text = obj.name[: self.tile_width - 1]
x, y, z = obj.x, obj.y, obj.z
else:
# draw "[N selected]" at avg of selected object locations
text = self.multi_select_label % len(self.ui.app.gw.selected_objects)
x, y, z = 0, 0, 0
for obj in self.ui.app.gw.selected_objects:
x += obj.x
y += obj.y
z += obj.z
x /= len(self.ui.app.gw.selected_objects)
y /= len(self.ui.app.gw.selected_objects)
z /= len(self.ui.app.gw.selected_objects)
self.art.clear_line(0, 0, 0, self.ui.colors.white, -1)
self.art.write_string(0, 0, 0, 0, text)
self.x, self.y = vector.world_to_screen_normalized(self.ui.app, x, y, z)
self.reset_loc()
class GameHoverLabel(GameLabel):
alpha = 0.75
def update(self):
self.visible = False
if self.ui.pulldown.visible or not self.ui.is_game_edit_ui_visible():
return
if not self.ui.app.gw.hovered_focus_object:
return
self.visible = True
obj = self.ui.app.gw.hovered_focus_object
text = obj.name[: self.tile_width - 1]
x, y, z = obj.x, obj.y, obj.z
self.art.clear_line(0, 0, 0, self.ui.colors.white, -1)
self.art.write_string(0, 0, 0, 0, text)
self.x, self.y = vector.world_to_screen_normalized(self.ui.app, x, y, z)
self.reset_loc()
self.renderable.alpha = self.alpha