Rearranged and renamed Animation Editor-related script files

This commit is contained in:
Maruno17
2023-10-23 23:44:34 +01:00
parent 340983e765
commit 64890f3c9e
23 changed files with 136 additions and 91 deletions

View File

@@ -0,0 +1,2 @@
# Container module for control classes.
module UIControls; end

View File

@@ -0,0 +1,192 @@
#===============================================================================
# Controls are arranged in a list in self's bitmap. Each control is given a
# "self's bitmap's width" x LINE_SPACING area of self's bitmap to draw itself
# in.
# TODO: The act of "capturing" a control makes other controls in this container
# not update themselves, i.e. they won't colour themselves with a hover
# highlight if the mouse happens to move over it while another control is
# captured. Is there a better way of dealing with this? I'm leaning
# towards the control itself deciding if it's captured, and it being
# treated as uncaptured once it says its value has changed, but I think
# this would require manually telling all other controls in this container
# that something else is captured and they shouldn't show a hover
# highlight when updated (perhaps as a parameter in def update), which I
# don't think is ideal.
#===============================================================================
class UIControls::ControlsContainer
attr_reader :x, :y
attr_reader :controls
attr_reader :values
attr_reader :visible
LINE_SPACING = 28
OFFSET_FROM_LABEL_X = 90
OFFSET_FROM_LABEL_Y = 0
def initialize(x, y, width, height)
@viewport = Viewport.new(x, y, width, height)
@viewport.z = 99999
@x = x
@y = y
@width = width
@height = height
@controls = []
@control_rects = []
@row_count = 0
@captured = nil
@visible = true
end
def dispose
@controls.each { |c| c[1]&.dispose }
@controls.clear
@viewport.dispose
end
def busy?
return !@captured.nil?
end
def changed?
return !@values.nil?
end
def clear_changed
@values = nil
end
def visible=(value)
@visible = value
@controls.each { |c| c[1].visible = value }
repaint if @visible
end
def get_control(id)
ret = nil
@controls.each do |c|
ret = c[1] if c[0] == id
break if ret
end
return ret
end
#-----------------------------------------------------------------------------
def add_label(id, label, has_label = false)
id = (id.to_s + "_label").to_sym
add_control(id, UIControls::Label.new(*control_size(has_label), @viewport, label), has_label)
end
def add_checkbox(id, value, has_label = false)
add_control(id, UIControls::Checkbox.new(*control_size(has_label), @viewport, value), has_label)
end
def add_labelled_checkbox(id, label, value)
add_label(id, label)
add_checkbox(id, value, true)
end
def add_text_box(id, value, has_label = false)
add_control(id, UIControls::TextBox.new(*control_size(has_label), @viewport, value), has_label)
end
def add_labelled_text_box(id, label, value)
add_label(id, label)
add_text_box(id, value, true)
end
def add_number_slider(id, min_value, max_value, value, has_label = false)
add_control(id, UIControls::NumberSlider.new(*control_size(has_label), @viewport, min_value, max_value, value), has_label)
end
def add_labelled_number_slider(id, label, min_value, max_value, value)
add_label(id, label)
add_number_slider(id, min_value, max_value, value, true)
end
def add_number_text_box(id, min_value, max_value, value, has_label = false)
add_control(id, UIControls::NumberTextBox.new(*control_size(has_label), @viewport, min_value, max_value, value), has_label)
end
def add_labelled_number_text_box(id, label, min_value, max_value, value)
add_label(id, label)
add_number_text_box(id, min_value, max_value, value, true)
end
def add_button(id, button_text, has_label = false)
add_control(id, UIControls::Button.new(*control_size(has_label), @viewport, button_text), has_label)
end
def add_labelled_button(id, label, button_text)
add_label(id, label)
add_button(id, button_text, true)
end
def add_dropdown_list(id, options, value, has_label = false)
add_control(id, UIControls::DropdownList.new(*control_size(has_label), @viewport, options, value), has_label)
end
def add_labelled_dropdown_list(id, label, options, value)
add_label(id, label)
add_dropdown_list(id, options, value, true)
end
#-----------------------------------------------------------------------------
def repaint
@controls.each { |ctrl| ctrl[1].repaint }
end
#-----------------------------------------------------------------------------
def update
return if !@visible
# Update controls
if @captured
# TODO: Ideally all controls will be updated here, if only to redraw
# themselves if they happen to be invalidated somehow. But that
# involves telling each control whether any other control is busy,
# to ensure that they don't show their hover colours or anything,
# which is fiddly and I'm not sure if it's the best approach.
@captured.update
@captured = nil if !@captured.busy?
else
@controls.each do |ctrl|
ctrl[1].update
@captured = ctrl[1] if ctrl[1].busy?
end
end
# Check for updated controls
@controls.each do |ctrl|
next if !ctrl[1].changed?
@values ||= {}
@values[ctrl[0]] = ctrl[1].value
ctrl[1].clear_changed
end
# Redraw controls if needed
repaint
end
#-----------------------------------------------------------------------------
private
def control_size(has_label = false)
if has_label
return @width - OFFSET_FROM_LABEL_X, LINE_SPACING - OFFSET_FROM_LABEL_Y
end
return @width, LINE_SPACING
end
def add_control(id, control, add_offset = false)
i = @controls.length
control_y = (add_offset ? @row_count - 1 : @row_count) * LINE_SPACING
@control_rects[i] = Rect.new(0, control_y, control.width, control.height)
control.x = @control_rects[i].x + (add_offset ? OFFSET_FROM_LABEL_X : 0)
control.y = @control_rects[i].y + (add_offset ? OFFSET_FROM_LABEL_Y : 0)
control.set_interactive_rects
@controls[i] = [id, control]
@row_count += 1 if !add_offset
repaint
end
end

View File

@@ -0,0 +1,185 @@
# TODO: Add "disabled" greying out/non-editable.
# TODO: Add indicator of whether the control's value is "lerping" between frames
# (use yellow somehow?).
#===============================================================================
#
#===============================================================================
class UIControls::BaseControl < BitmapSprite
attr_reader :value
# attr_accessor :disabled # TODO: Make use of this.
TEXT_COLOR = Color.black
TEXT_SIZE = 18 # Default is 22 if size isn't explicitly set
HOVER_COLOR = Color.cyan # For clickable area when hovering over it
CAPTURE_COLOR = Color.pink # For area you clicked in but aren't hovering over
def initialize(width, height, viewport)
super(width, height, viewport)
self.bitmap.font.color = TEXT_COLOR
self.bitmap.font.size = TEXT_SIZE
# @disabled = false # TODO: Make use of this.
@hover_area = nil # Is a symbol from the keys for @interactions if the mouse is hovering over that interaction
@captured_area = nil # Is a symbol from the keys for @interactions (or :none) if this control is clicked in
clear_changed
invalidate
end
def width
return self.bitmap.width
end
def height
return self.bitmap.height
end
def mouse_pos
mouse_coords = Mouse.getMousePos
return nil, nil if !mouse_coords
ret_x = mouse_coords[0] - self.viewport.rect.x - self.x
ret_y = mouse_coords[1] - self.viewport.rect.y - self.y
return ret_x, ret_y
end
def set_interactive_rects
@interactions = {}
end
#-----------------------------------------------------------------------------
def invalid?
return @invalid
end
# Marks that the control must be redrawn to reflect current logic.
def invalidate
@invalid = true
end
# Makes the control no longer invalid.
def validate
@invalid = false
end
def busy?
return !@captured_area.nil?
end
def changed?
return @changed
end
def set_changed
@changed = true
end
def clear_changed
@changed = false
end
#-----------------------------------------------------------------------------
def draw_text(this_bitmap, text_x, text_y, this_text)
text_size = this_bitmap.text_size(this_text)
this_bitmap.draw_text(text_x, text_y, text_size.width, text_size.height, this_text, 0)
end
def draw_text_centered(this_bitmap, text_x, text_y, wid, this_text)
text_size = this_bitmap.text_size(this_text)
this_bitmap.draw_text(text_x, text_y, wid, text_size.height, this_text, 1)
end
# Redraws the control only if it is invalid.
def repaint
return if !invalid?
refresh
validate
end
def refresh
# Paint over control to erase contents (intentionally not using self.bitmap.clear)
self.bitmap.clear
draw_area_highlight
end
def draw_area_highlight
return if !@interactions || @interactions.empty?
if !@captured_area || @hover_area == @captured_area
# Draw mouse hover over area highlight
rect = @interactions[@hover_area]
self.bitmap.fill_rect(rect.x, rect.y, rect.width, rect.height, HOVER_COLOR) if rect
elsif @captured_area
# Draw captured area highlight
rect = @interactions[@captured_area]
self.bitmap.fill_rect(rect.x, rect.y, rect.width, rect.height, CAPTURE_COLOR) if rect
end
end
#-----------------------------------------------------------------------------
# This method is only called if the mouse is in the game window and this
# control has interactive elements.
def on_mouse_press
return if !@interactions || @interactions.empty?
return if @captured_area
@captured_area = nil
mouse_x, mouse_y = mouse_pos
return if !mouse_x || !mouse_y
@interactions.each_pair do |area, rect|
next if !rect.contains?(mouse_x, mouse_y)
@captured_area = area
invalidate
break
end
end
def on_mouse_release
@captured_area = nil
invalidate
end
def update_hover_highlight
# Remove the hover highlight if there are no interactions for this control
# or if the mouse is off-screen
mouse_x, mouse_y = mouse_pos
if !@interactions || @interactions.empty? || !mouse_x || !mouse_y
invalidate if @hover_area
@hover_area = nil
return
end
# Check each interactive area for whether the mouse is hovering over it, and
# set @hover_area accordingly
in_area = false
@interactions.each_pair do |area, rect|
next if !rect.contains?(mouse_x, mouse_y)
invalidate if @hover_area != area
@hover_area = area
in_area = true
break
end
if !in_area
invalidate if @hover_area
@hover_area = nil
end
end
# Updates the logic on the control, invalidating it if necessary.
def update
return if !self.visible
# TODO: Disabled control stuff.
# return if self.disabled
update_hover_highlight
# Detect a mouse press/release
if @interactions && !@interactions.empty?
if Input.trigger?(Input::MOUSELEFT)
on_mouse_press
elsif busy? && Input.release?(Input::MOUSELEFT)
on_mouse_release
end
end
end
end

View File

@@ -0,0 +1,24 @@
#===============================================================================
#
#===============================================================================
class UIControls::Label < UIControls::BaseControl
attr_reader :label
LABEL_END_X = 80
TEXT_OFFSET_Y = 5
def initialize(width, height, viewport, label)
super(width, height, viewport)
@label = label
end
def label=(value)
@label = value
refresh
end
def refresh
super
draw_text(self.bitmap, 4, TEXT_OFFSET_Y, @label)
end
end

View File

@@ -0,0 +1,68 @@
#===============================================================================
# NOTE: Strictly speaking, this is a toggle switch and not a checkbox.
#===============================================================================
class UIControls::Checkbox < UIControls::BaseControl
CHECKBOX_X = 2
CHECKBOX_WIDTH = 40
CHECKBOX_HEIGHT = 24
CHECKBOX_FILL_SIZE = CHECKBOX_HEIGHT - 4
UNCHECKED_COLOR = Color.gray
CHECKED_COLOR = Color.new(64, 255, 64) # Green
def initialize(width, height, viewport, value = false)
super(width, height, viewport)
@value = value
end
def value=(new_value)
return if @value == new_value
@value = new_value
invalidate
end
def set_interactive_rects
@checkbox_rect = Rect.new(CHECKBOX_X, (height - CHECKBOX_HEIGHT) / 2,
CHECKBOX_WIDTH, CHECKBOX_HEIGHT)
@interactions = {
:checkbox => @checkbox_rect
}
end
#-----------------------------------------------------------------------------
def refresh
super
# Draw checkbox outline
self.bitmap.outline_rect(@checkbox_rect.x, @checkbox_rect.y,
@checkbox_rect.width, @checkbox_rect.height,
self.bitmap.font.color)
# Draw checkbox fill
if @value # If checked
self.bitmap.fill_rect(@checkbox_rect.x + @checkbox_rect.width - CHECKBOX_FILL_SIZE - 2, @checkbox_rect.y + 2,
CHECKBOX_FILL_SIZE, CHECKBOX_FILL_SIZE, CHECKED_COLOR)
self.bitmap.outline_rect(@checkbox_rect.x + @checkbox_rect.width - CHECKBOX_FILL_SIZE - 2, @checkbox_rect.y + 2,
CHECKBOX_FILL_SIZE, CHECKBOX_FILL_SIZE, self.bitmap.font.color)
else
self.bitmap.fill_rect(@checkbox_rect.x + 2, @checkbox_rect.y + 2,
CHECKBOX_FILL_SIZE, CHECKBOX_FILL_SIZE, UNCHECKED_COLOR)
self.bitmap.outline_rect(@checkbox_rect.x + 2, @checkbox_rect.y + 2,
CHECKBOX_FILL_SIZE, CHECKBOX_FILL_SIZE, self.bitmap.font.color)
end
end
#-----------------------------------------------------------------------------
def on_mouse_release
return if !@captured_area # Wasn't captured to begin with
# Change this control's value
if @captured_area == :checkbox
mouse_x, mouse_y = mouse_pos
if mouse_x && mouse_y && @interactions[@captured_area].contains?(mouse_x, mouse_y)
@value = !@value # The actual change of this control's value
set_changed
end
end
super # Make this control not busy again
end
end

View File

@@ -0,0 +1,293 @@
#===============================================================================
# TODO: Support selecting part of the text by remembering the initial
# cursor position and using it and the current cursor position to
# decide which characters are selected. Maybe? Note that this method
# is only triggered upon the initial mouse press, and isn't repeated
# while it's still held down.
#===============================================================================
class UIControls::TextBox < UIControls::BaseControl
TEXT_BOX_X = 2
TEXT_BOX_WIDTH = 172
TEXT_BOX_HEIGHT = 24
TEXT_BOX_PADDING = 4 # Gap between sides of text box and text
TEXT_OFFSET_Y = 5
def initialize(width, height, viewport, value = "")
super(width, height, viewport)
@value = value
@cursor_pos = -1
@display_pos = 0
@cursor_timer = nil
@cursor_shown = false
end
def value=(new_value)
return if @value == new_value
@value = new_value
invalidate
end
def insert_char(ch)
@value.insert(@cursor_pos, ch)
@cursor_pos += 1
@cursor_timer = System.uptime
@cursor_shown = true
invalidate
end
def delete_at(index)
@value.slice!(index)
@cursor_pos -= 1 if @cursor_pos > index
@cursor_timer = System.uptime
@cursor_shown = true
invalidate
end
def cursor_pos=(val)
@cursor_pos = val
reset_display_pos
@cursor_timer = System.uptime
@cursor_shown = true
invalidate
end
def set_interactive_rects
@text_box_rect = Rect.new(TEXT_BOX_X, (height - TEXT_BOX_HEIGHT) / 2,
[TEXT_BOX_WIDTH, width].min, TEXT_BOX_HEIGHT)
@interactions = {
:text_box => @text_box_rect
}
end
#-----------------------------------------------------------------------------
def busy?
return @cursor_pos >= 0 if @captured_area == :text_box
return super
end
def reset_interaction
@cursor_pos = -1
@display_pos = 0
@cursor_timer = nil
@initial_value = nil
Input.text_input = false
invalidate
end
#-----------------------------------------------------------------------------
def get_cursor_index_from_mouse_position
char_widths = []
@value.to_s.length.times { |i| char_widths[i] = self.bitmap.text_size(@value.to_s[i]).width }
mouse_x, mouse_y = mouse_pos
mouse_x -= @text_box_rect.x + TEXT_BOX_PADDING
return 0 if mouse_x < 0
(@display_pos...char_widths.length).each do |i|
mouse_x -= char_widths[i]
if mouse_x <= 0
return (mouse_x.abs >= char_widths[i] / 2) ? i : i + 1
end
end
return @value.to_s.length
end
def reset_display_pos
box_width = @text_box_rect.width - (TEXT_BOX_PADDING * 2)
char_widths = []
@value.to_s.length.times { |i| char_widths[i] = self.bitmap.text_size(@value.to_s[i]).width }
# Text isn't wider than the box
if char_widths.sum <= box_width
return false if @display_pos == 0
@display_pos = 0
return true
end
display_pos_changed = false
# Ensure the cursor hasn't gone off the left side of the text box
if @cursor_pos < @display_pos
@display_pos = @cursor_pos
display_pos_changed = true
end
# Ensure the cursor hasn't gone off the right side of the text box
if @cursor_pos > @display_pos
loop do
cursor_x = 0
(@display_pos...@cursor_pos).each do |i|
cursor_x += char_widths[i] if char_widths[i]
end
break if cursor_x < box_width
@display_pos += 1
display_pos_changed = true
break if @display_pos == @cursor_pos
end
end
# Ensure there isn't empty space on the right if the text can be moved to
# the right to fill it
if @display_pos > 0
cursor_x = 0
(@display_pos...char_widths.length).each do |i|
cursor_x += char_widths[i] if char_widths[i]
end
loop do
cursor_x += char_widths[@display_pos - 1]
break if cursor_x >= box_width
@display_pos -= 1
display_pos_changed = true
break if @display_pos == 0
end
end
return display_pos_changed
end
#-----------------------------------------------------------------------------
def draw_area_highlight
return if @captured_area == :text_box && (@hover_area == @captured_area || !Input.press?(Input::MOUSELEFT))
super
end
def draw_cursor(cursor_x)
return if !@cursor_shown || @cursor_pos < 0
cursor_y_offset = ((height - TEXT_BOX_HEIGHT) / 2) + 2
cursor_height = height - (cursor_y_offset * 2)
bitmap.fill_rect(cursor_x, cursor_y_offset, 2, cursor_height, self.bitmap.font.color)
end
def refresh
super
# Draw text box outline
self.bitmap.outline_rect(@text_box_rect.x, @text_box_rect.y,
@text_box_rect.width, @text_box_rect.height,
self.bitmap.font.color)
# Draw value
char_x = @text_box_rect.x + TEXT_BOX_PADDING
last_char_index = @display_pos
(@value.to_s.length - @display_pos).times do |i|
char = @value.to_s[@display_pos + i]
char_width = self.bitmap.text_size(char).width
cannot_display_next_char = char_x + char_width > @text_box_rect.x + @text_box_rect.width - TEXT_BOX_PADDING
draw_text(self.bitmap, char_x, TEXT_OFFSET_Y, char) if !cannot_display_next_char
# Draw cursor
draw_cursor(char_x - 1) if @display_pos + i == @cursor_pos
break if cannot_display_next_char
last_char_index = @display_pos + i
char_x += char_width
end
# Draw cursor at end
draw_cursor(char_x - 1) if @cursor_pos == @value.to_s.length
# Draw left/right arrows to indicate more text beyond the text box sides
if @display_pos > 0
bitmap.fill_rect(@text_box_rect.x, (height / 2) - 4, 1, 8, Color.white)
5.times do |i|
bitmap.fill_rect(@text_box_rect.x - 2 + i, (height / 2) - (i + 1), 1, 2 * (i + 1), self.bitmap.font.color)
end
end
if last_char_index < @value.to_s.length - 1
bitmap.fill_rect(@text_box_rect.x + @text_box_rect.width - 1, (height / 2) - 4, 1, 8, Color.white)
5.times do |i|
bitmap.fill_rect(@text_box_rect.x + @text_box_rect.width + 1 - i, (height / 2) - (i + 1), 1, 2 * (i + 1), self.bitmap.font.color)
end
end
end
#-----------------------------------------------------------------------------
def on_mouse_press
@captured_area = nil
super
if @captured_area == :text_box
# Clicked into the text box; put the text cursor in there
@cursor_pos = get_cursor_index_from_mouse_position
@cursor_timer = System.uptime
invalidate
else
@value.strip! if @value.respond_to?("strip!")
set_changed if @initial_value && @value != @initial_value
reset_interaction
end
end
def on_mouse_release
return if !@captured_area # Wasn't captured to begin with
# Start text entry if clicked and released mouse button in the text box
if @captured_area == :text_box
mouse_x, mouse_y = mouse_pos
if mouse_x && mouse_y && @interactions[@captured_area].contains?(mouse_x, mouse_y)
@initial_value = @value.clone
Input.text_input = true
invalidate
return # This control is still captured
end
end
# Released mouse button outside of text box, or initially clicked outside of
# text box; end interaction with this control
@value.strip! if @value.respond_to?("strip!")
set_changed if @initial_value && @value != @initial_value
reset_interaction
super # Make this control not busy again
end
def update_special_inputs
# Left/right to move cursor
if Input.triggerex?(:LEFT) || Input.repeatex?(:LEFT)
self.cursor_pos = @cursor_pos - 1 if @cursor_pos > 0
elsif Input.triggerex?(:RIGHT) || Input.repeatex?(:RIGHT)
self.cursor_pos = @cursor_pos + 1 if @cursor_pos < @value.to_s.length
end
# Home/End to jump to start/end of the text
if Input.triggerex?(:HOME) || Input.repeatex?(:HOME)
self.cursor_pos = 0
elsif Input.triggerex?(:END) || Input.repeatex?(:END)
self.cursor_pos = @value.to_s.length
end
# Backspace/Delete to remove text
if Input.triggerex?(:BACKSPACE) || Input.repeatex?(:BACKSPACE)
delete_at(@cursor_pos - 1) if @cursor_pos > 0
elsif Input.triggerex?(:DELETE) || Input.repeatex?(:DELETE)
delete_at(@cursor_pos) if @cursor_pos < @value.to_s.length
end
# Return/Escape to end text input (Escape undoes the change)
if Input.triggerex?(:RETURN) || Input.repeatex?(:RETURN) ||
Input.triggerex?(:KP_ENTER) || Input.repeatex?(:KP_ENTER)
@value.strip! if @value.respond_to?("strip!")
set_changed if @initial_value && @value != @initial_value
reset_interaction
@captured_area = nil
elsif Input.triggerex?(:ESCAPE) || Input.repeatex?(:ESCAPE)
@value = @initial_value if @initial_value
reset_interaction
@captured_area = nil
end
end
def update_text_entry
ret = false
Input.gets.each_char do |ch|
insert_char(ch)
ret = true
end
return ret
end
def update
return if !self.visible
super
# TODO: Disabled control stuff.
# return if self.disabled
# Make the cursor flash
if @captured_area == :text_box
cursor_to_show = ((System.uptime - @cursor_timer) / 0.35).to_i.even?
if cursor_to_show != @cursor_shown
@cursor_shown = cursor_to_show
invalidate
end
old_cursor_pos = @cursor_pos
# Update cursor movement, deletions and ending text input
update_special_inputs
return if @cursor_pos != old_cursor_pos || !busy?
# Detect character input and add them to @value
char_inserted = update_text_entry
invalidate if reset_display_pos || char_inserted
end
end
end

View File

@@ -0,0 +1,131 @@
#===============================================================================
#
#===============================================================================
class UIControls::NumberSlider < UIControls::BaseControl
attr_reader :min_value
attr_reader :max_value
PLUS_MINUS_SIZE = 16
SLIDER_PADDING = 6 # Gap between sides of interactive area for slider and drawn slider bar
MINUS_X = 0
SLIDER_X = MINUS_X + PLUS_MINUS_SIZE + SLIDER_PADDING
SLIDER_LENGTH = 128
PLUS_X = SLIDER_X + SLIDER_LENGTH + SLIDER_PADDING
VALUE_X = PLUS_X + PLUS_MINUS_SIZE + 5
TEXT_OFFSET_Y = 5
# TODO: Is there a better knob design than a big black rectangle? I'd rather
# it not be a different colour.
SLIDER_KNOB_COLOR = Color.black
def initialize(width, height, viewport, min_value, max_value, value)
super(width, height, viewport)
@min_value = min_value
@max_value = max_value
self.value = value
end
def value=(new_value)
old_val = @value
@value = new_value.to_i.clamp(self.min_value, self.max_value)
self.invalidate if @value != old_val
end
def min_value=(new_min)
return if new_min == @min_value
@min_value = new_min
@value = @value.clamp(self.min_value, self.max_value)
self.invalidate
end
def max_value=(new_max)
return if new_max == @max_value
@max_value = new_max
@value = @value.clamp(self.min_value, self.max_value)
self.invalidate
end
def set_interactive_rects
@slider_rect = Rect.new(SLIDER_X - SLIDER_PADDING, (self.height - PLUS_MINUS_SIZE) / 2, SLIDER_LENGTH + (SLIDER_PADDING * 2), PLUS_MINUS_SIZE)
@minus_rect = Rect.new(MINUS_X, (self.height - PLUS_MINUS_SIZE) / 2, PLUS_MINUS_SIZE, PLUS_MINUS_SIZE)
@plus_rect = Rect.new(PLUS_X, (self.height - PLUS_MINUS_SIZE) / 2, PLUS_MINUS_SIZE, PLUS_MINUS_SIZE)
@interactions = {
:slider => @slider_rect,
:minus => @minus_rect,
:plus => @plus_rect
}
end
#-----------------------------------------------------------------------------
def draw_area_highlight
# Don't want to ever highlight the slider with the capture color, because
# the mouse doesn't need to be on the slider to change this control's value
if @captured_area == :slider
rect = @interactions[@captured_area]
self.bitmap.fill_rect(rect.x, rect.y, rect.width, rect.height, HOVER_COLOR) if rect
else
super
end
end
def refresh
super
# Draw minus button
self.bitmap.fill_rect(@minus_rect.x + 2, @minus_rect.y + (@minus_rect.height / 2) - 2, @minus_rect.width - 4, 4, self.bitmap.font.color)
# Draw slider bar
self.bitmap.fill_rect(SLIDER_X, (self.height / 2) - 1, SLIDER_LENGTH, 2, self.bitmap.font.color)
# Draw notches on slider bar
5.times do |i|
self.bitmap.fill_rect(SLIDER_X - 1 + (i * SLIDER_LENGTH / 4), (self.height / 2) - 2, 2, 4, self.bitmap.font.color)
end
# Draw slider knob
fraction = (self.value - self.min_value) / self.max_value.to_f
knob_x = (SLIDER_LENGTH * fraction).to_i
self.bitmap.fill_rect(SLIDER_X + knob_x - 4, (self.height / 2) - 6, 8, 12, SLIDER_KNOB_COLOR)
# Draw plus button
self.bitmap.fill_rect(@plus_rect.x + 2, @plus_rect.y + (@plus_rect.height / 2) - 2, @plus_rect.width - 4, 4, self.bitmap.font.color)
self.bitmap.fill_rect(@plus_rect.x + (@plus_rect.width / 2) - 2, @plus_rect.y + 2, 4, @plus_rect.height - 4, self.bitmap.font.color)
# Draw value text
draw_text(self.bitmap, VALUE_X, TEXT_OFFSET_Y, self.value.to_s)
end
#-----------------------------------------------------------------------------
def on_mouse_press
super
@initial_value = @value if @captured_area
end
def on_mouse_release
return if !@captured_area # Wasn't captured to begin with
set_changed if @initial_value && @value != @initial_value
@initial_value = nil
super
end
def update
return if !self.visible
super
# TODO: Disabled control stuff.
# return if self.disabled
case @captured_area
when :minus
# Constant decrement of value while pressing the minus button
if @hover_area == @captured_area && Input.repeat?(Input::MOUSELEFT)
self.value -= 1
end
when :plus
# Constant incrementing of value while pressing the plus button
if @hover_area == @captured_area && Input.repeat?(Input::MOUSELEFT)
self.value += 1
end
when :slider
# Constant updating of value depending on mouse's x position
mouse_x, mouse_y = mouse_pos
return if !mouse_x || !mouse_y
self.value = lerp(self.min_value, self.max_value + (self.max_value & 1), SLIDER_LENGTH, mouse_x - SLIDER_X)
end
end
end

View File

@@ -0,0 +1,140 @@
#===============================================================================
#
#===============================================================================
class UIControls::NumberTextBox < UIControls::TextBox
attr_reader :min_value
attr_reader :max_value
PLUS_MINUS_SIZE = 16
CONTROL_PADDING = 2 # Gap between buttons and text box
MINUS_X = 0
TEXT_BOX_X = MINUS_X + PLUS_MINUS_SIZE + CONTROL_PADDING
TEXT_BOX_WIDTH = 64
TEXT_BOX_HEIGHT = 24
PLUS_X = TEXT_BOX_X + TEXT_BOX_WIDTH + CONTROL_PADDING
def initialize(width, height, viewport, min_value, max_value, value)
super(width, height, viewport, value)
@min_value = min_value
@max_value = max_value
self.value = value
end
def value=(new_value)
old_val = @value
@value = new_value.to_i.clamp(self.min_value, self.max_value)
self.invalidate if @value != old_val
end
def min_value=(new_min)
return if new_min == @min_value
@min_value = new_min
@value = @value.clamp(self.min_value, self.max_value)
self.invalidate
end
def max_value=(new_max)
return if new_max == @max_value
@max_value = new_max
@value = @value.clamp(self.min_value, self.max_value)
self.invalidate
end
# TODO: If current value is 0, replace it with ch instead of inserting ch?
def insert_char(ch)
self.value = @value.to_s.insert(@cursor_pos, ch).to_i
@cursor_pos += 1
@cursor_pos = @cursor_pos.clamp(0, @value.to_s.length)
@cursor_timer = System.uptime
@cursor_shown = true
invalidate
end
def delete_at(index)
new_val = @value.to_s
new_val.slice!(index)
self.value = new_val.to_i
@cursor_pos -= 1 if @cursor_pos > index
@cursor_pos = @cursor_pos.clamp(0, @value.to_s.length)
@cursor_timer = System.uptime
@cursor_shown = true
invalidate
end
def set_interactive_rects
@text_box_rect = Rect.new(TEXT_BOX_X, (height - TEXT_BOX_HEIGHT) / 2,
TEXT_BOX_WIDTH, TEXT_BOX_HEIGHT)
@minus_rect = Rect.new(MINUS_X, (self.height - PLUS_MINUS_SIZE) / 2, PLUS_MINUS_SIZE, PLUS_MINUS_SIZE)
@plus_rect = Rect.new(PLUS_X, (self.height - PLUS_MINUS_SIZE) / 2, PLUS_MINUS_SIZE, PLUS_MINUS_SIZE)
@interactions = {
:text_box => @text_box_rect,
:minus => @minus_rect,
:plus => @plus_rect
}
end
#-----------------------------------------------------------------------------
def refresh
super
# Draw minus button
self.bitmap.fill_rect(@minus_rect.x + 2, @minus_rect.y + (@minus_rect.height / 2) - 2, @minus_rect.width - 4, 4, self.bitmap.font.color)
# Draw plus button
self.bitmap.fill_rect(@plus_rect.x + 2, @plus_rect.y + (@plus_rect.height / 2) - 2, @plus_rect.width - 4, 4, self.bitmap.font.color)
self.bitmap.fill_rect(@plus_rect.x + (@plus_rect.width / 2) - 2, @plus_rect.y + 2, 4, @plus_rect.height - 4, self.bitmap.font.color)
end
#-----------------------------------------------------------------------------
def on_mouse_press
@captured_area = nil
super
if @captured_area == :text_box
# Clicked into the text box; put the text cursor in there
@cursor_pos = get_cursor_index_from_mouse_position
@cursor_timer = System.uptime
invalidate
elsif @captured_area
@initial_value = @value
else
set_changed if @initial_value && @value != @initial_value
reset_interaction
end
end
def update_text_entry
ret = false
Input.gets.each_char do |ch|
next if !["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-"].include?(ch)
if ch == "-"
next if @min_value >= 0 || @cursor_pos > 1 || (@cursor_pos > 0 && @value >= 0)
if @value < 0
delete_at(0) # Remove the negative sign
ret = true
next
end
end
insert_char(ch)
ret = true
end
return ret
end
def update
return if !self.visible
super
case @captured_area
when :minus
# Constant decrement of value while pressing the minus button
if @hover_area == @captured_area && Input.repeat?(Input::MOUSELEFT)
self.value -= 1
end
when :plus
# Constant incrementing of value while pressing the plus button
if @hover_area == @captured_area && Input.repeat?(Input::MOUSELEFT)
self.value += 1
end
end
end
end

View File

@@ -0,0 +1,75 @@
#===============================================================================
#
#===============================================================================
class UIControls::Button < UIControls::BaseControl
BUTTON_X = 2
BUTTON_Y = 2
BUTTON_PADDING = 10
BUTTON_HEIGHT = 28
# TODO: This will also depend on the font size.
TEXT_BASE_OFFSET_Y = 18 # Text is centred vertically in the button
def initialize(width, height, viewport, text = "")
super(width, height, viewport)
@text = text
@fixed_size = false
end
def set_fixed_size
@fixed_size = true
end
def set_interactive_rects
button_width = (@fixed_size) ? width - (BUTTON_X * 2) : self.bitmap.text_size(@text).width + (BUTTON_PADDING * 2)
button_height = (@fixed_size) ? height - (2 * BUTTON_Y) : BUTTON_HEIGHT
button_height = [button_height, height - (2 * BUTTON_Y)].min
@button_rect = Rect.new(BUTTON_X, (height - button_height) / 2, button_width, button_height)
@interactions = {
:button => @button_rect
}
end
def set_changed
@value = true
super
end
def clear_changed
@value = false
super
end
#-----------------------------------------------------------------------------
def refresh
super
# Draw button outline
self.bitmap.outline_rect(@button_rect.x, @button_rect.y,
@button_rect.width, @button_rect.height,
self.bitmap.font.color)
# TODO: Make buttons look more different to text boxes?
# shade = self.bitmap.font.color.clone
# shade.alpha = 96
# self.bitmap.outline_rect(@button_rect.x + 1, @button_rect.y + 1,
# @button_rect.width - 2, @button_rect.height - 2,
# shade, 3)
# Draw button text
draw_text_centered(self.bitmap, @button_rect.x,
@button_rect.y + (@button_rect.height - TEXT_BASE_OFFSET_Y) / 2,
@button_rect.width, @text)
end
#-----------------------------------------------------------------------------
def on_mouse_release
return if !@captured_area # Wasn't captured to begin with
# Change this control's value
if @captured_area == :button
mouse_x, mouse_y = mouse_pos
if mouse_x && mouse_y && @interactions[@captured_area].contains?(mouse_x, mouse_y)
set_changed
end
end
super # Make this control not busy again
end
end

View File

@@ -0,0 +1,207 @@
#===============================================================================
# TODO: Do I need to split self's bitmap into two (one for highlights and one
# for text)? This would be to reduce lag caused by redrawing text even if
# you're just waving the mouse over the control. There doesn't seem to be
# any lag at the moment with a tall list.
# TODO: Make a viewport for the list, and allow scrolling positions halfway
# through a line? Nah.
#===============================================================================
class UIControls::List < UIControls::BaseControl
LIST_X = 0
LIST_Y = 0
ROW_HEIGHT = 24
TEXT_PADDING_X = 4
TEXT_OFFSET_Y = 3
SELECTED_ROW_COLOR = Color.green
def initialize(width, height, viewport, values = [])
super(width, height, viewport)
@scrollbar = UIControls::Scrollbar.new(LIST_X + width - UIControls::Scrollbar::SLIDER_WIDTH, LIST_Y, height, viewport)
@scrollbar.set_interactive_rects
@scrollbar.range = ROW_HEIGHT
@scrollbar.z = self.z + 1
@rows_count = (height / ROW_HEIGHT).floor # Number of rows visible at once
@top_row = 0
@selected = -1
self.values = values
end
def dispose
@scrollbar.dispose
@scrollbar = nil
super
end
def x=(new_val)
super(new_val)
@scrollbar.x = new_val + LIST_X + width - UIControls::Scrollbar::SLIDER_WIDTH
end
def y=(new_val)
super(new_val)
@scrollbar.y = new_val + LIST_Y
end
# Each value in @values is an array: [id, text].
def values=(new_vals)
@values = new_vals
set_interactive_rects
@scrollbar.range = @values.length * ROW_HEIGHT
if @scrollbar.visible
self.top_row = (@scrollbar.position.to_f / ROW_HEIGHT).round
else
self.top_row = 0
end
self.selected = -1 if @selected >= @values.length
invalidate
end
def top_row=(val)
old_val = @top_row
@top_row = val
if @scrollbar.visible
@top_row = @top_row.clamp(0, @values.length - @rows_count)
else
@top_row = 0
end
invalidate if @top_row != old_val
end
def selected=(val)
return if @selected == val
@selected = val
invalidate
end
# Returns the ID of the selected row.
def value
return nil if @selected < 0
return @values[@selected][0]
end
def set_interactive_rects
@interactions = {}
@values.length.times do |i|
@interactions[i] = Rect.new(LIST_X, LIST_Y + (ROW_HEIGHT * i), width - LIST_X, ROW_HEIGHT)
end
end
#-----------------------------------------------------------------------------
def busy?
return !@captured_area.nil?
end
#-----------------------------------------------------------------------------
def draw_area_highlight
# If a row is captured, it will automatically be selected and the selection
# colour will be drawn over the highlight. There's no point drawing a
# highlight at all if anything is captured.
return if @captured_area
# Draw mouse hover over row highlight
rect = @interactions[@hover_area]
if rect
rect_y = rect.y
rect_y -= @top_row * ROW_HEIGHT if @hover_area.is_a?(Integer)
self.bitmap.fill_rect(rect.x, rect_y, rect.width, rect.height, HOVER_COLOR)
end
end
def repaint
@scrollbar.repaint if @scrollbar.invalid?
super if invalid?
end
def refresh
super
# Draw text options
@values.each_with_index do |val, i|
next if i < @top_row || i >= @top_row + @rows_count
if @selected == i
self.bitmap.fill_rect(
@interactions[i].x,
@interactions[i].y - (@top_row * ROW_HEIGHT),
@interactions[i].width, @interactions[i].height,
SELECTED_ROW_COLOR
)
end
draw_text(self.bitmap,
@interactions[i].x + TEXT_PADDING_X,
@interactions[i].y + TEXT_OFFSET_Y - (@top_row * ROW_HEIGHT),
val[1])
end
end
#-----------------------------------------------------------------------------
def on_mouse_press
@captured_area = nil
mouse_x, mouse_y = mouse_pos
return if !mouse_x || !mouse_y
return if @scrollbar.visible && (@scrollbar.busy? || mouse_x >= @scrollbar.x - self.x)
# Check for mouse presses on rows
mouse_y += @top_row * ROW_HEIGHT
@interactions.each_pair do |area, rect|
next if !area.is_a?(Integer) || area < @top_row || area >= @top_row + @rows_count
next if !rect.contains?(mouse_x, mouse_y)
@captured_area = area
invalidate
break
end
end
def on_mouse_release
return if !@captured_area # Wasn't captured to begin with
set_changed
super
end
def update_hover_highlight
# Remove the hover highlight if there are no interactions for this control
# or if the mouse is off-screen
mouse_x, mouse_y = mouse_pos
if !@interactions || @interactions.empty? || !mouse_x || !mouse_y
invalidate if @hover_area
@hover_area = nil
return
end
# Don't update the highlight if the mouse is using the scrollbar
if @scrollbar.visible && (@scrollbar.busy? || mouse_x >= @scrollbar.x - self.x)
invalidate if @hover_area
@hover_area = nil
return
end
# Check each interactive area for whether the mouse is hovering over it, and
# set @hover_area accordingly
in_area = false
mouse_y += @top_row * ROW_HEIGHT
@interactions.each_pair do |area, rect|
next if !area.is_a?(Integer) || area < @top_row || area >= @top_row + @rows_count
next if !rect.contains?(mouse_x, mouse_y)
invalidate if @hover_area != area
@hover_area = area
in_area = true
break
end
if !in_area
invalidate if @hover_area
@hover_area = nil
end
end
def update
return if !self.visible
@scrollbar.update
super
# TODO: Disabled control stuff.
# return if self.disabled
# Refresh the list's position if changed by moving the scrollbar
self.top_row = (@scrollbar.position.to_f / ROW_HEIGHT).round
# Set the selected row to the row the mouse is over, if clicked on
if @captured_area
@selected = @hover_area if @hover_area.is_a?(Integer)
end
end
end

View File

@@ -0,0 +1,9 @@
#===============================================================================
# TODO
#===============================================================================
class UIControls::DropdownList < UIControls::BaseControl
def initialize(width, height, viewport, options, value)
# NOTE: options is a hash: keys are symbols, values are display names.
super(width, height, viewport)
end
end

View File

@@ -0,0 +1,156 @@
#===============================================================================
# TODO: Make the slider a separate sprite that moves, instead of redrawing this
# sprite's bitmap whenever it moves? Intended to reduce lag. There doesn't
# seem to be any lag at the moment with a tall scrollbar.
#===============================================================================
class UIControls::Scrollbar < UIControls::BaseControl
SLIDER_WIDTH = 16
WIDTH_PADDING = 0
SCROLL_DISTANCE = 16
TRAY_COLOR = Color.white
SLIDER_COLOR = Color.black
GRAB_COLOR = HOVER_COLOR # Cyan
attr_reader :slider_top
def initialize(x, y, size, viewport, horizontal = false, always_visible = false)
if horizontal
super(size, SLIDER_WIDTH, viewport)
else
super(SLIDER_WIDTH, size, viewport)
end
self.x = x
self.y = y
@horizontal = horizontal # Is vertical if not horizontal
@tray_size = size # Number of pixels the scrollbar can move around in
@slider_size = size
@range = size # Total distance of the area this scrollbar is for
@slider_top = 0 # Top pixel within @size of the scrollbar
@always_visible = always_visible
self.visible = @always_visible
end
def position
return 0 if @range <= @tray_size
return (@range - @tray_size) * @slider_top / (@tray_size - @slider_size)
end
# Range is the total size of the large area that the scrollbar is able to
# show part of.
def range=(new_val)
raise "Can't set a scrollbar's range to 0!" if new_val == 0
@range = new_val
@slider_size = (@tray_size * [@tray_size.to_f / @range, 1].min).round
if @horizontal
@slider.width = @slider_size
else # Vertical
@slider.height = @slider_size
end
self.slider_top = @slider_top
self.visible = (@always_visible || @range > @tray_size)
invalidate
end
def slider_top=(new_val)
old_val = @slider_top
@slider_top = new_val.clamp(0, @tray_size - @slider_size)
if @horizontal
@slider.x = @slider_top
else # Vertical
@slider.y = @slider_top
end
invalidate if @slider_top != old_val
end
def set_interactive_rects
@interactions = {}
if @horizontal
@slider = Rect.new(@slider_top, WIDTH_PADDING, @slider_size, height - (WIDTH_PADDING * 2))
else # Vertical
@slider = Rect.new(WIDTH_PADDING, @slider_top, width - (WIDTH_PADDING * 2), @slider_size)
end
@interactions[:slider] = @slider
@slider_tray = Rect.new(0, 0, width, height)
@interactions[:slider_tray] = @slider_tray
end
#-----------------------------------------------------------------------------
def refresh
super
return if !self.visible
# Draw the tray
self.bitmap.fill_rect(@slider_tray.x, @slider_tray.y, @slider_tray.width, @slider_tray.height, TRAY_COLOR)
# Draw the slider
if @slider_size < @tray_size
bar_color = SLIDER_COLOR
if @captured_area == :slider || (!@captured_area && @hover_area == :slider)
bar_color = GRAB_COLOR
end
self.bitmap.fill_rect(@slider.x, @slider.y, @slider.width, @slider.height, bar_color)
end
end
#-----------------------------------------------------------------------------
def on_mouse_press
@captured_area = nil
mouse_x, mouse_y = mouse_pos
return if !mouse_x || !mouse_y
# Check for mouse presses on slider/slider tray
@interactions.each_pair do |area, rect|
next if !rect.contains?(mouse_x, mouse_y)
@captured_area = area
if area == :slider
if @horizontal
@slider_mouse_offset = mouse_x - rect.x
else
@slider_mouse_offset = mouse_y - rect.y
end
end
invalidate
break
end
end
def on_mouse_release
super if @captured_area
end
def update
return if !self.visible
super
# TODO: Disabled control stuff.
# return if self.disabled
if @captured_area == :slider
# TODO: Have a display y position for the slider bar which is in pixels,
# and round it to the nearest row when setting @top_row? This is
# just to make the slider bar movement smoother.
mouse_x, mouse_y = mouse_pos
return if !mouse_x || !mouse_y
long_coord = (@horizontal) ? mouse_x : mouse_y
self.slider_top = long_coord - @slider_mouse_offset
elsif @captured_area == :slider_tray
if Input.repeat?(Input::MOUSELEFT) && @hover_area == :slider_tray
mouse_x, mouse_y = mouse_pos
return if !mouse_x || !mouse_y
long_coord = (@horizontal) ? mouse_x : mouse_y
if long_coord < @slider_top
self.slider_top = @slider_top - ((@tray_size - @slider_size) / 4.0).ceil
else
self.slider_top = @slider_top + ((@tray_size - @slider_size) / 4.0).ceil
end
end
else
mouse_x, mouse_y = mouse_pos
if mouse_x && mouse_y && @interactions[:slider_tray].contains?(mouse_x, mouse_y)
wheel_v = Input.scroll_v
if wheel_v > 0 # Scroll up
self.slider_top -= SCROLL_DISTANCE
elsif wheel_v < 0 # Scroll down
self.slider_top += SCROLL_DISTANCE
end
end
end
end
end