├── .rspec ├── lib ├── fidgit │ ├── version.rb │ ├── chingu_ext │ │ └── window.rb │ ├── elements │ │ ├── horizontal.rb │ │ ├── vertical.rb │ │ ├── composite.rb │ │ ├── main_packer.rb │ │ ├── tool_tip.rb │ │ ├── color_well.rb │ │ ├── list.rb │ │ ├── packer.rb │ │ ├── group.rb │ │ ├── color_picker.rb │ │ ├── image_frame.rb │ │ ├── toggle_button.rb │ │ ├── scroll_area.rb │ │ ├── radio_button.rb │ │ ├── text_line.rb │ │ ├── scroll_window.rb │ │ ├── combo_box.rb │ │ ├── label.rb │ │ ├── button.rb │ │ ├── slider.rb │ │ ├── scroll_bar.rb │ │ ├── menu_pane.rb │ │ ├── file_browser.rb │ │ ├── container.rb │ │ ├── grid.rb │ │ ├── element.rb │ │ └── text_area.rb │ ├── window.rb │ ├── gosu_ext │ │ ├── image.rb │ │ └── color.rb │ ├── standard_ext │ │ └── hash.rb │ ├── states │ │ ├── file_dialog.rb │ │ ├── dialog_state.rb │ │ ├── message_dialog.rb │ │ └── gui_state.rb │ ├── cursor.rb │ ├── selection.rb │ ├── redirector.rb │ ├── history.rb │ ├── schema.rb │ └── event.rb └── fidgit.rb ├── media └── images │ ├── arrow.png │ ├── pixel.png │ ├── combo_arrow.png │ ├── file_file.png │ └── file_directory.png ├── .gitignore ├── Gemfile ├── examples ├── media │ └── images │ │ └── head_icon.png ├── _all_examples.rb ├── color_picker_example.rb ├── color_well_example.rb ├── combo_box_example.rb ├── helpers │ └── example_window.rb ├── list_example.rb ├── menu_pane_example.rb ├── radio_button_example.rb ├── text_area_example.rb ├── readme_example.rb ├── grid_packer_example.rb ├── label_example.rb ├── slider_example.rb ├── splash_example.rb ├── button_and_toggle_button_example.rb ├── file_dialog_example.rb ├── scroll_window_example.rb ├── align_example.rb └── message_dialog_example.rb ├── Rakefile ├── spec └── fidgit │ ├── elements │ ├── helpers │ │ └── helper.rb │ ├── label_spec.rb │ └── image_frame_spec.rb │ ├── gosu_ext │ ├── helpers │ │ └── helper.rb │ └── color_spec.rb │ ├── helpers │ └── helper.rb │ ├── schema_test.yml │ ├── schema_spec.rb │ ├── redirector_spec.rb │ ├── history_spec.rb │ └── event_spec.rb ├── CHANGELOG.md ├── LICENSE.txt ├── fidgit.gemspec ├── config └── default_schema.yml └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | --format documentation 3 | -------------------------------------------------------------------------------- /lib/fidgit/version.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | VERSION = '0.2.7' 3 | end 4 | -------------------------------------------------------------------------------- /media/images/arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gosu/fidgit/HEAD/media/images/arrow.png -------------------------------------------------------------------------------- /media/images/pixel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gosu/fidgit/HEAD/media/images/pixel.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg/* 2 | *.gem 3 | .bundle 4 | Gemfile.lock 5 | doc/ 6 | .yardoc 7 | README.html 8 | .idea/ -------------------------------------------------------------------------------- /media/images/combo_arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gosu/fidgit/HEAD/media/images/combo_arrow.png -------------------------------------------------------------------------------- /media/images/file_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gosu/fidgit/HEAD/media/images/file_file.png -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in fidgit.gemspec 4 | gemspec -------------------------------------------------------------------------------- /media/images/file_directory.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gosu/fidgit/HEAD/media/images/file_directory.png -------------------------------------------------------------------------------- /examples/media/images/head_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gosu/fidgit/HEAD/examples/media/images/head_icon.png -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | 4 | # Specs 5 | desc "Run rspec 2.0" 6 | task :rspec do 7 | system "rspec spec" 8 | end -------------------------------------------------------------------------------- /spec/fidgit/elements/helpers/helper.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | 3 | require_relative File.join(File.dirname(__FILE__), "..", "..", "..", "..", "lib", "fidgit") -------------------------------------------------------------------------------- /spec/fidgit/gosu_ext/helpers/helper.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | 3 | require_relative File.join(File.dirname(__FILE__), "..", "..", "..", "..", "lib", "fidgit") -------------------------------------------------------------------------------- /spec/fidgit/helpers/helper.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require "fileutils" 3 | 4 | $LOAD_PATH.unshift File.expand_path(File.join(File.dirname(__FILE__), "..", "..", "..", "lib", "fidgit")) -------------------------------------------------------------------------------- /lib/fidgit/chingu_ext/window.rb: -------------------------------------------------------------------------------- 1 | module Chingu 2 | class Window 3 | # Include fidgits extra functionality in the Chingu window (which is minimal). 4 | include Fidgit::Window 5 | end 6 | end -------------------------------------------------------------------------------- /lib/fidgit/elements/horizontal.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | # A vertically aligned element packing container. 3 | class Horizontal < Grid 4 | def initialize(options = {}) 5 | options[:num_rows] = 1 6 | 7 | super options 8 | end 9 | end 10 | end -------------------------------------------------------------------------------- /lib/fidgit/elements/vertical.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | # A vertically aligned element packing container. 3 | class Vertical < Grid 4 | def initialize(options = {}) 5 | options[:num_columns] = 1 6 | 7 | super options 8 | end 9 | end 10 | end -------------------------------------------------------------------------------- /lib/fidgit/window.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | module Window 3 | 4 | def self.included(base) 5 | base.send :include, Methods 6 | end 7 | 8 | module Methods 9 | 10 | def close 11 | super 12 | GuiState.clear 13 | end 14 | end 15 | 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/fidgit/gosu_ext/image.rb: -------------------------------------------------------------------------------- 1 | module Gosu 2 | EmptyImageSource = Struct.new(:columns, :rows) do 3 | def to_blob 4 | "\0\0\0\0" * (columns * rows) 5 | end 6 | end 7 | 8 | class Image 9 | def self.create(width, height) 10 | self.new(EmptyImageSource.new(width, height)) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /examples/_all_examples.rb: -------------------------------------------------------------------------------- 1 | # Run all examples, one after the other 2 | require_relative 'helpers/example_window' 3 | 4 | 5 | examples = Dir.glob(File.join(File.dirname(__FILE__), "*.rb")) - [__FILE__] 6 | examples.each_with_index do |file_name, index| 7 | ENV['FIDGIT_EXAMPLES_TEXT'] = "[#{index + 1} of #{examples.size + 1}]" 8 | `ruby #{file_name}` 9 | end 10 | -------------------------------------------------------------------------------- /examples/color_picker_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | class ExampleState < Fidgit::GuiState 4 | def initialize 5 | super 6 | 7 | vertical do 8 | my_label = label 'No color picked' 9 | 10 | color_picker(width: 100) do |sender, color| 11 | my_label.text = color.to_s 12 | end 13 | end 14 | end 15 | end 16 | 17 | ExampleWindow.new.show -------------------------------------------------------------------------------- /lib/fidgit/elements/composite.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | # A composite element, made up of other elements (but manages them internally). 3 | class Composite < Packer 4 | DEBUG_BORDER_COLOR = Gosu::Color.rgba(0, 255, 0, 100) # Color to draw an outline in when debugging layout. 5 | 6 | # @param (see Element#initialize) 7 | # 8 | # @option (see Element#initialize) 9 | def initialize(options = {}) 10 | options[:border_color] = DEBUG_BORDER_COLOR if Fidgit.debug_mode? 11 | 12 | super(options) 13 | end 14 | end 15 | end -------------------------------------------------------------------------------- /examples/color_well_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | class ExampleState < Fidgit::GuiState 4 | def initialize 5 | super 6 | 7 | vertical do 8 | my_label = label "No color selected." 9 | 10 | group do 11 | grid num_columns: 15, padding: 0, spacing: 4 do 12 | 150.times do 13 | color_well(color: Gosu::Color.rgb(rand(255), rand(255), rand(255))) 14 | end 15 | end 16 | 17 | subscribe :changed do |sender, color| 18 | my_label.text = color.to_s 19 | end 20 | end 21 | end 22 | end 23 | end 24 | 25 | ExampleWindow.new.show -------------------------------------------------------------------------------- /examples/combo_box_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | # Example for Button and ToggleButton 4 | class ExampleState < Fidgit::GuiState 5 | def initialize 6 | super 7 | 8 | vertical do 9 | my_label = label "Label", tip: "I'm a label" 10 | 11 | combo_box(value: 1, tip: "I'm a combo box; press me and make a selection!") do 12 | subscribe :changed do |sender, value| 13 | my_label.text = "Chose #{value}!" 14 | end 15 | 16 | item "One", 1 17 | item "Two", 2 18 | item "Three", 3 19 | end 20 | end 21 | end 22 | end 23 | 24 | ExampleWindow.new.show -------------------------------------------------------------------------------- /examples/helpers/example_window.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../lib/fidgit' 2 | 3 | media_dir = File.expand_path(File.join(File.dirname(__FILE__), '..', 'media')) 4 | Gosu::Image.autoload_dirs << File.join(media_dir, 'images') 5 | Gosu::Sample.autoload_dirs << File.join(media_dir, 'samples') 6 | Gosu::Font.autoload_dirs << File.join(media_dir, 'fonts') 7 | 8 | class ExampleWindow < Chingu::Window 9 | def initialize(options = {}) 10 | super(640, 480, false) 11 | 12 | on_input(:escape) { close } 13 | 14 | caption = "#{File.basename($0).chomp(".rb").tr('_', ' ')} #{ENV['FIDGIT_EXAMPLES_TEXT']}" 15 | push_game_state ExampleState 16 | end 17 | end -------------------------------------------------------------------------------- /examples/list_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | # Labels can have text and/or icons. 4 | class ExampleState < Fidgit::GuiState 5 | def initialize 6 | super 7 | 8 | vertical do 9 | my_label = label "No clicky" 10 | 11 | list do 12 | item "chunky bacon", :CHUNKYBACON, tip: "You prefer Chunky Bacon, don't you?" 13 | item "lentils", :LENTILS, tip: "Lentils? Well, I suppose someone has to like them" 14 | 15 | subscribe :changed do |sender, value| 16 | my_label.text = "I like #{value} more than anything in the world!" 17 | end 18 | end 19 | end 20 | end 21 | end 22 | 23 | ExampleWindow.new.show -------------------------------------------------------------------------------- /lib/fidgit/elements/main_packer.rb: -------------------------------------------------------------------------------- 1 | require_relative "vertical" 2 | 3 | module Fidgit 4 | # Main container that can contains a single "proper" packing element. 5 | class MainPacker < Vertical 6 | def initialize(options = {}) 7 | options = { 8 | width: $window.width, 9 | height: $window.height, 10 | }.merge! options 11 | 12 | super options 13 | end 14 | 15 | def width; $window.width; end 16 | def height; $window.height; end 17 | def width=(value); ; end 18 | def height=(value); ; end 19 | 20 | def add(element) 21 | raise "MainPacker can only contain packing elements" unless element.is_a? Packer 22 | super(element) 23 | end 24 | end 25 | end -------------------------------------------------------------------------------- /lib/fidgit/standard_ext/hash.rb: -------------------------------------------------------------------------------- 1 | class Hash 2 | # Merge not only the hashes, but all nested hashes as well. 3 | # Written by Stefan Rusterholz (apeiros) from http://www.ruby-forum.com/topic/142809 4 | def deep_merge!(other) 5 | merger = lambda do |key, a, b| 6 | (a.is_a?(Hash) && b.is_a?(Hash)) ? a.merge!(b, &merger) : b 7 | end 8 | 9 | merge!(other, &merger) 10 | end 11 | 12 | # Merge not only the hashes, but all nested hashes as well. 13 | # Written by Stefan Rusterholz (apeiros) from http://www.ruby-forum.com/topic/142809 14 | def deep_merge(other) 15 | merger = lambda do |key, a, b| 16 | (a.is_a?(Hash) && b.is_a?(Hash)) ? a.merge(b, &merger) : b 17 | end 18 | 19 | merge(other, &merger) 20 | end 21 | end -------------------------------------------------------------------------------- /lib/fidgit/states/file_dialog.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | # A simple dialog that manages a message with a set of buttons beneath it. 3 | class FileDialog < DialogState 4 | def initialize(type, options = {}, &block) 5 | options = { 6 | show: true, 7 | background_color: DEFAULT_BACKGROUND_COLOR, 8 | border_color: DEFAULT_BORDER_COLOR, 9 | }.merge! options 10 | 11 | super(options) 12 | 13 | vertical align: :center, padding: 0 do |packer| 14 | FileBrowser.new(type, { parent: packer }.merge!(options)) do |sender, result, file_name| 15 | hide 16 | block.call result, file_name if block 17 | end 18 | end 19 | 20 | show if options[:show] 21 | end 22 | end 23 | end 24 | 25 | -------------------------------------------------------------------------------- /spec/fidgit/schema_test.yml: -------------------------------------------------------------------------------- 1 | # Test case schema 2 | --- 3 | 4 | # Define all constant values here. For colours, use [R, G, B] or [R, G, B, A]. 5 | # Reference constants with ?constant_name 6 | :constants: 7 | # General constants (strings and numbers). 8 | :scroll_bar_thickness: 12 9 | 10 | # Gosu::Color constants 11 | :none: [0, 0, 0, 0] 12 | :white: [255, 255, 255] 13 | :gray: [100, 100, 100] 14 | :dark_gray: [50, 50, 50] 15 | 16 | # Default element attributes. 17 | :elements: 18 | :Button: 19 | :background_color: ?gray 20 | 21 | :disabled: 22 | :background_color: ?dark_gray 23 | 24 | :Element: 25 | :color: ?white 26 | :align_h: :left 27 | 28 | :HorizontalScrollBar: 29 | :height: ?scroll_bar_thickness 30 | 31 | :VerticalScrollBar: 32 | :width: ?scroll_bar_thickness 33 | -------------------------------------------------------------------------------- /examples/menu_pane_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | # Labels can have text and/or icons. 4 | class ExampleState < Fidgit::GuiState 5 | def initialize 6 | super 7 | 8 | vertical do 9 | my_label = label "Right click to open context menu" 10 | 11 | my_label.subscribe :released_right_mouse_button do 12 | menu do 13 | item "Chunky bacon", :CHUNKY_BACON, shortcut_text: "Ctrl-^-*" 14 | separator 15 | item "Lentils", :LENTILS, shortcut_text: "Alt-F15" 16 | item "Tepid gruel", :GRUEL, shortcut_text: "CenterMeta" 17 | 18 | subscribe :selected do |sender, value| 19 | my_label.text = "I like #{value} more than anything. Mmmm!" 20 | end 21 | end 22 | end 23 | end 24 | end 25 | end 26 | 27 | ExampleWindow.new.show -------------------------------------------------------------------------------- /examples/radio_button_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | class ExampleState < Fidgit::GuiState 4 | def initialize 5 | super 6 | 7 | vertical do 8 | my_label = label "No button selected" 9 | 10 | button("Deselect") do 11 | @group.value = nil 12 | end 13 | 14 | button("Select #7") do 15 | @group.value = 7 16 | end 17 | 18 | @group = group do 19 | grid num_columns: 5, padding: 0 do 20 | 15.times do |i| 21 | radio_button "##{i}", i, width: 60 22 | end 23 | end 24 | 25 | subscribe :changed do |sender, value| 26 | my_label.text = if value 27 | "Button #{value.to_s} selected" 28 | else 29 | "No button selected" 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | 37 | ExampleWindow.new.show -------------------------------------------------------------------------------- /examples/text_area_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | class ExampleState < Fidgit::GuiState 4 | def initialize 5 | super 6 | 7 | string = "Hello, my name is Brian the snail!" 8 | horizontal do 9 | vertical do 10 | label 'disabled' 11 | text_area(text: "Can't even select this text", width: 200, enabled: false) 12 | end 13 | 14 | vertical do 15 | label 'mirrors to right' 16 | text_area(width: 200, text: string) do |_, text| 17 | @mirror.text = text 18 | end 19 | end 20 | 21 | vertical do 22 | my_label = label 'not editable' 23 | font = Gosu::Font.new($window, "", my_label.font.height) 24 | font["a"] = Gosu::Image["head_icon.png"] 25 | @mirror = text_area(text: string, width: 200, editable: false, font: font) 26 | end 27 | end 28 | end 29 | end 30 | 31 | ExampleWindow.new.show -------------------------------------------------------------------------------- /lib/fidgit/cursor.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | class Cursor < Chingu::GameObject 3 | ARROW = 'arrow.png' 4 | HAND = 'hand.png' 5 | 6 | def initialize(options = {}) 7 | options = { 8 | image: Gosu::Image[ARROW], 9 | rotation_center: :top_left, 10 | zorder: Float::INFINITY 11 | }.merge!(options) 12 | 13 | super(options) 14 | 15 | nil 16 | end 17 | 18 | # Is the mouse pointer position inside the game window pane? 19 | def inside_window? 20 | x >= 0 and y >= 0 and x < $window.width and y < $window.height 21 | end 22 | 23 | def update 24 | self.x, self.y = $window.mouse_x, $window.mouse_y 25 | 26 | super 27 | 28 | nil 29 | end 30 | 31 | def draw 32 | # Prevent system and game mouse from being shown at the same time. 33 | super if inside_window? and $window.current_game_state.is_a? GuiState and not $window.needs_cursor? 34 | end 35 | end 36 | end -------------------------------------------------------------------------------- /examples/readme_example.rb: -------------------------------------------------------------------------------- 1 | require_relative "../lib/fidgit" 2 | 3 | # Normally, you'd load this from the gem using: 4 | # require 'fidgit' 5 | 6 | class MyGame < Chingu::Window 7 | def initialize 8 | super(640, 480, false) 9 | 10 | # To use the Fidgit features, a Fidgit::GuiState must be active. 11 | push_game_state MyGuiState 12 | end 13 | end 14 | 15 | class MyGuiState < Fidgit::GuiState 16 | def initialize 17 | super 18 | 19 | # Create a vertically packed section, centred on the window. 20 | vertical align: :center do 21 | # Create a label with a dark green background. 22 | my_label = label "Hello world!", background_color: Gosu::Color.rgb(0, 100, 0) 23 | 24 | # Create a button that, when clicked, changes the label text. 25 | button("Goodbye", align_h: :center, tip: "Press me and be done with it!") do 26 | my_label.text = "Goodbye cruel world!" 27 | end 28 | end 29 | end 30 | end 31 | 32 | MyGame.new.show -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Fidgit changelog 2 | ================ 3 | 4 | v0.2.3 5 | ------ 6 | 7 | * Exposed #handle accessor for Slider. 8 | * Made Slider accept being disabled. 9 | * Made Slider handle show tip. 10 | 11 | v0.2.2 12 | ------ 13 | 14 | * Prevented ToggleButton border colour changing. 15 | * More careful about stripping out html tags in TextArea. 16 | 17 | v0.2.1 18 | ------ 19 | 20 | * Added: Click in ScrollBar gutter to scroll window by height/width of view window. 21 | * Added: Click and drag to select text in enabled TextArea. 22 | * Fixed: Color changes on disabling buttons. 23 | 24 | v0.2.0 25 | ------ 26 | 27 | * Added editable attribute to TextArea (Allows selection, but not alteration). 28 | * Added Element#font= and :font option. 29 | * Added Gosu::Color#colorize to use when using in-line text styling. 30 | * Managed layout of entities and XML tags (Used by Gosu) in TextArea text better (tags still don't like newlines inside them). 31 | * Changed license from LGPL to MIT. -------------------------------------------------------------------------------- /spec/fidgit/elements/label_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "helpers/helper" 2 | 3 | include Fidgit 4 | 5 | describe Label do 6 | before :all do 7 | $window = Chingu::Window.new(100, 100, false) unless $window 8 | end 9 | 10 | context "with default parameters" do 11 | subject { Label.new( "Hello world!") } 12 | 13 | it "should have text value set" do 14 | subject.text.should eq "Hello world!" 15 | end 16 | 17 | it "should have white text" do 18 | subject.color.should eq Gosu::Color.rgb(255, 255, 255) 19 | end 20 | 21 | it "should have a transparent background" do 22 | subject.background_color.should be_transparent 23 | end 24 | 25 | it "should have a transparent border" do 26 | subject.border_color.should be_transparent 27 | end 28 | 29 | it "should be enabled" do 30 | subject.should be_enabled 31 | end 32 | 33 | it "should not have an image" do 34 | subject.icon.should be_nil 35 | end 36 | end 37 | end -------------------------------------------------------------------------------- /lib/fidgit/elements/tool_tip.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | class ToolTip < TextLine 3 | def x=(value); super(value); recalc; value; end 4 | def y=(value); super(value); recalc; value; end 5 | def hit?(x, y); false; end 6 | 7 | 8 | # @param (see Label#initialize) 9 | # 10 | # @option (see Label#initialize) 11 | def initialize(options = {}, &block) 12 | options = { 13 | z: Float::INFINITY, 14 | background_color: default(:background_color), 15 | border_color: default(:border_color), 16 | text: '', 17 | }.merge! options 18 | 19 | super(options[:text], options) 20 | end 21 | 22 | protected 23 | def layout 24 | super 25 | 26 | # Ensure the tip can't go over the edge of the screen. If it can't be avoided, align with left edge of screen. 27 | rect.x = [[x, $window.width - width - padding_right].min, 0].max 28 | rect.y = [[y, $window.height - height - padding_bottom].min, 0].max 29 | 30 | nil 31 | end 32 | end 33 | end -------------------------------------------------------------------------------- /lib/fidgit/elements/color_well.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | class ColorWell < RadioButton 3 | alias_method :color, :value 4 | 5 | # @param (see RadioButton#initialize) 6 | # @option (see RadioButton#initialize) 7 | def initialize(options = {}, &block) 8 | options = { 9 | width: default(:width), 10 | height: default(:height), 11 | color: default(:color), 12 | outline_color: default(:outline_color), 13 | checked_border_color: default(:checked, :border_color), 14 | }.merge! options 15 | 16 | @outline_color = options[:outline_color].dup 17 | 18 | super('', (options[:color] || options[:value]).dup, options) 19 | end 20 | 21 | protected 22 | def draw_background 23 | super 24 | 25 | draw_frame x + 2, y + 2, width - 4, height - 4, 1, z, @outline_color 26 | 27 | nil 28 | end 29 | 30 | protected 31 | def draw_foreground 32 | draw_rect x + 3, y + 3, width - 6, height - 6, z, value 33 | 34 | nil 35 | end 36 | end 37 | end -------------------------------------------------------------------------------- /examples/grid_packer_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | class ExampleState < Fidgit::GuiState 4 | BORDER_COLOR = Gosu::Color.rgb(255, 0, 0) 5 | FIXED_NUM = 5 6 | NUM_CELLS = 17 7 | 8 | def initialize 9 | super 10 | 11 | vertical do 12 | label "Grid with #{FIXED_NUM} columns" 13 | grid num_columns: FIXED_NUM, border_color: BORDER_COLOR, cell_border_color: Gosu::Color.rgba(0, 255, 0, 255), cell_border_thickness: 1 do 14 | NUM_CELLS.times do |i| 15 | label "Cell #{i}", font_height: rand(15) + 15, border_color: BORDER_COLOR, border_thickness: 1 16 | end 17 | end 18 | 19 | label "Grid with #{FIXED_NUM} rows" 20 | grid num_rows: FIXED_NUM, border_color: BORDER_COLOR, cell_background_color: Gosu::Color.rgba(0, 100, 100, 255) do 21 | NUM_CELLS.times do |i| 22 | label "Cell #{i}", font_height: rand(15) + 15, border_color: BORDER_COLOR, border_thickness: 1 23 | end 24 | end 25 | end 26 | end 27 | end 28 | 29 | ExampleWindow.new.show -------------------------------------------------------------------------------- /examples/label_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | # Labels can have text and/or icons. 4 | class ExampleState < Fidgit::GuiState 5 | def initialize 6 | super 7 | 8 | vertical background_color: Gosu::Color.rgb(255, 0, 0) do 9 | label "Hello!", tip: 'A label with text' 10 | label "Hello!", icon: Gosu::Image["head_icon.png"], tip: 'A label with text & icon' 11 | label '', icon: Gosu::Image["head_icon.png"], tip: 'A label with just icon' 12 | label '', background_color: Gosu::Color.rgb(0, 255, 0), tip: 'No text or icon' 13 | end 14 | 15 | vertical do 16 | label ":left justification", width: 400, background_color: Gosu::Color.rgb(0, 100, 0), justify: :left, tip: 'A label with text' 17 | label ":right justification", width: 400, background_color: Gosu::Color.rgb(0, 100, 0), justify: :right, tip: 'A label with text' 18 | label ":center justification", width: 400, background_color: Gosu::Color.rgb(0, 100, 0), justify: :center, tip: 'A label with text' 19 | end 20 | end 21 | end 22 | 23 | ExampleWindow.new.show -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Bil Bas (Spooner) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /lib/fidgit/elements/list.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | class List < Composite 3 | class Item < RadioButton 4 | end 5 | 6 | event :changed 7 | 8 | def size; @items.size; end 9 | def clear; @items.clear; end 10 | 11 | def initialize(options = {}) 12 | options = { 13 | background_color: default(:background_color), 14 | border_color: default(:border_color), 15 | }.merge! options 16 | 17 | super options 18 | 19 | group do 20 | subscribe :changed do |sender, value| 21 | publish :changed, value 22 | end 23 | 24 | @items = vertical spacing: 0 25 | end 26 | end 27 | 28 | # @param [String] text 29 | # @option options [Gosu::Image] :icon 30 | def item(text, value, options = {}, &block) 31 | Item.new(text, value, { parent: @items }.merge!(options), &block) 32 | end 33 | 34 | protected 35 | def layout 36 | super 37 | if @items 38 | max_width = @items.each.to_a.map {|c| c.width }.max || 0 39 | @items.each {|c| c.rect.width = max_width } 40 | end 41 | 42 | nil 43 | end 44 | end 45 | end -------------------------------------------------------------------------------- /examples/slider_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | class ExampleState < Fidgit::GuiState 4 | def initialize 5 | super 6 | 7 | vertical do 8 | horizontal do 9 | # Discrete values (0..100) 10 | slider = slider(width: 100, range: 0..5, value: 3, tip: "Discrete value is") do |sender, value| 11 | @discrete_label.text = "Discrete slider is at #{value}" 12 | end 13 | 14 | @discrete_label = label "Discrete slider is at #{slider.value}" 15 | end 16 | 17 | horizontal do 18 | # Continuous values (0.0..1.0) 19 | 20 | slider = slider(width: 100, range: 0.0..100.0, value: 77.2, tip: "Continuous value is") do |sender, value| 21 | @continuous_label.text = "Continuous slider is at #{"%.03f" % value}%" 22 | end 23 | 24 | @continuous_label = label "Continuous slider is at #{"%.03f" % slider.value}%" 25 | end 26 | 27 | horizontal do 28 | slider(width: 100, range: 0.0..100.0, value: 77.2, tip: "Disabled slider value is", enabled: false) 29 | end 30 | end 31 | end 32 | end 33 | 34 | ExampleWindow.new.show -------------------------------------------------------------------------------- /fidgit.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../lib', __FILE__) 2 | require 'fidgit/version' 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'fidgit' 6 | s.version = Fidgit::VERSION 7 | s.platform = Gem::Platform::RUBY 8 | s.authors = ['Bil Bas (Spooner)'] 9 | s.email = ['bil.bagpuss@gmail.com'] 10 | s.homepage = 'https://github.com/gosu/fidgit/' 11 | s.summary = 'Fidgit is a GUI library built on Gosu/Chingu' 12 | s.description = 'Fidgit is a GUI library built on Gosu/Chingu' 13 | 14 | s.files = `git ls-files`.split("\n") 15 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 16 | s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } 17 | s.require_paths = ['lib'] 18 | s.license = 'MIT' 19 | 20 | s.add_runtime_dependency('gosu', '~> 0.7') 21 | s.add_runtime_dependency('chingu', '~> 0.9rc9') 22 | s.add_runtime_dependency('clipboard', '~> 0.9') 23 | 24 | s.add_development_dependency('rspec', '~> 2.8') 25 | s.add_development_dependency('rake', '~> 12.3', '=> 12.3.3') 26 | s.add_development_dependency('yard', '~> 0.9.11') 27 | s.add_development_dependency('RedCloth', '~> 4.2', '>= 4.2.9') 28 | end 29 | -------------------------------------------------------------------------------- /examples/splash_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | # By using a splash screen of some sort, one can switch to another resolution for the main game. 4 | class ExampleState < Fidgit::GuiState 5 | def initialize 6 | super 7 | vertical do 8 | horizontal do 9 | text = label "Width:" 10 | 11 | @width_combo = combo_box(value: [640, 480]) do 12 | [[640, 480], [800, 600], [Gosu::screen_width, Gosu::screen_height]].each do |width, height| 13 | item "#{width}x#{height}", [width, height] 14 | end 15 | end 16 | end 17 | 18 | @full_screen_button = toggle_button "Fullscreen?" 19 | 20 | button "Load game", icon: Gosu::Image["head_icon.png"] do 21 | $window.close 22 | window = Chingu::Window.new(*@width_combo.value, @full_screen_button.value) 23 | window.push_game_state ExampleAfterState 24 | window.show 25 | end 26 | end 27 | end 28 | end 29 | 30 | class ExampleAfterState < Fidgit::GuiState 31 | def initialize 32 | super 33 | 34 | on_input(:esc, :exit) 35 | 36 | vertical do 37 | label "Game loaded!", icon: Gosu::Image["head_icon.png"], border_color: Gosu::Color.rgb(255, 255, 255) 38 | end 39 | end 40 | end 41 | 42 | ExampleWindow.new.show -------------------------------------------------------------------------------- /lib/fidgit/elements/packer.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | # Container that auto-packs elements. 3 | # 4 | # @abstract 5 | class Packer < Container 6 | attr_reader :spacing_h, :spacing_v 7 | 8 | # @param (see Container#initialize) 9 | # 10 | # @option (see Container#initialize) 11 | def initialize(options = {}) 12 | options = { 13 | }.merge! options 14 | 15 | @spacing_h = options[:spacing_h] || options[:spacing] || default(:spacing_h) 16 | @spacing_v = options[:spacing_v] || options[:spacing] || default(:spacing_v) 17 | 18 | super(options) 19 | end 20 | 21 | protected 22 | # Recalculate the size of the container. 23 | # Should be overridden by any descendant that manages the positions of its children. 24 | def layout 25 | # This assumes that the container overlaps all the children. 26 | 27 | # Move all children if we have moved. 28 | @children.each.with_index do |child, index| 29 | child.x = padding_left + x 30 | child.y = padding_top + y 31 | end 32 | 33 | # Make us as wrap around the largest child. 34 | rect.width = (@children.map {|c| c.width }.max || 0) + padding_left + padding_right 35 | rect.height = (@children.map {|c| c.height }.max || 0) + padding_top + padding_bottom 36 | 37 | super 38 | end 39 | end 40 | end -------------------------------------------------------------------------------- /examples/button_and_toggle_button_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | # Example for Button and ToggleButton 4 | class ExampleState < Fidgit::GuiState 5 | def initialize 6 | super 7 | 8 | vertical do 9 | my_label = label "Label", tip: "I'm a label" 10 | 11 | buttons = [] 12 | 13 | # A plain button, with some text on it. 14 | buttons << button("Button", tip: "I'm a button; press me!", shortcut: :auto) do 15 | my_label.text = "Pressed the button!" 16 | end 17 | 18 | # Buttons with icons in each possible positions. 19 | [:left, :right, :top, :bottom].each do |position| 20 | buttons << button("Icon at #{position}", icon: Gosu::Image["head_icon.png"], icon_position: position, icon_options: { factor: 2 }, tip: "I'm a button; press me!", shortcut: :auto) do 21 | my_label.text = "Pressed the button (icon to #{position})!" 22 | end 23 | end 24 | 25 | # A toggling button. 26 | buttons << toggle_button("ToggleButton", tip: "I'm a button that toggles", shortcut: :o) do |sender, value| 27 | my_label.text = "Turned the toggle button #{value ? "on" : "off"}!" 28 | end 29 | 30 | # A toggle-button that controls whether all the other buttons are enabled. 31 | toggle_button("Enable other two buttons", value: true) do |sender, value| 32 | buttons.each {|button| button.enabled = value } 33 | end 34 | end 35 | end 36 | end 37 | 38 | ExampleWindow.new.show -------------------------------------------------------------------------------- /examples/file_dialog_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | 4 | Fidgit::Element.schema.merge_elements!(Element: { font_height: 15 }) 5 | 6 | class ExampleState < Fidgit::GuiState 7 | def initialize 8 | super 9 | 10 | container.background_color = Gosu::Color.rgb(50, 50, 50) 11 | vertical align: :center do 12 | full_base_directory = '' 13 | restricted_base_directory = File.expand_path(File.join(__FILE__, '..', '..')) 14 | directory = File.join(restricted_base_directory, 'media', 'images') 15 | 16 | my_label = label "No files are actually loaded or saved by this example" 17 | button("Load...(limited path access)") do 18 | file_dialog(:open, base_directory: restricted_base_directory, directory: directory, pattern: "*.png") do |result, file| 19 | case result 20 | when :open 21 | my_label.text = "Loaded #{file}" 22 | when :cancel 23 | my_label.text = "Loading cancelled" 24 | end 25 | end 26 | end 27 | 28 | button("Save...(unrestricted path access)") do 29 | file_dialog(:save, base_directory: full_base_directory, directory: directory, pattern: "*.png") do |result, file| 30 | case result 31 | when :save 32 | my_label.text = "Saved #{file}" 33 | when :cancel 34 | my_label.text = "Save cancelled" 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | 42 | ExampleWindow.new.show -------------------------------------------------------------------------------- /examples/scroll_window_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | # Example for Button and ToggleButton 4 | class ExampleState < Fidgit::GuiState 5 | HEIGHT = 225 6 | WIDTH = 140 7 | def initialize 8 | super 9 | 10 | vertical do 11 | horizontal do 12 | [ 13 | [20, "All cheer the ascendancy of number "], # Should have both scrollers 14 | [20, "#"], # Only has v-scroller. 15 | [4, "#"], # No scrollers. 16 | [4, "All cheer the ascendancy of number "], # Only has h-scroller 17 | ].each do |num_labels, text| 18 | vertical do 19 | scroll_window(width: WIDTH, height: HEIGHT, background_color: Gosu::Color.rgb(0, 100, 0)) do 20 | vertical do 21 | (1..num_labels).each do |i| 22 | label "#{text}#{i}!" 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | 30 | horizontal padding: 0 do 31 | vertical do 32 | scroll_window(width: 300, height: 150) do 33 | text_area(text: "Hello world! " * 19, width: 284) 34 | end 35 | end 36 | 37 | vertical do 38 | scroll_window(width: 300, height: 150) do 39 | %w[One Two Three Four Five Six].each do |name| 40 | toggle_button(name) 41 | end 42 | end 43 | end 44 | end 45 | end 46 | end 47 | end 48 | 49 | ExampleWindow.new.show -------------------------------------------------------------------------------- /lib/fidgit/elements/group.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | class Group < Packer 3 | attr_reader :selected 4 | 5 | event :changed 6 | 7 | def value; @selected ? @selected.value : nil; end 8 | 9 | # @example 10 | # group do 11 | # horizontal do 12 | # radio_button 1, text: '1', checked: true 13 | # radio_button 2, text: '2' 14 | # subscribe :changed do |sender, value| 15 | # puts value 16 | # end 17 | # end 18 | # end 19 | # 20 | # @param (see Packer#initialize) 21 | # 22 | # @option (see Packer#initialize) 23 | def initialize(options = {}, &block) 24 | super(options) 25 | 26 | @selected = nil 27 | @buttons = [] 28 | end 29 | 30 | def add_button(button) 31 | @buttons.push button 32 | self.value = button.value if button.checked? 33 | nil 34 | end 35 | 36 | def remove_button(button) 37 | self.value = nil if button == @selected 38 | @buttons.delete button 39 | nil 40 | end 41 | 42 | # @example 43 | # @my_group = group do 44 | # horizontal do 45 | # radio_button(1, text: '1', checked: true) 46 | # radio_button(2, text: '2') 47 | # end 48 | # end 49 | # 50 | # # later 51 | # @my_group.value = 2 52 | def value=(value) 53 | if value != self.value 54 | button = @buttons.find { |b| b.value == value } 55 | @selected.uncheck if @selected and @selected.checked? 56 | @selected = button 57 | @selected.check if @selected and not @selected.checked? 58 | publish :changed, self.value 59 | end 60 | 61 | value 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/fidgit/elements/color_picker.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | class ColorPicker < Composite 3 | CHANNELS = [:red, :green, :blue] 4 | DEFAULT_CHANNEL_NAMES = CHANNELS.map {|c| c.to_s.capitalize } 5 | 6 | INDICATOR_HEIGHT = 25 7 | 8 | event :changed 9 | 10 | def color; @color.dup; end 11 | 12 | def color=(value) 13 | @color = value.dup 14 | CHANNELS.each do |channel| 15 | @sliders[channel].value = @color.send channel 16 | end 17 | 18 | publish :changed, @color.dup 19 | 20 | value 21 | end 22 | 23 | # @param (see Composite#initialize) 24 | # @option (see Composite#initialize) 25 | def initialize(options = {}, &block) 26 | options = { 27 | padding: 0, 28 | spacing: 0, 29 | channel_names: DEFAULT_CHANNEL_NAMES, 30 | color: default(:color), 31 | indicator_height: default(:indicator_height), 32 | }.merge! options 33 | 34 | @color = options[:color].dup 35 | @indicator_height = options[:indicator_height] 36 | 37 | super(options) 38 | 39 | slider_width = width 40 | vertical do 41 | @sliders = {} 42 | CHANNELS.each_with_index do |channel, i| 43 | @sliders[channel] = slider(value: @color.send(channel), range: 0..255, width: slider_width, 44 | tip: options[:channel_names][i]) do |sender, value| 45 | @color.send "#{channel}=", value 46 | @indicator.background_color = @color 47 | publish :changed, @color.dup 48 | end 49 | end 50 | 51 | @indicator = label '', background_color: @color, width: slider_width, height: @indicator_height 52 | end 53 | end 54 | 55 | protected 56 | # Use block as an event handler. 57 | def post_init_block(&block) 58 | subscribe :changed, &block 59 | end 60 | end 61 | end -------------------------------------------------------------------------------- /examples/align_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | # Change font and labels in the schema. 4 | Fidgit::Element.schema.merge_elements!(Element: { font_height: 24 }, Label: { background_color: "?dark_blue" }) 5 | 6 | class ExampleState < Fidgit::GuiState 7 | ROW_BACKGROUND = Gosu::Color.rgb(0, 100, 0) 8 | CELL_BACKGROUND = Gosu::Color.rgb(100, 0, 0) 9 | OUTER_BACKGROUND = Gosu::Color.rgb(100, 0, 100) 10 | 11 | def initialize 12 | super 13 | 14 | vertical align: :center, background_color: OUTER_BACKGROUND do 15 | label "h => align_h, v => align_v", align_h: :center 16 | 17 | grid num_columns: 4, align: :center, cell_background_color: CELL_BACKGROUND, background_color: ROW_BACKGROUND do 18 | label "xxx" 19 | label "h fill", align_h: :fill 20 | label "h right", align_h: :right 21 | label "h center", align_h: :center 22 | 23 | 24 | vertical do 25 | label "xxx" 26 | label "xxx" 27 | end 28 | label "v fill", align_v: :fill 29 | label "v center", align_v: :center 30 | label "v bottom", align_v: :bottom 31 | 32 | vertical align_h: :center do 33 | label "h center" 34 | label "h center" 35 | end 36 | label "top right", align: [:top, :left] 37 | label "bottom left", align_h: :left, align_v: :bottom 38 | label "h/v fill", align: :fill 39 | 40 | label "" 41 | label "bottom right", align_h: :right, align_v: :bottom 42 | label "bottom center", align_h: :center, align_v: :bottom 43 | vertical align_h: :right do 44 | label "h right" 45 | label "h right" 46 | end 47 | 48 | label "Blah, bleh!" 49 | label "Yada, yada, yada" 50 | label "Bazingo by jingo!" 51 | end 52 | end 53 | end 54 | end 55 | 56 | ExampleWindow.new.show -------------------------------------------------------------------------------- /lib/fidgit/elements/image_frame.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | # A wrapper around a Gosu::Image to show it in the GUI. 3 | class ImageFrame < Element 4 | ENABLED_COLOR = Gosu::Color::WHITE 5 | DISABLED_COLOR = Gosu::Color.rgb(150, 150, 150) 6 | 7 | attr_reader :image, :factor_x, :factor_y 8 | 9 | def thumbnail?; @thumbnail; end 10 | 11 | # @param (see Element#initialize) 12 | # @param [Gosu::Image] image Gosu image to display. 13 | # 14 | # @option (see Element#initialize) 15 | # @option options [Boolean] :thumbnail (false) Is the image expanded to be square? 16 | def initialize(image, options = {}) 17 | options = { 18 | thumbnail: false, 19 | factor: 1, 20 | }.merge! options 21 | 22 | @thumbnail = options[:thumbnail] 23 | @factor_x = options[:factor_x] || options[:factor] 24 | @factor_y = options[:factor_y] || options[:factor] 25 | 26 | super(options) 27 | 28 | self.image = image 29 | end 30 | 31 | def image=(image) 32 | @image = image 33 | 34 | recalc 35 | 36 | image 37 | end 38 | 39 | 40 | def draw_foreground 41 | @image.draw(x + padding_left, y + padding_top, z, factor_x, factor_y, enabled? ? ENABLED_COLOR : DISABLED_COLOR) if @image 42 | end 43 | 44 | protected 45 | def layout 46 | if @image 47 | if @thumbnail 48 | size = [@image.width, @image.height].max 49 | rect.width = size * @factor_x 50 | rect.height = size * @factor_y 51 | else 52 | rect.width = @image.width * @factor_x 53 | rect.height = @image.height * @factor_y 54 | end 55 | else 56 | rect.width = rect.height = 0 57 | end 58 | 59 | rect.width += padding_left + padding_right 60 | rect.height += padding_top + padding_bottom 61 | 62 | nil 63 | end 64 | end 65 | end -------------------------------------------------------------------------------- /lib/fidgit/states/dialog_state.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | # A modal dialog. 3 | # @abstract 4 | class DialogState < GuiState 5 | DEFAULT_BACKGROUND_COLOR = Gosu::Color.rgb(75, 75, 75) 6 | DEFAULT_BORDER_COLOR = Gosu::Color.rgb(255, 255, 255) 7 | 8 | DEFAULT_SHADOW_COLOR = Gosu::Color.rgba(0, 0, 0, 100) 9 | DEFAULT_SHADOW_OFFSET = 8 10 | 11 | def initialize(options = {}) 12 | # @option options [Gosu::Color] :shadow_color (transparent black) Color of the shadow. 13 | # @option options [Gosu::Color] :shadow_offset (8) Distance shadow is offset to bottom and left. 14 | # @option options [Gosu::Color] :shadow_full (false) Shadow fills whole screen. Ignores :shadow_offset option if true. 15 | options = { 16 | shadow_color: DEFAULT_SHADOW_COLOR, 17 | shadow_offset: DEFAULT_SHADOW_OFFSET, 18 | shadow_full: false, 19 | }.merge! options 20 | 21 | @shadow_color = options[:shadow_color].dup 22 | @shadow_offset = options[:shadow_offset] 23 | @shadow_full = options[:shadow_full] 24 | 25 | super() 26 | end 27 | 28 | def draw 29 | $window.game_state_manager.previous_game_state.draw # Keep the underlying state being shown. 30 | $window.flush 31 | 32 | if @shadow_full 33 | draw_rect 0, 0, $window.width, $window.height, -Float::INFINITY, @shadow_color 34 | elsif @shadow_offset > 0 35 | dialog = container[0] 36 | draw_rect dialog.x + @shadow_offset, dialog.y + @shadow_offset, dialog.width, dialog.height, -Float::INFINITY, @shadow_color 37 | end 38 | 39 | super 40 | end 41 | 42 | def show 43 | $window.game_state_manager.push(self, finalize: false) unless $window.game_state_manager.game_states.include? self 44 | nil 45 | end 46 | 47 | def hide 48 | $window.game_state_manager.pop(setup: false) if $window.game_state_manager.current == self 49 | nil 50 | end 51 | end 52 | end -------------------------------------------------------------------------------- /lib/fidgit/elements/toggle_button.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | # A button that toggles its value from false<->true when clicked. 3 | class ToggleButton < Button 4 | event :changed 5 | 6 | attr_reader :value 7 | def value=(value); @value = value; update_status; end 8 | 9 | # @param (see Button#initialize) 10 | # 11 | # @option (see Button#initialize) 12 | def initialize(text, options = {}, &block) 13 | options = { 14 | value: false 15 | }.merge! options 16 | 17 | @value = options[:value] 18 | 19 | super(text, options) 20 | 21 | @text_on = (options[:text_on] || text).dup 22 | @icon_on = options[:icon_on] || icon 23 | @tip_on = (options[:tip_on] || tip).dup 24 | @border_color_on = (options[:border_color_on] || options[:border_color] || default(:toggled, :border_color)).dup 25 | 26 | @text_off = (options[:text_off] || text).dup 27 | @icon_off = options[:icon_off] || icon 28 | @tip_off = (options[:tip_off] || tip).dup 29 | @border_color_off = (options[:border_color_off] || options[:border_color] || default(:border_color)).dup 30 | 31 | update_status 32 | 33 | subscribe :clicked_left_mouse_button do |sender, x, y| 34 | @value = (not @value) 35 | update_status 36 | publish :changed, @value 37 | end 38 | end 39 | 40 | protected 41 | # The block for a toggle-button is connected to :changed event. 42 | def post_init_block(&block) 43 | subscribe :changed, &block 44 | end 45 | 46 | protected 47 | def update_status 48 | if @value 49 | self.text = @text_on.dup 50 | @icon = @icon_on ? @icon_on.dup : nil 51 | @tip = @tip_on.dup 52 | @border_color = @border_color_on.dup 53 | else 54 | self.text = @text_off.dup 55 | @icon = @icon_off ? @icon_off.dup : nil 56 | @tip = @tip_off.dup 57 | @border_color = @border_color_off.dup 58 | end 59 | 60 | recalc 61 | 62 | nil 63 | end 64 | end 65 | end -------------------------------------------------------------------------------- /lib/fidgit.rb: -------------------------------------------------------------------------------- 1 | require 'chingu' 2 | require 'clipboard' 3 | 4 | require_relative "fidgit/cursor" 5 | require_relative "fidgit/event" 6 | require_relative "fidgit/history" 7 | require_relative "fidgit/redirector" 8 | require_relative "fidgit/schema" 9 | require_relative "fidgit/selection" 10 | require_relative "fidgit/version" 11 | require_relative "fidgit/window" 12 | 13 | require_relative "fidgit/chingu_ext/window" 14 | require_relative "fidgit/gosu_ext/color" 15 | require_relative "fidgit/gosu_ext/image" 16 | require_relative "fidgit/standard_ext/hash" 17 | 18 | require_relative "fidgit/elements/element" 19 | require_relative "fidgit/elements/container" 20 | require_relative "fidgit/elements/packer" 21 | require_relative "fidgit/elements/composite" 22 | require_relative "fidgit/elements/grid" 23 | require_relative "fidgit/elements/group" 24 | require_relative "fidgit/elements/label" 25 | require_relative "fidgit/elements/button" 26 | require_relative "fidgit/elements/radio_button" 27 | 28 | require_relative "fidgit/elements/color_picker" 29 | require_relative "fidgit/elements/color_well" 30 | require_relative "fidgit/elements/combo_box" 31 | require_relative "fidgit/elements/file_browser" 32 | require_relative "fidgit/elements/horizontal" 33 | require_relative "fidgit/elements/image_frame" 34 | require_relative "fidgit/elements/list" 35 | require_relative "fidgit/elements/main_packer" 36 | require_relative "fidgit/elements/menu_pane" 37 | require_relative "fidgit/elements/scroll_area" 38 | require_relative "fidgit/elements/scroll_bar" 39 | require_relative "fidgit/elements/scroll_window" 40 | require_relative "fidgit/elements/slider" 41 | require_relative "fidgit/elements/text_area" 42 | require_relative "fidgit/elements/text_line" 43 | require_relative "fidgit/elements/toggle_button" 44 | require_relative "fidgit/elements/tool_tip" 45 | require_relative "fidgit/elements/vertical" 46 | 47 | require_relative "fidgit/states/gui_state" 48 | require_relative "fidgit/states/dialog_state" 49 | require_relative "fidgit/states/file_dialog" 50 | require_relative "fidgit/states/message_dialog" -------------------------------------------------------------------------------- /lib/fidgit/elements/scroll_area.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | # A basic scrolling area. It is not managed in any way (use ScrollWindow for that). 3 | class ScrollArea < Container 4 | # @return [Vertical] The content shown within this ScrollArea 5 | attr_reader :content 6 | 7 | def offset_x; x - @content.x; end 8 | def offset_y; y - @content.y; end 9 | 10 | def offset_x=(value) 11 | @content.x = x - [[@content.width - width, value].min, 0].max 12 | end 13 | 14 | def offset_y=(value) 15 | @content.y = y - [[@content.height - height, value].min, 0].max 16 | end 17 | 18 | # @option options [Number] :offset (0) 19 | # @option options [Number] :offset_x (value of :offset option) 20 | # @option options [Number] :offset_y (value of :offset option) 21 | # @option options [Element] :owner The owner of the content, such as the scroll-window containing the content. 22 | def initialize(options = {}) 23 | options = { 24 | offset: 0, 25 | owner: nil, 26 | }.merge! options 27 | 28 | @owner = options[:owner] 29 | 30 | super(options) 31 | 32 | @content = Vertical.new(parent: self, padding: 0) 33 | 34 | self.offset_x = options[:offset_x] || options[:offset] 35 | self.offset_y = options[:offset_y] || options[:offset] 36 | end 37 | 38 | def hit_element(x, y) 39 | # Only pass on mouse events if they are inside the window. 40 | if hit?(x, y) 41 | @content.hit_element(x, y) || self 42 | else 43 | nil 44 | end 45 | end 46 | 47 | def recalc 48 | super 49 | # Always recalc our owner if our content resizes, even though our size can't change even if the content changes 50 | # (may encourage ScrollWindow to show/hide scroll-bars, for example) 51 | @owner.recalc if @owner 52 | end 53 | 54 | protected 55 | def draw_foreground 56 | $window.clip_to(*rect) do 57 | @content.draw 58 | end 59 | end 60 | 61 | protected 62 | def post_init_block(&block) 63 | with(&block) 64 | end 65 | end 66 | end -------------------------------------------------------------------------------- /spec/fidgit/elements/image_frame_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/helper' 2 | 3 | require 'fidgit' 4 | 5 | def check_thumbnail_is_square 6 | {square: [10, 10], tall: [5, 12], wide: [6, 5]}.each_pair do |type, dimensions| 7 | context "with a #{type} image" do 8 | it "should be square and just large enough to contain the image" do 9 | subject = described_class.new(Gosu::Image.create(*dimensions), thumbnail: true) 10 | subject.width.should equal dimensions.max 11 | subject.height.should equal dimensions.max 12 | end 13 | end 14 | end 15 | end 16 | 17 | def check_image_dimensions 18 | {square: [10, 10], tall: [5, 12], wide: [6, 5]}.each_pair do |type, dimensions| 19 | context "with a #{type} image" do 20 | it "should be just large enough to contain the image" do 21 | subject = described_class.new(Gosu::Image.create(*dimensions)) 22 | subject.width.should equal dimensions.first 23 | subject.height.should equal dimensions.last 24 | end 25 | end 26 | end 27 | end 28 | 29 | module Fidgit 30 | describe ImageFrame do 31 | before :all do 32 | $window = Chingu::Window.new(100, 100, false) unless $window 33 | end 34 | 35 | before :each do 36 | @image = Gosu::Image.create(10, 10) 37 | end 38 | 39 | subject { described_class.new(@image) } 40 | 41 | describe '#image' do 42 | it "should have the image set" do 43 | subject.image.should be @image 44 | end 45 | end 46 | 47 | describe '#width and #height' do 48 | check_image_dimensions 49 | end 50 | 51 | describe "Thumbnails" do 52 | subject { described_class.new(@image, thumbnail: true) } 53 | 54 | describe '#image=' do 55 | it "should update the height and width" do 56 | image = Gosu::Image.create(8, 2) 57 | subject.image = image 58 | subject.height.should equal 8 59 | subject.width.should equal 8 60 | end 61 | end 62 | 63 | describe '#width and #height' do 64 | check_thumbnail_is_square 65 | end 66 | end 67 | end 68 | end -------------------------------------------------------------------------------- /lib/fidgit/elements/radio_button.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | class RadioButton < Button 3 | attr_reader :group, :value 4 | 5 | event :changed 6 | 7 | def checked?; @checked; end 8 | 9 | # @param (see Button#initialize) 10 | # @param [Object] value 11 | # 12 | # @option (see Button#initialize) 13 | # @option options [Boolean] :checked 14 | def initialize(text, value, options = {}, &block) 15 | options = { 16 | checked: false, 17 | checked_border_color: default(:checked, :border_color), 18 | }.merge! options 19 | 20 | @checked = options[:checked] 21 | @value = value 22 | 23 | super(text, options) 24 | 25 | @checked_border_color = options[:checked_border_color].dup 26 | @unchecked_border_color = border_color 27 | add_to_group 28 | 29 | @border_color = (checked? ? @checked_border_color : @unchecked_border_color).dup 30 | end 31 | 32 | def clicked_left_mouse_button(sender, x, y) 33 | super 34 | check 35 | nil 36 | end 37 | 38 | # Check the button and update its group. This may uncheck another button in the group if one is selected. 39 | def check 40 | return if checked? 41 | 42 | @checked = true 43 | @group.value = value 44 | @border_color = @checked_border_color.dup 45 | publish :changed, @checked 46 | 47 | nil 48 | end 49 | 50 | # Uncheck the button and update its group. 51 | def uncheck 52 | return unless checked? 53 | 54 | @checked = false 55 | @group.value = value 56 | @border_color = @unchecked_border_color.dup 57 | publish :changed, @checked 58 | 59 | nil 60 | end 61 | 62 | protected 63 | def parent=(parent) 64 | @group.remove_button self if @parent 65 | super(parent) 66 | add_to_group if parent 67 | parent 68 | end 69 | 70 | protected 71 | def add_to_group 72 | container = parent 73 | while container and not container.is_a? Group 74 | container = container.parent 75 | end 76 | 77 | raise "#{self.class.name} must be placed inside a group element" unless container 78 | 79 | @group = container 80 | @group.add_button self 81 | nil 82 | end 83 | end 84 | end -------------------------------------------------------------------------------- /lib/fidgit/states/message_dialog.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | # A simple dialog that manages a message with a set of buttons beneath it. 3 | class MessageDialog < DialogState 4 | VALID_TYPES = [:ok, :ok_cancel, :yes_no, :yes_no_cancel, :quit_cancel, :quit_save_cancel] 5 | 6 | attr_reader :type 7 | 8 | # @param [String] message 9 | # 10 | # @option options [Symbol] :type (:ok) One from :ok, :ok_cancel, :yes_no, :yes_no_cancel, :quit_cancel or :quit_save_cancel 11 | # @option options [String] :ok_text ("OK") 12 | # @option options [String] :yes_text ("Yes") 13 | # @option options [String] :no_text ("No") 14 | # @option options [String] :cancel_text ("Cancel") 15 | # @option options [String] :save_text ("Save") 16 | # @option options [String] :quit_text ("Quit") 17 | # @option options [Boolean] :show (true) Whether to show the message immediately (otherwise need to use #show later). 18 | # 19 | # @yield when the dialog is closed. 20 | # @yieldparam [Symbol] result :ok, :yes, :no, :quit, :save or :cancel, depending on the button pressed. 21 | def initialize(message, options = {}, &block) 22 | options = { 23 | type: :ok, 24 | ok_text: "OK", 25 | yes_text: "Yes", 26 | no_text: "No", 27 | quit_text: "Quit", 28 | save_text: "Save", 29 | cancel_text: "Cancel", 30 | show: true, 31 | background_color: DEFAULT_BACKGROUND_COLOR, 32 | border_color: DEFAULT_BORDER_COLOR, 33 | width: $window.width / 2 34 | }.merge! options 35 | 36 | @type = options[:type] 37 | raise ArgumentError, ":type must be one of #{VALID_TYPES}, not #{@type}" unless VALID_TYPES.include? @type 38 | 39 | super(options) 40 | 41 | # Dialog is forced to the centre. 42 | options[:align_h] = options[:align_v] = :center 43 | 44 | vertical options do 45 | text_area(text: message, enabled: false, width: options[:width] - padding_left - padding_right) 46 | 47 | horizontal align_h: :center do 48 | @type.to_s.split('_').each do |type| 49 | button(options[:"#{type}_text"]) do 50 | hide 51 | block.call type.to_sym if block 52 | end 53 | end 54 | end 55 | end 56 | 57 | show if options[:show] 58 | end 59 | end 60 | end -------------------------------------------------------------------------------- /spec/fidgit/schema_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/helper' 2 | 3 | require 'fidgit' 4 | 5 | SCHEMA_FILE_NAME = File.expand_path(File.join(__FILE__, '..', 'schema_test.yml')) 6 | 7 | include Fidgit 8 | 9 | describe Schema do 10 | subject { Schema.new(Hash.new) } 11 | 12 | context "given the default schema" do 13 | subject { Schema.new(YAML.load(File.read(SCHEMA_FILE_NAME))) } 14 | 15 | describe "#constant" do 16 | it "should have the constant :scroll_bar_thickness" do 17 | subject.constant(:scroll_bar_thickness).should equal 12 18 | end 19 | 20 | it "should have the color constant :none" do 21 | subject.constant(:none).should eq Gosu::Color.rgba(0, 0, 0, 0) 22 | end 23 | 24 | it "should have the color constant :white" do 25 | subject.constant(:white).should eq Gosu::Color.rgb(255, 255, 255) 26 | end 27 | 28 | it "should not have the constant :moon_cow_height" do 29 | subject.constant(:moon_cow_height).should be_nil 30 | end 31 | end 32 | 33 | describe "#default" do 34 | it "should fail if the class given is not an Element" do 35 | ->{ subject.default(String, :frog) }.should raise_error ArgumentError 36 | end 37 | 38 | it "should fail if the value is not ever defined" do 39 | ->{ subject.default(Element, :knee_walking_turkey) }.should raise_error 40 | end 41 | 42 | it "should give the correct value for a defined color" do 43 | subject.default(Element, :color).should eq Gosu::Color.rgb(255, 255, 255) 44 | end 45 | 46 | it "should give the symbol name if the value is one" do 47 | subject.default(Element, :align_h).should be :left 48 | end 49 | 50 | it "should give the correct value for a defined constant" do 51 | subject.default(VerticalScrollBar, :width).should equal 12 52 | end 53 | 54 | it "should give the correct value for a color defined in an ancestor class" do 55 | subject.default(Label, :color).should eq Gosu::Color.rgb(255, 255, 255) 56 | end 57 | 58 | it "should give the correct value for a defined nested value" do 59 | subject.default(Fidgit::Button, [:disabled, :background_color]).should eq Gosu::Color.rgb(50, 50, 50) 60 | end 61 | 62 | it "should give the outer value for an undefined nested value" do 63 | subject.default(Element, [:hover, :color]).should eq Gosu::Color.rgb(255, 255, 255) 64 | end 65 | end 66 | end 67 | end -------------------------------------------------------------------------------- /examples/message_dialog_example.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/example_window' 2 | 3 | # Example for Button and ToggleButton 4 | class ExampleState < Fidgit::GuiState 5 | def initialize 6 | super 7 | 8 | vertical do 9 | my_label = label "Why not open a dialog? You know you want to!", tip: "I'm a label" 10 | 11 | button("Open an ok message dialog") do 12 | message "System shutdown immanent" 13 | end 14 | 15 | button("Open an ok/cancel message dialog") do 16 | message("Really 'rm -rf .'?", type: :ok_cancel) do |result| 17 | my_label.text = case result 18 | when :ok then "All your base are belong to us!" 19 | when :cancel then "Cancelled" 20 | end 21 | end 22 | end 23 | 24 | button("Open a yes/no message dialog") do 25 | message("Do you like any sorts of cheese? Even Government cheese counts, you know!", type: :yes_no, yes_text: "Yay!", no_text: "Nay!") do |result| 26 | my_label.text = case result 27 | when :yes then "You like cheese" 28 | when :no then "You don't like cheese" 29 | end 30 | end 31 | end 32 | 33 | button("Open a yes/no/cancel message dialog") do 34 | message("Do you know what you are doing?", type: :yes_no_cancel) do |result| 35 | my_label.text = case result 36 | when :yes then "I'm not convinced you know what you are doing" 37 | when :no then "At least you are aware of your own shortcomings" 38 | when :cancel then "Don't avoid the question!" 39 | end 40 | end 41 | end 42 | 43 | button("Open quit/cancel message dialog") do 44 | message("Really leave us?", type: :quit_cancel) do |result| 45 | my_label.text = case result 46 | when :quit then "Quit! Bye!" 47 | when :cancel then "Oh, you are staying? Yay!" 48 | end 49 | end 50 | end 51 | 52 | button("Open quit/save/cancel message dialog") do 53 | message("You have unsaved data.\nSave before quitting?", type: :quit_save_cancel) do |result| 54 | my_label.text = case result 55 | when :quit then "File discarded and quit" 56 | when :save then "File saved and quit" 57 | when :cancel then "Nothing happened. You should be more committed" 58 | end 59 | end 60 | end 61 | end 62 | end 63 | end 64 | 65 | ExampleWindow.new.show -------------------------------------------------------------------------------- /spec/fidgit/redirector_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/helper' 2 | 3 | require 'redirector' 4 | 5 | include Fidgit 6 | 7 | describe RedirectorMethods do 8 | describe "::Object" do 9 | subject { Object.new } 10 | 11 | describe '#instance_methods_eval' do 12 | it "should fail if a block is not provided" do 13 | ->{ subject.instance_methods_eval }.should raise_error 14 | end 15 | 16 | it "should yield the target" do 17 | subject.instance_methods_eval { |target| @frog = target } 18 | @frog.should equal subject 19 | end 20 | 21 | it "should allow ivars to be read from the calling context" do 22 | @frog = 5 23 | fish = 0 24 | subject.instance_methods_eval { fish = @frog } 25 | fish.should equal 5 26 | end 27 | 28 | it "should allow ivars to be written to the calling context" do 29 | subject.instance_methods_eval { @frog = 10 } 30 | @frog.should equal 10 31 | end 32 | 33 | it "should not allow access to ivars in the subject" do 34 | subject.instance_variable_set :@frog, 5 35 | fish = 0 36 | subject.instance_methods_eval { fish = @frog } 37 | fish.should be_nil 38 | end 39 | 40 | it "should allow access to methods in the subject" do 41 | subject.should_receive(:frog) 42 | subject.instance_methods_eval { frog } 43 | end 44 | 45 | it "should allow stacked calls" do 46 | object1 = Object.new 47 | should_not_receive :frog 48 | object1.should_not_receive :frog 49 | subject.should_receive :frog 50 | 51 | object1.instance_methods_eval do 52 | subject.instance_methods_eval do 53 | frog 54 | end 55 | end 56 | end 57 | 58 | it "should fail if method does not exist on the subject or context" do 59 | ->{ subject.instance_methods_eval { frog } }.should raise_error NameError 60 | end 61 | 62 | it "should call the method on the context, if it doesn't exist on the subject" do 63 | should_receive(:frog) 64 | subject.instance_methods_eval { frog } 65 | end 66 | 67 | [:public, :protected, :private].each do |access| 68 | it "should preserve #{access} access for methods redirected on the context" do 69 | class << self; def frog; end; end 70 | (class << self; self; end).send access, :frog 71 | subject.should_receive :frog 72 | subject.instance_methods_eval { frog } 73 | (send "#{access}_methods").should include :frog 74 | end 75 | end 76 | end 77 | end 78 | end -------------------------------------------------------------------------------- /lib/fidgit/selection.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | class Selection 3 | MIN_DRAG_DISTANCE = 2 4 | 5 | def size; @items.size; end 6 | def empty?; @items.empty?; end 7 | def [](index); @items[index]; end 8 | def each(&block); @items.each(&block); end 9 | def to_a; @items.dup; end 10 | def include?(object); @items.include? object; end 11 | 12 | # Current being dragged? 13 | def dragging?; @dragging; end 14 | 15 | # Actually moved during a dragging operation? 16 | def moved?; @moved; end 17 | 18 | def initialize 19 | @items = [] 20 | @moved = false 21 | @dragging = false 22 | end 23 | 24 | def add(object) 25 | object.selected = true 26 | @items.push(object) 27 | 28 | self 29 | end 30 | 31 | def remove(object) 32 | @items.delete(object) 33 | object.selected = false 34 | object.dragging = false 35 | 36 | self 37 | end 38 | 39 | def clear 40 | end_drag if dragging? 41 | @items.each { |o| o.selected = false; o.dragging = false } 42 | @items.clear 43 | 44 | self 45 | end 46 | 47 | def begin_drag(x, y) 48 | @initial_x, @initial_y = x, y 49 | @last_x, @last_y = x, y 50 | @dragging = true 51 | @moved = false 52 | 53 | self 54 | end 55 | 56 | def end_drag 57 | @items.each do |object| 58 | object.x, object.y = object.x.round, object.y.round 59 | object.dragging = false 60 | end 61 | @dragging = false 62 | @moved = false 63 | 64 | self 65 | end 66 | 67 | # Move all dragged object back to original positions. 68 | def reset_drag 69 | if moved? 70 | @items.each do |o| 71 | o.x += @initial_x - @last_x 72 | o.y += @initial_y - @last_y 73 | end 74 | end 75 | 76 | self.end_drag 77 | 78 | self 79 | end 80 | 81 | def update_drag(x, y) 82 | x, y = x.round, y.round 83 | 84 | # If the mouse has been dragged far enough from the initial click position, then 'pick up' the objects and drag. 85 | unless moved? 86 | if distance(@initial_x, @initial_y, x, y) > MIN_DRAG_DISTANCE 87 | @items.each { |o| o.dragging = true } 88 | @moved = true 89 | end 90 | end 91 | 92 | if moved? 93 | @items.each do |o| 94 | o.x += x - @last_x 95 | o.y += y - @last_y 96 | end 97 | 98 | @last_x, @last_y = x, y 99 | end 100 | 101 | self 102 | end 103 | end 104 | end -------------------------------------------------------------------------------- /lib/fidgit/elements/text_line.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | # Used internally by the label. 3 | class TextLine < Element 4 | VALID_JUSTIFICATION = [:left, :right, :center] 5 | 6 | attr_reader :color, :justify 7 | 8 | def color=(color) 9 | raise ArgumentError.new("Text must be a Gosu::Color") unless color.is_a? Gosu::Color 10 | 11 | @color = color.dup 12 | 13 | color 14 | end 15 | 16 | def text; @text.dup; end 17 | 18 | def text=(text) 19 | raise ArgumentError.new("Text must be a String") unless text.respond_to? :to_s 20 | 21 | @text = text.to_s.dup 22 | 23 | recalc 24 | text 25 | end 26 | 27 | def justify=(justify) 28 | raise ArgumentError.new("Justify must be one of #{VALID_JUSTIFICATION.inspect}") unless VALID_JUSTIFICATION.include? justify 29 | @justify = justify 30 | end 31 | 32 | # @param (see Element#initialize) 33 | # @param [String] text The string to display in the line of text. 34 | # 35 | # @option (see Element#initialize) 36 | # @option options [:left, :right, :center] :justify (:left) Text justification. 37 | def initialize(text, options = {}) 38 | options = { 39 | color: default(:color), 40 | justify: default(:justify), 41 | }.merge! options 42 | 43 | super(options) 44 | 45 | self.justify = options[:justify] 46 | self.color = options[:color] 47 | self.text = text 48 | end 49 | 50 | def draw_foreground 51 | case @justify 52 | when :left 53 | rel_x = 0.0 54 | draw_x = x + padding_left 55 | 56 | when :right 57 | rel_x = 1.0 58 | draw_x = x + rect.width - padding_right 59 | 60 | when :center 61 | rel_x = 0.5 62 | draw_x = (x + padding_left) + (rect.width - padding_right - padding_left) / 2.0 63 | end 64 | 65 | font.draw_rel(@text, draw_x, y + padding_top, z, rel_x, 0, 1, 1, color) 66 | end 67 | 68 | def min_width 69 | if @text.empty? 70 | [padding_left + padding_right, super].max 71 | else 72 | [padding_left + font.text_width(@text) + padding_right, super].max 73 | end 74 | end 75 | 76 | protected 77 | def layout 78 | rect.width = [min_width, max_width].min 79 | 80 | if @text.empty? 81 | rect.height = [[padding_top + padding_bottom, min_height].max, max_height].min 82 | else 83 | rect.height = [[padding_top + font.height + padding_bottom, min_height].max, max_height].min 84 | end 85 | end 86 | 87 | public 88 | def to_s 89 | "#{super} '#{@text}'" 90 | end 91 | end 92 | end -------------------------------------------------------------------------------- /lib/fidgit/elements/scroll_window.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | class ScrollWindow < Composite 3 | def content; @view.content; end 4 | def offset_x; @view.offset_x; end 5 | def offset_x=(value); @view.offset_x = value; end 6 | def offset_y; @view.offset_y; end 7 | def offset_y=(value); @view.offset_y = value; end 8 | 9 | def view_width; @view.width; end 10 | def view_height; @view.height; end 11 | def content_width; @view.content.width; end 12 | def content_height; @view.content.height; end 13 | def width=(value); super(value); end 14 | def height=(value); super(value); end 15 | 16 | def initialize(options = {}) 17 | options = { 18 | scroll_bar_thickness: default(:scroll_bar_thickness), 19 | }.merge! options 20 | 21 | super(options) 22 | 23 | @grid = grid num_columns: 2, padding: 0, spacing: 0 do 24 | @view = scroll_area(owner: self, width: options[:width], height: options[:height]) 25 | @spacer = label '', padding: 0, width: 0, height: 0 26 | end 27 | 28 | @scroll_bar_v = VerticalScrollBar.new(owner: self, width: options[:scroll_bar_thickness], align_v: :fill) 29 | @scroll_bar_h = HorizontalScrollBar.new(owner: self, height: options[:scroll_bar_thickness], align_h: :fill) 30 | end 31 | 32 | protected 33 | def layout 34 | # Prevent recursive layouts. 35 | return if @in_layout 36 | 37 | @in_layout = true 38 | 39 | if @view 40 | # Constrain the values of the offsets. 41 | @view.offset_x = @view.offset_x 42 | @view.offset_y = @view.offset_y 43 | 44 | if content_height > view_height 45 | unless @scroll_bar_v.parent 46 | @view.send(:rect).width -= @scroll_bar_v.width 47 | @grid.remove @spacer 48 | @grid.insert 1, @scroll_bar_v 49 | end 50 | else 51 | if @scroll_bar_v.parent 52 | @view.send(:rect).width += @scroll_bar_v.width 53 | @grid.remove @scroll_bar_v 54 | @grid.insert 1, @spacer 55 | end 56 | end 57 | 58 | if content_width > view_width 59 | unless @scroll_bar_h.parent 60 | @view.send(:rect).height -= @scroll_bar_h.height 61 | @grid.add @scroll_bar_h 62 | end 63 | else 64 | if @scroll_bar_h.parent 65 | @view.send(:rect).height += @scroll_bar_h.height 66 | @grid.remove @scroll_bar_h 67 | end 68 | end 69 | end 70 | 71 | super 72 | 73 | @in_layout = false 74 | end 75 | 76 | protected 77 | def post_init_block(&block) 78 | @view.content.with(&block) 79 | end 80 | end 81 | end -------------------------------------------------------------------------------- /lib/fidgit/elements/combo_box.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | module Fidgit 4 | class ComboBox < Button 5 | extend Forwardable 6 | 7 | ARROW_IMAGE = "combo_arrow.png" 8 | 9 | def_delegators :@menu, :each 10 | 11 | event :changed 12 | 13 | def index; @menu.index(@value) end 14 | def value; @value; end 15 | 16 | def value=(value) 17 | if @value != value 18 | @value = value 19 | item = @menu.find(@value) 20 | self.text = item.text 21 | self.icon = item.icon 22 | publish :changed, @value 23 | end 24 | 25 | value 26 | end 27 | 28 | def index=(index) 29 | if index.between?(0, @menu.size - 1) 30 | self.value = @menu[index].value 31 | end 32 | 33 | index 34 | end 35 | 36 | # @param (see Button#initialize) 37 | # @option (see Button#initialize) 38 | # @option options [] :value 39 | def initialize(options = {}, &block) 40 | options = { 41 | background_color: default(:background_color), 42 | border_color: default(:border_color), 43 | }.merge! options 44 | 45 | @value = options[:value] 46 | 47 | @hover_index = 0 48 | 49 | @menu = MenuPane.new(show: false) do 50 | subscribe :selected do |widget, value| 51 | self.value = value 52 | end 53 | end 54 | 55 | @@arrow ||= Gosu::Image[ARROW_IMAGE] 56 | 57 | super('', options) 58 | 59 | rect.height = [height, font.height + padding_top + padding_bottom].max 60 | rect.width = [width, font.height * 4 + padding_left + padding_right].max 61 | end 62 | 63 | def item(text, value, options = {}, &block) 64 | item = @menu.item(text, value, options, &block) 65 | 66 | # Force text to be updated if the item added has the same value. 67 | if item.value == @value 68 | self.text = item.text 69 | self.icon = item.icon 70 | end 71 | 72 | recalc 73 | 74 | item 75 | end 76 | 77 | def draw 78 | super 79 | size = height / @@arrow.width.to_f 80 | @@arrow.draw x + width - height, y, z, size, size 81 | end 82 | 83 | def clicked_left_mouse_button(sender, x, y) 84 | @menu.x = self.x 85 | @menu.y = self.y + height + border_thickness 86 | $window.game_state_manager.current_game_state.show_menu @menu 87 | 88 | nil 89 | end 90 | 91 | def clear 92 | self.text = "" 93 | self.icon = nil 94 | @menu.clear 95 | end 96 | 97 | protected 98 | def layout 99 | super 100 | 101 | # Max width of all items + allow size for the arrow. 102 | rect.width = [@menu.width + height, min_width].max 103 | 104 | nil 105 | end 106 | 107 | 108 | protected 109 | # Any combo-box passed a block will allow you access to its methods. 110 | def post_init_block(&block) 111 | with(&block) 112 | end 113 | end 114 | end -------------------------------------------------------------------------------- /lib/fidgit/redirector.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | # Redirects methods to an object, but does not mask methods and ivars from the calling context. 3 | module RedirectorMethods 4 | # Evaluate a block accessing methods and ivars from the calling context, but calling public methods 5 | # (not ivars or non-public methods) on this object in preference. 6 | def instance_methods_eval(&block) 7 | raise ArgumentError, "block required" unless block_given? 8 | 9 | context = eval('self', block.binding) 10 | 11 | context.send :push_redirection_target, self 12 | 13 | begin 14 | yield context 15 | ensure 16 | context.send :pop_redirection_target 17 | end 18 | 19 | self 20 | end 21 | 22 | protected 23 | def push_redirection_target(target) 24 | meta_class = class << self; self; end 25 | base_methods = Object.public_instance_methods 26 | 27 | # Redirect just the public methods of the target, less those that are on Object. 28 | methods_to_redirect = target.public_methods - base_methods 29 | 30 | # Only hide those public/private/protected methods that are being redirected. 31 | methods_overridden = [] 32 | [:public, :protected, :private].each do |access| 33 | methods_to_hide = meta_class.send("#{access}_instance_methods", false) & methods_to_redirect 34 | methods_to_hide.each do |meth| 35 | # Take a reference to the method we are about to override. 36 | methods_overridden.push [meth, method(meth), access] 37 | meta_class.send :remove_method, meth 38 | end 39 | end 40 | 41 | # Add a method, to redirect calls to the target. 42 | methods_to_redirect.each do |meth| 43 | meta_class.send :define_method, meth do |*args, &block| 44 | target.send meth, *args, &block 45 | end 46 | end 47 | 48 | redirection_stack.push [target, methods_overridden, methods_to_redirect] 49 | 50 | target 51 | end 52 | 53 | protected 54 | def pop_redirection_target 55 | meta_class = class << self; self; end 56 | 57 | target, methods_to_recreate, methods_to_remove = redirection_stack.pop 58 | 59 | # Remove the redirection methods 60 | methods_to_remove.reverse_each do |meth| 61 | meta_class.send :remove_method, meth 62 | end 63 | 64 | # Replace with the previous versions of the methods. 65 | methods_to_recreate.reverse_each do |meth, reference, access| 66 | meta_class.send :define_method, meth, reference 67 | meta_class.send access, meth unless access == :public 68 | end 69 | 70 | target 71 | end 72 | 73 | # Direct access to the redirection stack. 74 | private 75 | def redirection_stack 76 | @_redirection_stack ||= [] 77 | end 78 | end 79 | end 80 | 81 | class Object 82 | include Fidgit::RedirectorMethods 83 | end -------------------------------------------------------------------------------- /lib/fidgit/history.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | # Manages a history of actions, along with doing, undoing and redoing those actions. 3 | class History 4 | # Maximum number of actions in the History before Actions are deleted. 5 | DEFAULT_MAX_SIZE = 250 6 | 7 | # An action in the History. Inherit actions from this in order to add them to a History. 8 | class Action 9 | # Perform the action. 10 | def do; raise NotImplementedError, "#{self.class} does not have a do method defined"; end 11 | 12 | # Reverse the action. 13 | def undo; raise NotImplementedError, "#{self.class} does not have an undo method defined"; end 14 | end 15 | 16 | # Is there an action that can be undone? 17 | def can_undo?; @last_done >= 0; end 18 | 19 | # Is there an action that has been undone that can now be redone? 20 | def can_redo?; @last_done < (@actions.size - 1); end 21 | 22 | def initialize(max_size = DEFAULT_MAX_SIZE) 23 | @max_size = max_size 24 | @actions = [] 25 | @last_done = -1 # Last command that was performed. 26 | end 27 | 28 | # Perform a History::Action, adding it to the history. 29 | # If there are currently any actions that have been undone, they will be permanently lost and cannot be redone. 30 | # 31 | # @param [History::Action] action Action to be performed 32 | def do(action) 33 | raise ArgumentError, "Parameter, 'action', expected to be a #{Action}, but received: #{action}" unless action.is_a? Action 34 | 35 | # Remove all undone actions when a new one is performed. 36 | if can_redo? 37 | if @last_done == -1 38 | @actions.clear 39 | else 40 | @actions = @actions[0..@last_done] 41 | end 42 | end 43 | 44 | # If history is too big, remove the oldest action. 45 | if @actions.size >= @max_size 46 | @actions.shift 47 | end 48 | 49 | @last_done = @actions.size 50 | @actions << action 51 | action.do 52 | 53 | nil 54 | end 55 | 56 | # Perform a History::Action, replacing the last action that was performed. 57 | # 58 | # @param [History::Action] action Action to be performed 59 | def replace_last(action) 60 | raise ArgumentError, "Parameter, 'action', expected to be a #{Action}, but received: #{action}" unless action.is_a? Action 61 | 62 | @actions[@last_done].undo 63 | @actions[@last_done] = action 64 | action.do 65 | 66 | nil 67 | end 68 | 69 | # Undo the last action that was performed. 70 | def undo 71 | raise "Can't undo unless there are commands in past" unless can_undo? 72 | 73 | @actions[@last_done].undo 74 | @last_done -= 1 75 | 76 | nil 77 | end 78 | 79 | # Redo the last action that was undone. 80 | def redo 81 | raise "Can't redo if there are no commands in the future" unless can_redo? 82 | 83 | @last_done += 1 84 | @actions[@last_done].do 85 | 86 | nil 87 | end 88 | end 89 | end -------------------------------------------------------------------------------- /lib/fidgit/elements/label.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | class Label < Composite 3 | ICON_POSITIONS = [:top, :bottom, :left, :right] 4 | 5 | attr_reader :icon_position 6 | 7 | attr_accessor :background_color, :border_color 8 | 9 | def_delegators :@text, :text, :color, :font, :color=, :text= 10 | 11 | def icon; @icon ? @icon.image : nil; end 12 | 13 | def hit_element(x, y) 14 | # The sub-elements should never get events. 15 | hit?(x, y) ? self : nil 16 | end 17 | 18 | def icon=(icon) 19 | raise ArgumentError.new("Icon must be a Gosu::Image") unless icon.is_a? Gosu::Image or icon.nil? 20 | 21 | @contents.remove(@icon) if @icon.image 22 | @icon.image = icon 23 | position = [:left, :top].include?(icon_position) ? 0 : 1 24 | @contents.insert(position, @icon) if @icon.image 25 | 26 | icon 27 | end 28 | 29 | # Set the position of the icon, respective to the text. 30 | def icon_position=(position) 31 | raise ArgumentError.new("icon_position must be one of #{ICON_POSITIONS}") unless ICON_POSITIONS.include? position 32 | 33 | @icon_position = position 34 | 35 | case @icon_position 36 | when :top, :bottom 37 | @contents.instance_variable_set :@type, :fixed_columns 38 | @contents.instance_variable_set :@num_columns, 1 39 | when :left, :right 40 | @contents.instance_variable_set :@type, :fixed_rows 41 | @contents.instance_variable_set :@num_rows, 1 42 | end 43 | 44 | self.icon = @icon.image if @icon.image # Force the icon into the correct position. 45 | 46 | position 47 | end 48 | 49 | # @param (see Element#initialize) 50 | # @param [String] text The string to display in the label. 51 | # 52 | # @option (see Element#initialize) 53 | # @option options [Gosu::Image, nil] :icon (nil) 54 | # @option options [:left, :right, :center] :justify (:left) Text justification. 55 | def initialize(text, options = {}) 56 | options = { 57 | color: default(:color), 58 | justify: default(:justify), 59 | background_color: default(:background_color), 60 | border_color: default(:border_color), 61 | icon_options: {}, 62 | font_name: default(:font_name), 63 | font_height: default(:font_height), 64 | icon_position: default(:icon_position), 65 | }.merge! options 66 | 67 | super(options) 68 | 69 | # Bit of a fudge since font info is managed circularly here! 70 | # By using a grid, we'll be able to turn it around easily (in theory). 71 | @contents = grid num_rows: 1, padding: 0, spacing_h: spacing_h, spacing_v: spacing_v, width: options[:width], height: options[:height], z: z do |contents| 72 | @text = TextLine.new(text, parent: contents, justify: options[:justify], color: options[:color], padding: 0, z: z, 73 | font_name: options[:font_name], font_height: options[:font_height], align_h: :fill, align_v: :center) 74 | end 75 | 76 | # Create an image frame, but don't show it unless there is an image in it. 77 | @icon = ImageFrame.new(nil, options[:icon_options].merge(z: z, align: :center)) 78 | @icon.image = options[:icon] 79 | 80 | self.icon_position = options[:icon_position] 81 | end 82 | end 83 | end -------------------------------------------------------------------------------- /lib/fidgit/elements/button.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | class Button < Label 3 | # @param (see Label#initialize) 4 | # @option (see Label#initialize) 5 | # @option options [Symbol] :shortcut (nil) Adds a shortcut key for this element, that activates it. :auto takes the first letter of the text. 6 | def initialize(text, options = {}, &block) 7 | options = { 8 | color: default(:color), 9 | background_color: default(:background_color), 10 | border_color: default(:border_color), 11 | shortcut_color: default(:shortcut_color), 12 | shortcut: nil, 13 | }.merge! options 14 | 15 | @shortcut_color = options[:shortcut_color].dup 16 | 17 | @shortcut = if options[:shortcut] == :auto 18 | raise ArgumentError.new("Can't use :auto for :shortcut without text") if text.empty? 19 | text[0].downcase.to_sym 20 | else 21 | options[:shortcut] 22 | end 23 | 24 | raise ArgumentError.new(":shortcut must be a symbol") unless @shortcut.nil? or @shortcut.is_a? Symbol 25 | 26 | super(text, options) 27 | 28 | self.text = text # Force shortcut to be written out properly. 29 | 30 | update_colors 31 | end 32 | 33 | def text=(value) 34 | if @shortcut 35 | super value.sub(/#{Regexp.escape @shortcut}/i) {|char| "#{char}" } 36 | else 37 | super value 38 | end 39 | end 40 | 41 | def parent=(value) 42 | if @shortcut 43 | state = $window.game_state_manager.inside_state || $window.current_game_state 44 | if parent 45 | raise ArgumentError.new("Repeat of shortcut #{@shortcut.inspect}") if state.input.has_key? @shortcut 46 | state.on_input(@shortcut) { activate unless state.focus } 47 | else 48 | state.input.delete @shortcut 49 | end 50 | end 51 | 52 | super(value) 53 | end 54 | 55 | def clicked_left_mouse_button(sender, x, y) 56 | # TODO: Play click sound? 57 | nil 58 | end 59 | 60 | def enabled=(value) 61 | super(value) 62 | update_colors 63 | 64 | value 65 | end 66 | 67 | def enter(sender) 68 | @mouse_over = true 69 | update_colors 70 | 71 | nil 72 | end 73 | 74 | def leave(sender) 75 | @mouse_over = false 76 | update_colors 77 | 78 | nil 79 | end 80 | 81 | protected 82 | def update_colors 83 | [:color, :background_color].each do |attribute| 84 | send :"#{attribute.to_s}=", enabled? ? default(attribute) : default(:disabled, attribute) 85 | end 86 | 87 | self.background_color = if @mouse_over and enabled? 88 | default(:hover, :background_color) 89 | else 90 | default(:background_color) 91 | end 92 | 93 | @icon.enabled = enabled? if @icon 94 | 95 | nil 96 | end 97 | 98 | protected 99 | # A block added to any button subscribes to LMB click. 100 | def post_init_block(&block) 101 | subscribe :clicked_left_mouse_button, &block 102 | end 103 | 104 | public 105 | # Activate the button, as though it had been clicked on. 106 | # Does not do anything if the button is disabled. 107 | def activate 108 | publish(:clicked_left_mouse_button, x + width / 2, y + height / 2) if enabled? 109 | end 110 | end 111 | end -------------------------------------------------------------------------------- /lib/fidgit/elements/slider.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | class Slider < Composite 3 | # @private 4 | class Handle < Element 5 | event :begin_drag 6 | event :end_drag 7 | event :update_drag 8 | 9 | def drag?(button); button == :left; end 10 | 11 | # @param (see Element#initialize) 12 | # 13 | # @option (see Element#initialize) 14 | def initialize(options = {}, &block) 15 | options = { 16 | background_color: default(:background_color), 17 | border_color: default(:border_color), 18 | }.merge! options 19 | 20 | super options 21 | 22 | subscribe :begin_drag do |sender, x, y| 23 | # Store position of the handle when it starts to drag. 24 | @drag_start_pos = [x - self.x, y - self.y] 25 | end 26 | 27 | subscribe :update_drag do |sender, x, y| 28 | if parent.enabled? 29 | parent.handle_dragged_to x - @drag_start_pos[0], y - @drag_start_pos[1] 30 | else 31 | publish :end_drag 32 | end 33 | end 34 | 35 | subscribe :end_drag do 36 | @drag_start_pos = nil 37 | end 38 | end 39 | 40 | def tip; parent.tip; end 41 | end 42 | 43 | event :changed 44 | 45 | attr_reader :value, :range, :handle 46 | 47 | # @param (see Composite#initialize) 48 | # 49 | # @option (see Composite#initialize) 50 | # @option options [Range] :range (0.0..1.0) 51 | # @option options [Range] :value (minimum of :range) 52 | def initialize(options = {}, &block) 53 | options = { 54 | range: 0.0..1.0, 55 | height: 25, 56 | background_color: default(:background_color), 57 | border_color: default(:border_color), 58 | groove_color: default(:groove_color), 59 | handle_color: default(:handle_color), 60 | groove_thickness: 5, 61 | }.merge! options 62 | 63 | @range = options[:range].dup 64 | @groove_color = options[:groove_color].dup 65 | @groove_thickness = options[:groove_thickness] 66 | @continuous = @range.min.is_a?(Float) || @range.max.is_a?(Float) 67 | 68 | super(options) 69 | 70 | @handle = Handle.new(parent: self, width: (height / 2 - padding_left), height: height - padding_top + padding_bottom, 71 | background_color: options[:handle_color]) 72 | 73 | self.value = options.has_key?(:value) ? options[:value] : @range.min 74 | end 75 | 76 | def value=(value) 77 | @value = @continuous ? value.to_f : value.round 78 | @value = [[@value, @range.min].max, @range.max].min 79 | @handle.x = x + padding_left + ((width - @handle.width) * (@value - @range.min) / (@range.max - @range.min).to_f) 80 | publish :changed, @value 81 | 82 | @value 83 | end 84 | 85 | def tip 86 | tip = super 87 | tip.empty? ? @value.to_s : "#{tip}: #{@value}" 88 | end 89 | 90 | def left_mouse_button(sender, x, y) 91 | # In this case, x should be the centre of the handle after it has moved. 92 | self.value = ((x - (@handle.width / 2) - self.x) / (width - @handle.width)) * (@range.max - @range.min) + @range.min 93 | @mouse_down = true 94 | 95 | nil 96 | end 97 | 98 | def handle_dragged_to(x, y) 99 | # In this case, x is the left-hand side fo the handle. 100 | self.value = ((x - self.x) / (width - @handle.width)) * (@range.max - @range.min) + @range.min 101 | end 102 | 103 | protected 104 | # Prevent standard packing layout change. 105 | def layout 106 | nil 107 | end 108 | 109 | protected 110 | def draw_background 111 | super 112 | # Draw a groove for the handle to move along. 113 | draw_rect x + (@handle.width / 2), y + (height - @groove_thickness) / 2, width - @handle.width, @groove_thickness, z, @groove_color 114 | nil 115 | end 116 | 117 | protected 118 | # Use block as an event handler. 119 | def post_init_block(&block) 120 | subscribe :changed, &block 121 | end 122 | end 123 | end -------------------------------------------------------------------------------- /lib/fidgit/elements/scroll_bar.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | # @abstract 3 | class ScrollBar < Composite 4 | class Handle < Element 5 | event :begin_drag 6 | event :update_drag 7 | event :end_drag 8 | 9 | def drag?(button); button == :left; end 10 | 11 | def initialize(options = {}) 12 | super options 13 | 14 | subscribe :begin_drag do |sender, x, y| 15 | # Store position of the handle when it starts to drag. 16 | @drag_start_pos = [x - self.x, y - self.y] 17 | end 18 | 19 | subscribe :update_drag do |sender, x, y| 20 | parent.parent.handle_dragged_to x - @drag_start_pos[0], y - @drag_start_pos[1] 21 | end 22 | 23 | subscribe :end_drag do 24 | @drag_start_pos = nil 25 | end 26 | end 27 | end 28 | 29 | def initialize(options = {}) 30 | options = { 31 | background_color: default(:background_color), 32 | border_color: default(:border_color), 33 | rail_width: default(:rail_width), 34 | rail_color: default(:rail_color), 35 | handle_color: default(:handle_color), 36 | owner: nil, 37 | }.merge! options 38 | 39 | @owner = options[:owner] 40 | @rail_thickness = options[:rail_width] 41 | @rail_color = options[:rail_color] 42 | 43 | super options 44 | 45 | @handle_container = Container.new(parent: self, width: options[:width], height: options[:height]) do 46 | @handle = Handle.new(parent: self, x: x, y: y, background_color: options[:handle_color]) 47 | end 48 | 49 | subscribe :left_mouse_button do |sender, x, y| 50 | clicked_to_move x, y 51 | end 52 | end 53 | end 54 | 55 | class HorizontalScrollBar < ScrollBar 56 | attr_reader :owner 57 | 58 | def initialize(options = {}) 59 | super options 60 | 61 | @handle.height = height 62 | 63 | @handle_container.subscribe :left_mouse_button do |sender, x, y| 64 | distance = @owner.view_width 65 | @owner.offset_x += (x > @handle.x)? +distance : -distance 66 | end 67 | end 68 | 69 | def update 70 | window = parent.parent 71 | 72 | # Resize and re-locate the handles based on changes to the scroll-window. 73 | content_width = window.content_width.to_f 74 | @handle.width = (window.view_width * width) / content_width 75 | @handle.x = x + (window.offset_x * width) / content_width 76 | end 77 | 78 | def draw_foreground 79 | draw_rect x + padding_left, y + (height - @rail_thickness) / 2, width, @rail_thickness, z, @rail_color 80 | super 81 | end 82 | 83 | def handle_dragged_to(x, y) 84 | @owner.offset_x = @owner.content_width * ((x - self.x) / width.to_f) 85 | end 86 | 87 | def clicked_to_move(x, y) 88 | new_x = x < @handle.x ? @handle.x - @handle.width : @handle.x + @handle.width 89 | handle_dragged_to new_x, @handle.y 90 | end 91 | end 92 | 93 | class VerticalScrollBar < ScrollBar 94 | def initialize(options = {}) 95 | super options 96 | 97 | @handle.width = width 98 | 99 | @handle_container.subscribe :left_mouse_button do |sender, x, y| 100 | distance = @owner.view_height 101 | @owner.offset_y += (y > @handle.y)? +distance : -distance 102 | end 103 | end 104 | 105 | def update 106 | window = parent.parent 107 | content_height = window.content_height.to_f 108 | @handle.height = (window.view_height * height) / content_height 109 | @handle.y = y + (window.offset_y * height) / content_height 110 | end 111 | 112 | def draw_foreground 113 | draw_rect x + (width - @rail_thickness) / 2, y + padding_top, @rail_thickness, height, z, @rail_color 114 | super 115 | end 116 | 117 | def handle_dragged_to(x, y) 118 | @owner.offset_y = @owner.content_height * ((y - self.y) / height.to_f) 119 | end 120 | 121 | def clicked_to_move(x, y) 122 | new_y = y < @handle.y ? @handle.y - @handle.height : @handle.y + @handle.height 123 | handle_dragged_to @handle.x, new_y 124 | end 125 | end 126 | end -------------------------------------------------------------------------------- /lib/fidgit/gosu_ext/color.rb: -------------------------------------------------------------------------------- 1 | module Gosu 2 | class Color 3 | # Is the color completely transparent? 4 | def transparent?; alpha == 0; end 5 | # Is the color completely opaque? 6 | def opaque?; alpha == 255; end 7 | 8 | # RGB in 0..255 format (Alpha assumed 255) 9 | # 10 | # @param [Integer] red 11 | # @param [Integer] green 12 | # @param [Integer] blue 13 | # @return [Color] 14 | def self.rgb(red, green, blue) 15 | new(255, red, green, blue) 16 | end 17 | 18 | # RGBA in 0..255 format 19 | # 20 | # @param [Integer] red 21 | # @param [Integer] green 22 | # @param [Integer] blue 23 | # @param [Integer] alpha 24 | # @return [Color] 25 | def self.rgba(red, green, blue, alpha) 26 | new(alpha, red, green, blue) 27 | end 28 | 29 | # ARGB in 0..255 format (equivalent to Color.new, but explicit) 30 | # 31 | # @param [Integer] alpha 32 | # @param (see Color.rgb) 33 | # @return [Color] 34 | def self.argb(alpha, red, green, blue) 35 | new(alpha, red, green, blue) 36 | end 37 | 38 | # HSV format (alpha assumed to be 255) 39 | # 40 | # @param [Float] hue 0.0..360.0 41 | # @param [Float] saturation 0.0..1.0 42 | # @param [Float] value 0.0..1.0 43 | # @return [Color] 44 | def self.hsv(hue, saturation, value) 45 | from_hsv(hue, saturation, value) 46 | end 47 | 48 | # HSVA format 49 | # 50 | # @param [Float] hue 0.0..360.0 51 | # @param [Float] saturation 0.0..1.0 52 | # @param [Float] value 0.0..1.0 53 | # @param [Integer] alpha 1..255 54 | # @return [Color] 55 | def self.hsva(hue, saturation, value, alpha) 56 | from_ahsv(alpha, hue, saturation, value) 57 | end 58 | 59 | class << self 60 | alias_method :ahsv, :from_ahsv 61 | end 62 | 63 | # Convert from an RGBA array, as used by TexPlay. 64 | # 65 | # @param [Array] color TexPlay color [r, g, b, a] in range 0.0..1.0 66 | # @return [Color] 67 | def self.from_tex_play(color) 68 | rgba(*color.map {|c| (c * 255).to_i }) 69 | end 70 | 71 | # Convert to an RGBA array, as used by TexPlay. 72 | # 73 | # @return [Array] TexPlay color array [r, g, b, a] in range 0.0..1.0 74 | def to_tex_play 75 | [red / 255.0, green / 255.0, blue / 255.0, alpha / 255.0] 76 | end 77 | 78 | # Convert to a 6-digit hexadecimal value, appropriate for passing to the Gosu ++ tag. The alpha channel is ignored. 79 | # 80 | # @return [String] RGB hexadecimal string, such as "AABB00" 81 | def to_hex 82 | "%02x%02x%02x" % [red, green, blue] 83 | end 84 | 85 | # Colorize text for in-line rendering by Gosu. 86 | # e.g. "frog" => "frog" 87 | def colorize(text) 88 | "#{text}" 89 | end 90 | 91 | # Show the Color as or, if opaque, (Gosu default is '(ARGB:0/0/0/0)') 92 | def to_s 93 | if opaque? 94 | "" 95 | else 96 | "" 97 | end 98 | end 99 | 100 | def +(other) 101 | raise ArgumentError, "Can only add another #{self.class}" unless other.is_a? Color 102 | 103 | copy = Color.new(0) 104 | 105 | copy.red = [red + other.red, 255].min 106 | copy.green = [green + other.green, 255].min 107 | copy.blue = [blue + other.blue, 255].min 108 | copy.alpha = [alpha + other.alpha, 255].min 109 | 110 | copy 111 | end 112 | 113 | def -(other) 114 | raise ArgumentError, "Can only take away another #{self.class}" unless other.is_a? Color 115 | 116 | copy = Color.new(0) 117 | 118 | copy.red = [red - other.red, 0].max 119 | copy.green = [green - other.green, 0].max 120 | copy.blue = [blue - other.blue, 0].max 121 | copy.alpha = [alpha - other.alpha, 0].max 122 | 123 | copy 124 | end 125 | 126 | def ==(other) 127 | if other.is_a? Color 128 | red == other.red and green == other.green and blue == other.blue and alpha == other.alpha 129 | else 130 | false 131 | end 132 | end 133 | end 134 | end -------------------------------------------------------------------------------- /spec/fidgit/gosu_ext/color_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "helpers/helper" 2 | 3 | include Gosu 4 | 5 | describe Color do 6 | describe "rgb" do 7 | it "should create a color with the correct values from channel values" do 8 | color = Color.rgb(1, 2, 3) 9 | color.red.should equal 1 10 | color.green.should equal 2 11 | color.blue.should equal 3 12 | color.alpha.should equal 255 13 | end 14 | end 15 | 16 | describe "rgba" do 17 | it "should create a color with the correct value from channel valuess" do 18 | color = Color.rgba(1, 2, 3, 4) 19 | color.red.should equal 1 20 | color.green.should equal 2 21 | color.blue.should equal 3 22 | color.alpha.should equal 4 23 | end 24 | end 25 | 26 | describe "argb" do 27 | it "should create a color with the correct values from channel values" do 28 | color = Color.argb(1, 2, 3, 4) 29 | color.red.should equal 2 30 | color.green.should equal 3 31 | color.blue.should equal 4 32 | color.alpha.should equal 1 33 | end 34 | end 35 | 36 | describe "ahsv" do 37 | it "should create a color with the correct values from channel values" do 38 | color = Color.ahsv(1, 180.0, 0.5, 0.7) 39 | color.hue.should be_within(0.001).of(180.0) 40 | color.saturation.should be_within(0.01).of(0.5) 41 | color.value.should be_within(0.01).of(0.7) 42 | color.alpha.should equal 1 43 | end 44 | end 45 | 46 | describe "hsva" do 47 | it "should create a color with the correct values from channel values" do 48 | color = Color.hsva(180.0, 0.5, 0.7, 4) 49 | color.hue.should be_within(0.001).of(180.0) 50 | color.saturation.should be_within(0.01).of(0.5) 51 | color.value.should be_within(0.01).of(0.7) 52 | color.alpha.should equal 4 53 | end 54 | end 55 | 56 | describe "from_tex_play" do 57 | it "should create a color with the correct values" do 58 | color = Color.from_tex_play([1 / 255.0, 2 / 255.0, 3 / 255.0, 4 / 255.0]) 59 | color.red.should equal 1 60 | color.green.should equal 2 61 | color.blue.should equal 3 62 | color.alpha.should equal 4 63 | end 64 | end 65 | 66 | describe "#to_tex_play" do 67 | it "should create an array with the correct values" do 68 | array = Color.rgba(1, 2, 3, 4).to_tex_play 69 | array.should eq [1 / 255.0, 2 / 255.0, 3 / 255.0, 4 / 255.0] 70 | end 71 | end 72 | 73 | describe "#==" do 74 | it "should return true for colours that are identical" do 75 | (Color.rgb(1, 2, 3) == Color.rgb(1, 2, 3)).should be_true 76 | end 77 | 78 | it "should return false for colours that are not the same" do 79 | (Color.rgb(1, 2, 3) == Color.rgb(4, 2, 3)).should be_false 80 | end 81 | end 82 | 83 | describe "#transparent?" do 84 | it "should be true if alpha is 0" do 85 | Color.rgba(1, 1, 1, 0).should be_transparent 86 | end 87 | 88 | it "should be false if 0 > alpha > 255" do 89 | Color.rgba(1, 1, 1, 2).should_not be_transparent 90 | end 91 | 92 | it "should be false if alpha == 255" do 93 | Color.rgba(1, 1, 1, 255).should_not be_transparent 94 | end 95 | end 96 | 97 | describe "#opaque?" do 98 | it "should be true if alpha is 0" do 99 | Color.rgba(1, 1, 1, 0).should_not be_opaque 100 | end 101 | 102 | it "should be false if 0 > alpha > 255" do 103 | Color.rgba(1, 1, 1, 2).should_not be_opaque 104 | end 105 | 106 | it "should be false if alpha == 255" do 107 | Color.rgba(1, 1, 1, 255).should be_opaque 108 | end 109 | end 110 | 111 | describe "#+" do 112 | it "should add two colours together" do 113 | (Color.rgba(1, 2, 3, 4) + Color.rgba(10, 20, 30, 40)).should == Color.rgba(11, 22, 33, 44) 114 | end 115 | 116 | it "should cap values at 255" do 117 | (Color.rgba(56, 56, 56, 56) + Color.rgba(200, 200, 200, 200)).should == Color.rgba(255, 255, 255, 255) 118 | end 119 | end 120 | 121 | describe "#-" do 122 | it "should subtract one color from another two colours together" do 123 | (Color.rgba(10, 20, 30, 40) - Color.rgba(1, 2, 3, 4)).should == Color.rgba(9, 18, 27, 36) 124 | end 125 | 126 | it "should cap values at 0" do 127 | (Color.rgba(56, 56, 56, 56) - Color.rgba(57, 57, 57, 57)).should == Color.rgba(0, 0, 0, 0) 128 | end 129 | end 130 | end -------------------------------------------------------------------------------- /spec/fidgit/history_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/helper' 2 | 3 | require 'history' 4 | 5 | module Fidgit 6 | class History 7 | class Maths < Action 8 | def initialize(value) 9 | @value = value 10 | end 11 | end 12 | 13 | class Add < Maths 14 | def do 15 | $x += @value 16 | end 17 | 18 | def undo 19 | $x -= @value 20 | end 21 | end 22 | 23 | class Sub < Maths 24 | def do 25 | $x -= @value 26 | end 27 | 28 | def undo 29 | $x += @value 30 | end 31 | end 32 | end 33 | 34 | describe History do 35 | before :each do 36 | $x = 0 # Used as target of test action. 37 | @object = described_class.new 38 | end 39 | 40 | describe "initialize()" do 41 | it "should produce an object that is not undoable or redoable" do 42 | @object.can_undo?.should be_false 43 | @object.can_redo?.should be_false 44 | end 45 | end 46 | 47 | describe "do()" do 48 | it "should raise an error if the parameter is incorrect" do 49 | lambda { @object.do(12) }.should raise_error ArgumentError 50 | end 51 | 52 | it "should perform the action's do() method correctly" do 53 | @object.do(History::Add.new(2)) 54 | 55 | $x.should == 2 56 | @object.can_undo?.should be_true 57 | @object.can_redo?.should be_false 58 | end 59 | 60 | it "should correctly delete any re-doable actions, even if there is only one" do 61 | @object.do(History::Add.new(2)) 62 | @object.undo 63 | @object.do(History::Add.new(3)) 64 | @object.undo 65 | 66 | @object.can_undo?.should be_false 67 | end 68 | end 69 | 70 | describe "undo()" do 71 | it "should raise an error if there is nothing to undo" do 72 | lambda { @object.undo }.should raise_error 73 | end 74 | 75 | it "should perform the undo method on the action" do 76 | @object.do(History::Add.new(2)) 77 | @object.undo 78 | 79 | $x.should == 0 80 | @object.can_undo?.should be_false 81 | @object.can_redo?.should be_true 82 | end 83 | end 84 | 85 | describe "redo()" do 86 | it "should raise an error if there is nothing to redo" do 87 | lambda { @object.redo }.should raise_error 88 | end 89 | 90 | it "should perform the undo method on the action" do 91 | @object.do(History::Add.new(2)) 92 | @object.undo 93 | @object.redo 94 | 95 | $x.should == 2 96 | @object.can_undo?.should be_true 97 | @object.can_redo?.should be_false 98 | end 99 | end 100 | 101 | describe "replace_last()" do 102 | it "should raise an error if there is nothing to redo" do 103 | lambda { @object.replace_last(12) }.should raise_error ArgumentError 104 | end 105 | 106 | it "should raise an error if there is nothing to replace" do 107 | lambda { @object.replace_last(History::Add2.new) }.should raise_error 108 | end 109 | 110 | it "should correctly replace the last action" do 111 | @object.do(History::Add.new(2)) 112 | @object.replace_last(History::Sub.new(1)) 113 | 114 | $x.should == -1 115 | @object.can_redo?.should be_false 116 | @object.can_undo?.should be_true 117 | end 118 | 119 | it "should not remove redoable actions" do 120 | @object.do(History::Add.new(2)) 121 | @object.do(History::Add.new(8)) 122 | @object.undo 123 | @object.replace_last(History::Sub.new(1)) 124 | 125 | $x.should == -1 126 | @object.can_redo?.should be_true 127 | @object.can_undo?.should be_true 128 | 129 | @object.redo 130 | $x.should == 7 131 | end 132 | end 133 | end 134 | 135 | # Abstract class, so doesn't really do anything. 136 | describe History::Action do 137 | before :each do 138 | @object = described_class.new 139 | end 140 | 141 | describe "do()" do 142 | it "should raise an error" do 143 | lambda { @object.do }.should raise_error NotImplementedError 144 | end 145 | end 146 | 147 | describe "undo()" do 148 | it "should raise an error" do 149 | lambda { @object.undo }.should raise_error NotImplementedError 150 | end 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /lib/fidgit/schema.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | # An object that manages Schema values. Usually loaded from a YAML file. 3 | # 4 | # @example 5 | # schema = Schema.new(YAML.load(file.read('default_schema.yml'))) 6 | # default_color = schema.default(Element, :disabled, :color) 7 | # schema.merge_schema!(YAML.load(file.read('override_schema.yml')) 8 | # overridden_color = schema.default(Element, :disabled, :color) 9 | class Schema 10 | CONSTANT_PREFIX = '?' 11 | 12 | # @param [Hash Hash>] schema data containing 13 | def initialize(schema) 14 | @constants = {} 15 | @elements = {} 16 | 17 | merge_schema! schema 18 | end 19 | 20 | # Merge in a hash containing constant values. 21 | # 22 | # @param [Hash Hash>] constants_hash Containing :colors, :constants and :elements hashes. 23 | def merge_schema!(schema) 24 | merge_constants!(schema[:constants]) if schema[:constants] 25 | merge_elements!(schema[:elements]) if schema[:elements] 26 | 27 | self 28 | end 29 | 30 | # Merge in a hash containing constant values. Arrays will be resolved as colors in RGBA or RGB format. 31 | # 32 | # @param [Hash Object>] constants_hash 33 | def merge_constants!(constants_hash) 34 | constants_hash.each_pair do |name, value| 35 | @constants[name] = case value 36 | when Array 37 | case value.size 38 | when 3 then Gosu::Color.rgb(*value) 39 | when 4 then Gosu::Color.rgba(*value) 40 | else 41 | raise "Colors must be in 0..255, RGB or RGBA array format" 42 | end 43 | else 44 | value 45 | end 46 | end 47 | 48 | self 49 | end 50 | 51 | # Merge in a hash containing default values for each element. 52 | # 53 | # @param [Hash Hash>] elements_hash 54 | def merge_elements!(elements_hash) 55 | elements_hash.each_pair do |klass_names, data| 56 | klass = Fidgit 57 | klass_names.to_s.split('::').each do |klass_name| 58 | klass = klass.const_get klass_name 59 | end 60 | 61 | raise "elements must be names of classes derived from #{Element}" unless klass.ancestors.include? Fidgit::Element 62 | @elements[klass] ||= {} 63 | @elements[klass].deep_merge! data 64 | end 65 | 66 | self 67 | end 68 | 69 | # Get the constant value associated with +name+. 70 | # 71 | # @param [Symbol] name 72 | # @return [Object] 73 | def constant(name) 74 | @constants[name] 75 | end 76 | 77 | # @param [Class] klass Class to look for defaults for. 78 | # @param [Symbol, Array] names Hash names to search for in that class's schema. 79 | def default(klass, names) 80 | raise ArgumentError, "#{klass} is not a descendent of the #{Element} class" unless klass.ancestors.include? Element 81 | value = default_internal(klass, Array(names), true) 82 | raise("Failed to find named value #{names.inspect} for class #{klass}") unless value 83 | value 84 | end 85 | 86 | protected 87 | # @param [Class] klass Class to look for defaults for. 88 | # @param [Array] names Hash names to search for in that class's schema. 89 | # @param [Boolean] default_to_outer Whether to default to an outer value (used internally) 90 | def default_internal(klass, names, default_to_outer) 91 | # Find the value by moving through the nested hash via the names. 92 | value = @elements[klass] 93 | 94 | names.each do |name| 95 | break unless value.is_a? Hash 96 | value = value.has_key?(name) ? value[name] : nil 97 | end 98 | 99 | # Convert the value to a color/constant if they are symbols. 100 | value = if value.is_a? String and value[0] == CONSTANT_PREFIX 101 | str = value[1..-1] 102 | constant(str.to_sym) || value # If the value isn't a constant, return the string. 103 | else 104 | value 105 | end 106 | 107 | # If we didn't find the value for this class, default to parent class value. 108 | if value.nil? and klass != Element and klass.ancestors.include? Element 109 | # Check if any ancestors define the fully named value. 110 | value = default_internal(klass.superclass, names, false) 111 | end 112 | 113 | if value.nil? and default_to_outer and names.size > 1 114 | # Check the outer values (e.g. if [:hover, :color] is not defined, try [:color]). 115 | value = default_internal(klass, names[1..-1], true) 116 | end 117 | 118 | value 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/fidgit/elements/menu_pane.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | module Fidgit 4 | class MenuPane < Composite 5 | # An item within the menu. 6 | class Item < Button 7 | attr_reader :value, :shortcut_text 8 | 9 | # @param (see Button#initialize) 10 | # 11 | # @option (see Button#initialize) 12 | # @param [any] value Value if the user picks this item 13 | # @option options [String] :shortcut_text ('') 14 | def initialize(text, value, options = {}) 15 | options = { 16 | enabled: true, 17 | border_color: default(:border_color), 18 | shortcut_text: '', 19 | }.merge! options 20 | 21 | @value = value 22 | @shortcut_text = options[:shortcut_text] 23 | 24 | super(text, options) 25 | end 26 | 27 | def draw_foreground 28 | super 29 | 30 | unless @shortcut_text.empty? 31 | font.draw_rel("#{@shortcut_text}", rect.right - padding_right, y + ((height - font.height) / 2).floor, z, 1, 0, 1, 1, color) 32 | end 33 | 34 | nil 35 | end 36 | 37 | protected 38 | def layout 39 | super 40 | 41 | # Ignore layout request when asked before TextLine has been created. 42 | rect.width += font.text_width(" #{@shortcut_text}") unless @shortcut_text.empty? or @text.nil? 43 | 44 | nil 45 | end 46 | end 47 | 48 | class Separator < Label 49 | # @param (see Item#initialize) 50 | # 51 | # @option (see Item#initialize) 52 | def initialize(options = {}) 53 | options = { 54 | height: default(:line_height), 55 | background_color: default(:background_color), 56 | padding: 0, 57 | }.merge! options 58 | 59 | super '', options 60 | end 61 | end 62 | 63 | extend Forwardable 64 | 65 | def_delegators :@items, :each, :clear, :size, :[] 66 | 67 | event :selected 68 | 69 | def index(value); @items.index find(value); end 70 | def x=(value); super(value); recalc; end 71 | def y=(value); super(value); recalc; end 72 | 73 | # @option (see Composite#initialize) 74 | # @option options [Float] :x (cursor x, if in a GuiState) 75 | # @option options [Float] :y (cursor y, if in a GuiState) 76 | # @option options [Boolean] :show (true) Whether to show immediately (show later with #show). 77 | def initialize(options = {}, &block) 78 | options = { 79 | background_color: default(:color), 80 | z: Float::INFINITY, 81 | show: true, 82 | }.merge! options 83 | 84 | state = $window.current_game_state 85 | if state.is_a? GuiState 86 | cursor = $window.current_game_state.cursor 87 | options = { 88 | x: cursor.x, 89 | y: cursor.y, 90 | }.merge! options 91 | end 92 | 93 | @items = nil 94 | 95 | super(options) 96 | 97 | @items = vertical spacing: 0, padding: 0 98 | 99 | if options[:show] and state.is_a? GuiState 100 | show 101 | end 102 | end 103 | 104 | def find(value) 105 | @items.find {|c| c.value == value } 106 | end 107 | 108 | def separator(options = {}) 109 | options[:z] = z 110 | 111 | Separator.new({ parent: @items }.merge!(options)) 112 | end 113 | 114 | def item(text, value, options = {}, &block) 115 | options = options.merge({ 116 | parent: @items, 117 | z: z, 118 | }) 119 | item = Item.new(text, value, options, &block) 120 | 121 | item.subscribe :left_mouse_button, method(:item_selected) 122 | item.subscribe :right_mouse_button, method(:item_selected) 123 | 124 | item 125 | end 126 | 127 | def item_selected(sender, x, y) 128 | publish(:selected, sender.value) 129 | 130 | $window.game_state_manager.current_game_state.hide_menu 131 | 132 | nil 133 | end 134 | 135 | def show 136 | $window.game_state_manager.current_game_state.show_menu self 137 | nil 138 | end 139 | 140 | protected 141 | def layout 142 | super 143 | 144 | if @items 145 | # Ensure the menu can't go over the edge of the screen. If it can't be avoided, align with top-left edge of screen. 146 | rect.x = [[x, $window.width - width - padding_right].min, 0].max 147 | rect.y = [[y, $window.height - height - padding_bottom].min, 0].max 148 | 149 | # Move the actual list if the menu has moved to keep on the screen. 150 | @items.x = x + padding_left 151 | @items.y = y + padding_top 152 | 153 | # Ensure that all items are of the same width. 154 | max_width = @items.map(&:width).max || 0 155 | @items.each {|c| c.rect.width = max_width } 156 | 157 | @items.recalc # Move all the items inside the packer to correct ones. 158 | end 159 | 160 | nil 161 | end 162 | end 163 | end -------------------------------------------------------------------------------- /config/default_schema.yml: -------------------------------------------------------------------------------- 1 | # Default schema for Fidgit 2 | --- 3 | 4 | # Define all constant values here. For colours, use [R, G, B] or [R, G, B, A]. 5 | # Reference constants with ?constant_name 6 | :constants: 7 | # General constants (strings and numbers). 8 | :scroll_bar_thickness: 12 9 | 10 | # Gosu::Color constants 11 | :none: [0, 0, 0, 0] 12 | 13 | :black: [0, 0, 0] 14 | :white: [255, 255, 255] 15 | 16 | :very_dark_gray: [50, 50, 50] 17 | :dark_gray: [100, 100, 100] 18 | :gray: [150, 150, 150] 19 | :light_gray: [200, 200, 200] 20 | 21 | :red: [255, 0, 0] 22 | :green: [0, 0, 255] 23 | :blue: [0, 0, 255] 24 | 25 | :dark_red: [100, 0, 0] 26 | :dark_green: [0, 100, 0] 27 | :dark_blue: [0, 0, 100] 28 | 29 | # Default element attributes. 30 | :elements: 31 | :Button: # < Label 32 | :background_color: ?dark_gray 33 | :border_color: ?light_gray 34 | :border_thickness: 2 35 | :shortcut_color: ?red 36 | :padding_top: 2 37 | :padding_right: 4 38 | :padding_bottom: 2 39 | :padding_left: 4 40 | 41 | :disabled: 42 | :background_color: ?very_dark_gray 43 | :border_color: ?gray 44 | 45 | :hover: 46 | :background_color: ?gray 47 | 48 | :ColorPicker: 49 | :indicator_height: 30 50 | 51 | :ColorWell: # < RadioButton 52 | :height: 32 53 | :outline_color: ?gray 54 | :width: 32 55 | 56 | :checked: 57 | :border_color: ?very_dark_gray 58 | 59 | :ComboBox: # < Button 60 | :background_color: ?dark_gray 61 | :border_color: ?white 62 | 63 | :Composite: # < Container 64 | :padding_top: 0 65 | :padding_right: 0 66 | :padding_bottom: 0 67 | :padding_left: 0 68 | 69 | :Container: # < Element 70 | {} 71 | 72 | :Element: 73 | :align_h: :left 74 | :align_v: :top 75 | :background_color: ?none 76 | :background_image: nil 77 | :border_color: ?none 78 | :border_thickness: 0 79 | :color: ?white 80 | :font_height: 30 81 | :font_name: :default 82 | :padding_top: 2 83 | :padding_right: 4 84 | :padding_bottom: 2 85 | :padding_left: 4 86 | 87 | 88 | :FileBrowser: # < Composite 89 | :pattern: "*.*" 90 | :show_extension: true 91 | 92 | :Grid: # < Packer 93 | :cell_background_color: ?none 94 | :cell_border_color: ?none 95 | :cell_border_thickness: 0 96 | 97 | :Group: # < Packer 98 | :padding_top: 0 99 | :padding_right: 0 100 | :padding_bottom: 0 101 | :padding_left: 0 102 | 103 | :Horizontal: # < Grid 104 | {} 105 | 106 | :HorizontalScrollBar: # < ScrollBar 107 | :height: ?scroll_bar_thickness 108 | 109 | :ImageFrame: # 0 102 | if num_filled_columns > 0 103 | @widths[filled_columns.index true] += extra_width 104 | else 105 | @widths[-1] += extra_width 106 | end 107 | end 108 | end 109 | 110 | # Expand the size of each filled row to make the minimum size required. 111 | unless @heights.empty? 112 | num_filled_rows = filled_rows.select {|value| value }.count 113 | total_height = @heights.inject(0, :+) + (padding_left + padding_right) + ((@num_rows - 1) * spacing_v) 114 | extra_height = min_height - total_height 115 | if extra_height > 0 116 | if num_filled_rows > 0 117 | @heights[filled_rows.index true] += extra_height 118 | else 119 | @heights[-1] += extra_height 120 | end 121 | end 122 | end 123 | 124 | # Actually place all the elements into the grid positions, modified by valign and align. 125 | current_y = y + padding_top 126 | @rows.each_with_index do |row, row_num| 127 | current_x = x + padding_left 128 | 129 | row.each_with_index do |element, column_num| 130 | element.x = current_x + element.border_thickness 131 | 132 | case element.align_h # Take horizontal alignment into consideration. 133 | when :fill 134 | if element.width < @widths[column_num] 135 | element.width = @widths[column_num] 136 | element.send :repack if element.is_a? Grid 137 | end 138 | when :center 139 | element.x += (@widths[column_num] - element.width) / 2 140 | when :right 141 | element.x += @widths[column_num] - element.width 142 | end 143 | 144 | current_x += @widths[column_num] 145 | current_x += spacing_h unless column_num == @num_columns - 1 146 | 147 | element.y = current_y + element.border_thickness 148 | 149 | case element.align_v # Take horizontal alignment into consideration. 150 | when :fill 151 | if element.height < @heights[row_num] 152 | element.height = @heights[row_num] 153 | element.send :repack if element.is_a? Grid 154 | end 155 | when :center 156 | element.y += (@heights[row_num] - element.height) / 2 157 | when :bottom 158 | element.y += @heights[row_num] - element.height 159 | else 160 | end 161 | end 162 | 163 | self.width = current_x - x + padding_left if row_num == 0 164 | 165 | current_y += @heights[row_num] unless row.empty? 166 | current_y += spacing_h unless row_num == num_rows - 1 167 | end 168 | 169 | self.height = current_y - y + padding_top 170 | 171 | nil 172 | end 173 | 174 | protected 175 | # @yield The rectangle of each cell within the grid. 176 | # @yieldparam [Number] x 177 | # @yieldparam [Number] y 178 | # @yieldparam [Number] width 179 | # @yieldparam [Number] height 180 | def each_cell_rect 181 | x = self.x + padding_left 182 | 183 | @widths.each_with_index do |width, column_num| 184 | y = self.y + padding_top 185 | 186 | @heights.each_with_index do |height, row_num| 187 | yield x, y, width, height if @rows[row_num][column_num] 188 | y += height + spacing_v 189 | end 190 | 191 | x += width + spacing_h 192 | end 193 | 194 | nil 195 | end 196 | 197 | protected 198 | def draw_background 199 | super 200 | 201 | # Draw the cell backgrounds. 202 | unless @cell_background_color.transparent? 203 | each_cell_rect do |x, y, width, height| 204 | draw_rect x, y, width, height, z, @cell_background_color 205 | end 206 | end 207 | 208 | nil 209 | end 210 | 211 | protected 212 | def draw_border 213 | super 214 | 215 | # Draw the cell borders. 216 | if @cell_border_thickness > 0 and not @cell_border_color.transparent? 217 | each_cell_rect do |x, y, width, height| 218 | draw_frame x, y, width, height, @cell_border_thickness, z, @cell_border_color 219 | end 220 | end 221 | 222 | nil 223 | end 224 | end 225 | end -------------------------------------------------------------------------------- /spec/fidgit/event_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helpers/helper' 2 | 3 | require 'event' 4 | 5 | module Fidgit 6 | describe Event do 7 | before :each do 8 | class Test 9 | include Event 10 | end 11 | end 12 | 13 | after :each do 14 | Fidgit.send :remove_const, :Test 15 | end 16 | 17 | subject { Test } 18 | 19 | describe "events" do 20 | it "should initially be empty" do 21 | subject.events.should be_empty 22 | end 23 | end 24 | 25 | describe "event" do 26 | it "should add the event to the list of events handled" do 27 | subject.event :frog 28 | subject.events.should include :frog 29 | end 30 | 31 | it "should inherit parent's events and be able to add more" do 32 | Test.event :frog 33 | class Test2 < Test; end 34 | Test2.event :fish 35 | Test2.events.should include :frog 36 | Test2.events.should include :fish 37 | end 38 | end 39 | 40 | context "When included into a class that is instanced" do 41 | subject { Test.event :frog; Test.new } 42 | 43 | describe "#subscribe" do 44 | it "should add a handler as a block" do 45 | subject.subscribe(:frog) { puts "hello" } 46 | end 47 | 48 | it "should fail if the event name isn't handled by this object" do 49 | ->{ subject.subscribe(:unhandled_event) {} }.should raise_error ArgumentError 50 | end 51 | 52 | it "should add a handler as a method" do 53 | subject.stub! :handler 54 | subject.subscribe(:frog, subject.method(:handler)) 55 | end 56 | 57 | it "should fail if neither a method or a block is passed" do 58 | ->{ subject.subscribe(:frog) }.should raise_error ArgumentError 59 | end 60 | 61 | it "should fail if both a method and a block is passed" do 62 | subject.stub! :handler 63 | ->{ subject.subscribe(:frog, subject.method(:handler)) { } }.should raise_error ArgumentError 64 | end 65 | 66 | it "should return a Subscription" do 67 | handler = proc {} 68 | result = subject.subscribe(:frog, &handler) 69 | result.should be_a Fidgit::Event::Subscription 70 | result.handler.should be handler 71 | result.publisher.should be subject 72 | result.event.should equal :frog 73 | end 74 | 75 | it "should return a Subscription that can use Subscription#unsubscribe" do 76 | handler_ran = false 77 | subscription = subject.subscribe(:frog) { handler_ran = true } 78 | subscription.unsubscribe 79 | subject.publish :frog 80 | handler_ran.should be_false 81 | end 82 | end 83 | 84 | describe "#unsubscribe" do 85 | it "should accept a Subscription" do 86 | handler_ran = false 87 | subscription = subject.subscribe(:frog) { handler_ran = true } 88 | subject.unsubscribe subscription 89 | subject.publish :frog 90 | handler_ran.should be_false 91 | end 92 | 93 | it "should accept a handler" do 94 | handler_ran = false 95 | handler = proc { handler_ran = true } 96 | subject.subscribe(:frog, &handler) 97 | subject.unsubscribe handler 98 | subject.publish :frog 99 | handler_ran.should be_false 100 | end 101 | 102 | it "should accept an event and handler" do 103 | handler_ran = false 104 | handler = proc { handler_ran = true } 105 | subject.subscribe(:frog, &handler) 106 | subject.unsubscribe :frog, handler 107 | subject.publish :frog 108 | handler_ran.should be_false 109 | end 110 | 111 | it "should require 1..2 arguments" do 112 | ->{ subject.unsubscribe }.should raise_error ArgumentError 113 | ->{ subject.unsubscribe 1, 2, 3 }.should raise_error ArgumentError 114 | end 115 | 116 | it "should fail with bad types" do 117 | ->{ subject.unsubscribe 1 }.should raise_error TypeError 118 | ->{ subject.unsubscribe 1, ->{} }.should raise_error TypeError 119 | ->{ subject.unsubscribe :event, 2 }.should raise_error TypeError 120 | end 121 | 122 | it "should fail if passed a Subscription to another object" do 123 | subscription = Fidgit::Event::Subscription.new(Object.new.extend(Fidgit::Event), :event, proc {}) 124 | ->{ subject.unsubscribe subscription }.should raise_error ArgumentError 125 | end 126 | end 127 | 128 | describe "#publish" do 129 | it "should return nil if there are no handlers" do 130 | subject.publish(:frog).should be_nil 131 | end 132 | 133 | it "should return nil if there are no handlers that handle the event" do 134 | subject.should_receive(:frog).with(subject) 135 | subject.should_receive(:handler).with(subject) 136 | subject.subscribe(:frog, subject.method(:handler)) 137 | subject.publish(:frog).should be_nil 138 | end 139 | 140 | it "should return :handled if a manual handler handled the event and not call other handlers" do 141 | subject.should_not_receive(:handler1) 142 | subject.should_receive(:handler2).with(subject).and_return(:handled) 143 | subject.subscribe(:frog, subject.method(:handler1)) 144 | subject.subscribe(:frog, subject.method(:handler2)) 145 | subject.publish(:frog).should == :handled 146 | end 147 | 148 | it "should return :handled if an automatic handler handled the event and not call other handlers" do 149 | subject.should_receive(:frog).with(subject).and_return(:handled) 150 | subject.should_not_receive(:handler2) 151 | subject.subscribe(:frog, subject.method(:handler2)) 152 | subject.publish(:frog).should == :handled 153 | end 154 | 155 | it "should pass the object as the first parameter" do 156 | subject.should_receive(:handler).with(subject) 157 | subject.subscribe(:frog, subject.method(:handler)) 158 | subject.publish :frog 159 | end 160 | 161 | it "should call all the handlers, once each" do 162 | subject.should_receive(:handler1).with(subject) 163 | subject.should_receive(:handler2).with(subject) 164 | subject.subscribe(:frog, subject.method(:handler1)) 165 | subject.subscribe(:frog, subject.method(:handler2)) 166 | subject.publish(:frog).should be_nil 167 | end 168 | 169 | it "should pass parameters passed to it" do 170 | subject.should_receive(:handler).with(subject, 1, 2) 171 | subject.subscribe(:frog, subject.method(:handler)) 172 | subject.publish(:frog, 1, 2).should be_nil 173 | end 174 | 175 | it "should do nothing if the subject is disabled" do 176 | subject.should_receive(:enabled?).and_return(false) 177 | subject.should_not_receive(:handler) 178 | subject.subscribe(:frog, subject.method(:handler)) 179 | subject.publish(:frog, 1, 2).should be_nil 180 | end 181 | 182 | it "should act normally if the subject is enabled" do 183 | subject.should_receive(:enabled?).and_return(true) 184 | subject.should_receive(:handler) 185 | subject.subscribe(:frog, subject.method(:handler)) 186 | subject.publish(:frog, 1, 2).should be_nil 187 | end 188 | 189 | it "should only call the handlers requested" do 190 | Test.event :fish 191 | 192 | subject.should_receive(:handler1).with(subject) 193 | subject.should_not_receive(:handler2) 194 | subject.subscribe(:frog, subject.method(:handler1)) 195 | subject.subscribe(:fish, subject.method(:handler2)) 196 | subject.publish(:frog).should be_nil 197 | end 198 | 199 | it "should automatically call a method on the publisher if it exists" do 200 | subject.should_receive(:frog).with(subject) 201 | subject.publish(:frog).should be_nil 202 | end 203 | 204 | it "should fail if the event name isn't handled by this object" do 205 | ->{ subject.publish(:unhandled_event) }.should raise_error ArgumentError 206 | end 207 | end 208 | end 209 | end 210 | end -------------------------------------------------------------------------------- /lib/fidgit/elements/element.rb: -------------------------------------------------------------------------------- 1 | # The Fidgit GUI framework for Gosu. 2 | module Fidgit 3 | class << self 4 | attr_accessor :debug_mode 5 | end 6 | 7 | self.debug_mode = false 8 | 9 | def self.debug_mode?; debug_mode; end 10 | 11 | # An element within the GUI environment. 12 | # @abstract 13 | class Element 14 | include Event 15 | 16 | event :left_mouse_button 17 | event :holding_left_mouse_button 18 | event :released_left_mouse_button 19 | event :clicked_left_mouse_button 20 | 21 | event :right_mouse_button 22 | event :holding_right_mouse_button 23 | event :released_right_mouse_button 24 | event :clicked_right_mouse_button 25 | 26 | event :middle_mouse_button 27 | event :holding_middle_mouse_button 28 | event :released_middle_mouse_button 29 | event :clicked_middle_mouse_button 30 | 31 | event :mouse_wheel_up 32 | event :mouse_wheel_down 33 | 34 | event :enter 35 | event :hover 36 | event :leave 37 | 38 | DEFAULT_SCHEMA_FILE = File.expand_path(File.join(__FILE__, '..', '..', '..', '..', 'config', 'default_schema.yml')) 39 | 40 | VALID_ALIGN_H = [:left, :center, :right, :fill] 41 | VALID_ALIGN_V = [:top, :center, :bottom, :fill] 42 | 43 | attr_reader :z, :tip, :padding_top, :padding_right, :padding_bottom, :padding_left, 44 | :align_h, :align_v, :parent, :border_thickness, :font 45 | 46 | attr_accessor :background_color 47 | attr_writer :tip 48 | 49 | def x; rect.x; end 50 | def x=(value); rect.x = value; end 51 | 52 | def y; rect.y; end 53 | def y=(value); rect.y = value; end 54 | 55 | # Width not including border. 56 | def width; rect.width; end 57 | def width=(value); rect.width = [[value, @width_range.max].min, @width_range.min].max; end 58 | def min_width; @width_range.min; end 59 | def max_width; @width_range.max; end 60 | # Width including border thickness. 61 | def outer_width; rect.width + @border_thickness * 2; end 62 | 63 | # Height not including border. 64 | def height; rect.height; end 65 | def height=(value); rect.height = [[value, @height_range.max].min, @height_range.min].max; end 66 | def min_height; @height_range.min; end 67 | def max_height; @height_range.max; end 68 | # Height including border thickness. 69 | def outer_height; rect.height + @border_thickness * 2; end 70 | 71 | # Can the object be dragged? 72 | def drag?(button); false; end 73 | 74 | def enabled?; @enabled; end 75 | 76 | def enabled=(value) 77 | if @mouse_over and enabled? and not value 78 | $window.current_game_state.unset_mouse_over 79 | end 80 | 81 | @enabled = value 82 | end 83 | 84 | def rect; @rect; end; protected :rect 85 | 86 | def self.schema; @@schema ||= Schema.new(YAML.load(File.read(DEFAULT_SCHEMA_FILE)));; end 87 | 88 | class << self 89 | alias_method :original_new, :new 90 | 91 | def new(*args, &block) 92 | obj = original_new(*args) # Block should be ignored. 93 | obj.send :post_init 94 | obj.send :post_init_block, &block if block_given? 95 | obj 96 | end 97 | end 98 | 99 | # Get the default value from the schema. 100 | # 101 | # @param [Symbol, Array] names 102 | def default(*names) 103 | self.class.schema.default(self.class, names) 104 | end 105 | 106 | # @param [Element, nil] parent 107 | # 108 | # @option options [Number] :x (0) 109 | # @option options [Number] :y (0) 110 | # @option options [Number] :z (0) 111 | # 112 | # @option options [Number] :width (auto) 113 | # @option options [Number] :min_width (value of :width option) 114 | # @option options [Number] :max_width (value of :width option) 115 | # 116 | # @option options [Number] :height (auto) 117 | # @option options [Number] :min_height (value of :height option) 118 | # @option options [Number] :max_height (value of :height option) 119 | # 120 | # @option options [String] :tip ('') Tool-tip text 121 | # @option options [String, :default] :font_name (:default, which resolves as the default Gosu font) 122 | # @option options [String] :font_height (30) 123 | # @option options [Gosu::Font] :font Use this instead of :font_name and :font_height 124 | # 125 | # @option options [Gosu::Color] :background_color (transparent) 126 | # @option options [Gosu::Color] :border_color (transparent) 127 | # 128 | # @option options [Boolean] :enabled (true) 129 | # 130 | # @option options [Number] :padding (4) 131 | # @option options [Number] :padding_h (:padding option) 132 | # @option options [Number] :padding_v (:padding option) 133 | # @option options [Number] :padding_top (:padding_v option) 134 | # @option options [Number] :padding_right (:padding_h option) 135 | # @option options [Number] :padding_bottom (:padding_v option) 136 | # @option options [Number] :padding_left (:padding_h option) 137 | # 138 | # @option options [Symbol] :align Align both horizontally and vertically. One of :center, :fill or [, ] such as [:top, :right]. 139 | # @option options [Symbol] :align_h (value or :align else :left) One of :left, :center, :right :fill 140 | # @option options [Symbol] :align_v (value of :align else :top) One of :top, :center, :bottom, :fill 141 | 142 | # @yield instance_methods_eval with respect to self. 143 | def initialize(options = {}, &block) 144 | options = { 145 | x: 0, 146 | y: 0, 147 | z: 0, 148 | tip: '', 149 | font_name: default(:font_name), 150 | font_height: default(:font_height), 151 | background_color: default(:background_color), 152 | border_color: default(:border_color), 153 | border_thickness: default(:border_thickness), 154 | enabled: true, 155 | }.merge! options 156 | 157 | @enabled = options[:enabled] 158 | 159 | @mouse_over = false 160 | 161 | # Alignment and min/max dimensions. 162 | @align_h = options[:align_h] || Array(options[:align]).last || default(:align_h) 163 | raise ArgumentError, "Invalid align_h: #{@align_h}" unless VALID_ALIGN_H.include? @align_h 164 | 165 | min_width = (options[:min_width] || options[:width] || 0) 166 | max_width = (options[:max_width] || options[:width] || Float::INFINITY) 167 | @width_range = min_width..max_width 168 | 169 | @align_v = options[:align_v] || Array(options[:align]).first || default(:align_v) 170 | raise ArgumentError, "Invalid align_v: #{@align_v}" unless VALID_ALIGN_V.include? @align_v 171 | 172 | min_height = (options[:min_height] || options[:height] || 0) 173 | max_height = (options[:max_height] || options[:height] || Float::INFINITY) 174 | @height_range = min_height..max_height 175 | 176 | @background_color = options[:background_color].dup 177 | @border_color = options[:border_color].dup 178 | @border_thickness = options[:border_thickness] 179 | 180 | @padding_top = options[:padding_top] || options[:padding_v] || options[:padding] || default(:padding_top) 181 | @padding_right = options[:padding_right] || options[:padding_h] || options[:padding] || default(:padding_right) 182 | @padding_bottom = options[:padding_bottom] || options[:padding_v] || options[:padding] || default(:padding_bottom) 183 | @padding_left = options[:padding_left] || options[:padding_h] || options[:padding] || default(:padding_left) 184 | self.parent = options[:parent] 185 | 186 | @z = options[:z] 187 | @tip = options[:tip].dup 188 | font_name = if options[:font_name].nil? or options[:font_name] == :default 189 | Gosu::default_font_name 190 | else 191 | options[:font_name].dup 192 | end 193 | 194 | @font = options[:font] || Gosu::Font[font_name, options[:font_height]] 195 | 196 | @rect = Chingu::Rect.new(options[:x], options[:y], options[:width] || 0, options[:height] || 0) 197 | end 198 | 199 | def font=(font) 200 | raise TypeError unless font.is_a? Gosu::Font 201 | @font = font 202 | recalc 203 | font 204 | end 205 | 206 | def recalc 207 | old_width, old_height = width, height 208 | layout 209 | parent.recalc if parent and (width != old_width or height != old_height) 210 | 211 | nil 212 | end 213 | 214 | # Check if a point (screen coordinates) is over the element. 215 | def hit?(x, y) 216 | @rect.collide_point?(x, y) 217 | end 218 | 219 | # Redraw the element. 220 | def draw 221 | draw_background 222 | draw_border 223 | draw_foreground 224 | nil 225 | end 226 | 227 | # Update the element. 228 | def update 229 | nil 230 | end 231 | 232 | def draw_rect(*args) 233 | $window.current_game_state.draw_rect(*args) 234 | end 235 | 236 | def draw_frame(*args) 237 | $window.current_game_state.draw_frame(*args) 238 | end 239 | 240 | protected 241 | def parent=(parent); @parent = parent; end 242 | 243 | protected 244 | def draw_background 245 | draw_rect(x, y, width, height, z, @background_color) unless @background_color.transparent? 246 | end 247 | 248 | protected 249 | def draw_border 250 | draw_frame(x, y, width, height, @border_thickness, z, @border_color) if @border_thickness > 0 and not @border_color.transparent? 251 | end 252 | 253 | protected 254 | def draw_foreground 255 | nil 256 | end 257 | 258 | protected 259 | # Should be overridden in children to recalculate the width and height of the element and, if a container 260 | # manage the positions of its children. 261 | def layout 262 | nil 263 | end 264 | 265 | protected 266 | def post_init 267 | recalc 268 | @parent.send :add, self if @parent 269 | end 270 | 271 | public 272 | # Evaluate a block, just like it was a constructor block. 273 | def with(&block) 274 | raise ArgumentError.new("Must pass a block") unless block_given? 275 | case block.arity 276 | when 1 277 | yield self 278 | when 0 279 | instance_methods_eval(&block) 280 | else 281 | raise "block arity must be 0 or 1" 282 | end 283 | end 284 | 285 | protected 286 | # By default, elements do not accept block arguments. 287 | def post_init_block(&block) 288 | raise ArgumentError, "does not accept a block" 289 | end 290 | 291 | public 292 | def to_s 293 | "#{self.class} (#{x}, #{y}) #{width}x#{height}" 294 | end 295 | end 296 | end -------------------------------------------------------------------------------- /lib/fidgit/states/gui_state.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | module Fidgit 4 | class GuiState < Chingu::GameState 5 | # A 1x1 white pixel used for drawing. 6 | PIXEL_IMAGE = 'pixel.png' 7 | 8 | extend Forwardable 9 | 10 | def_delegators :@container, :horizontal, :vertical, :grid 11 | 12 | # The Container that contains all the elements for this GuiState. 13 | # @return [Packer] 14 | attr_reader :container 15 | 16 | # The element with focus. 17 | # @return [Element] 18 | attr_reader :focus 19 | 20 | # The Cursor. 21 | # @return [Cursor] 22 | def cursor; @@cursor; end 23 | 24 | # Sets the focus to a particular element. 25 | def focus=(element) 26 | @focus.publish :blur if @focus and element 27 | @focus = element 28 | end 29 | 30 | # Delay, in ms, before a tool-tip will appear. 31 | def tool_tip_delay 32 | 500 # TODO: configure this. 33 | end 34 | 35 | # Show a file_dialog. 36 | # (see FileDialog#initialize) 37 | def file_dialog(type, options = {}, &block) 38 | FileDialog.new(type, options, &block) 39 | end 40 | 41 | # (see MenuPane#initialize) 42 | def menu(options = {}, &block); MenuPane.new(options, &block); end 43 | 44 | # (see MessageDialog#initialize) 45 | def message(text, options = {}, &block); MessageDialog.new(text, options, &block); end 46 | 47 | # (see Container#clear) 48 | def clear(*args, &block); @container.clear(*args, &block); end 49 | 50 | def initialize 51 | # The container is where the user puts their content. 52 | @container = MainPacker.new 53 | @menu = nil 54 | @last_cursor_pos = [-1, -1] 55 | @mouse_over = nil 56 | 57 | unless defined? @@draw_pixel 58 | media_dir = File.expand_path(File.join(File.dirname(__FILE__), '..', '..', '..', 'media')) 59 | Gosu::Image.autoload_dirs << File.join(media_dir, 'images') 60 | Gosu::Sample.autoload_dirs << File.join(media_dir, 'sounds') 61 | 62 | @@draw_pixel = Gosu::Image.new($window, File.join(media_dir, 'images', PIXEL_IMAGE), true) # Must be tileable or it will blur. 63 | @@cursor = Cursor.new 64 | end 65 | 66 | @min_drag_distance = 0 67 | 68 | super() 69 | 70 | add_inputs( 71 | left_mouse_button: ->{ redirect_mouse_button(:left) }, 72 | holding_left_mouse_button: ->{ redirect_holding_mouse_button(:left) }, 73 | released_left_mouse_button: ->{ redirect_released_mouse_button(:left) }, 74 | 75 | middle_mouse_button: ->{ redirect_mouse_button(:middle) }, 76 | holding_middle_mouse_button: ->{ redirect_holding_mouse_button(:middle) }, 77 | released_middle_mouse_button: ->{ redirect_released_mouse_button(:middle) }, 78 | 79 | right_mouse_button: ->{ redirect_mouse_button(:right) }, 80 | holding_right_mouse_button: ->{ redirect_holding_mouse_button(:right) }, 81 | released_right_mouse_button: ->{ redirect_released_mouse_button(:right) }, 82 | 83 | mouse_wheel_up: :redirect_mouse_wheel_up, 84 | mouse_wheel_down: :redirect_mouse_wheel_down, 85 | 86 | x: -> { if @focus and (holding_any?(:left_control, :right_control)) then @focus.cut end }, 87 | c: -> { if @focus and (holding_any?(:left_control, :right_control)) then @focus.copy end }, 88 | v: -> { if @focus and (holding_any?(:left_control, :right_control)) then @focus.paste end } 89 | ) 90 | end 91 | 92 | # Internationalisation helper. 93 | def t(*args); I18n.t(*args); end 94 | 95 | # Clear the data which is specific to the current $window. 96 | def self.clear 97 | remove_class_variable '@@cursor' if defined? @@cursor 98 | remove_class_variable '@@draw_pixel' if defined? @@draw_pixel 99 | end 100 | 101 | def update 102 | cursor.update 103 | @tool_tip.update if @tool_tip 104 | @menu.update if @menu 105 | @container.update 106 | 107 | # Check menu first, then other elements. 108 | new_mouse_over = @menu.hit_element(cursor.x, cursor.y) if @menu 109 | new_mouse_over = @container.hit_element(cursor.x, cursor.y) unless new_mouse_over 110 | 111 | if new_mouse_over 112 | new_mouse_over.publish :enter if new_mouse_over != @mouse_over 113 | new_mouse_over.publish :hover, cursor.x, cursor.y 114 | end 115 | 116 | @mouse_over.publish :leave if @mouse_over and new_mouse_over != @mouse_over 117 | 118 | @mouse_over = new_mouse_over 119 | 120 | # Check if the mouse has moved, and no menu is shown, so we can show a tooltip. 121 | if [cursor.x, cursor.y] == @last_cursor_pos and (not @menu) 122 | if @mouse_over and (Gosu::milliseconds - @mouse_moved_at) > tool_tip_delay 123 | if text = @mouse_over.tip and not text.empty? 124 | @tool_tip ||= ToolTip.new 125 | @tool_tip.text = text 126 | @tool_tip.x = cursor.x 127 | @tool_tip.y = cursor.y + cursor.height # Place the tip beneath the cursor. 128 | else 129 | @tool_tip = nil 130 | @mouse_moved_at = Gosu::milliseconds 131 | end 132 | end 133 | else 134 | @tool_tip = nil 135 | @mouse_moved_at = Gosu::milliseconds 136 | end 137 | 138 | # The element that grabs input. 139 | @active_element = @dragging_element || @focus || @mouse_over 140 | 141 | @last_cursor_pos = [cursor.x, cursor.y] 142 | 143 | super 144 | end 145 | 146 | def write_tree 147 | puts "=== #{self.class} ===" 148 | indent = " " 149 | @container.write_tree(indent) 150 | @menu.write_tree(indent) if @menu 151 | @tool_tip.write_tree(indent) if @tool_tip 152 | end 153 | 154 | def draw 155 | @container.draw 156 | @menu.draw if @menu 157 | @tool_tip.draw if @tool_tip 158 | cursor.draw 159 | 160 | super 161 | end 162 | 163 | def setup 164 | super 165 | 166 | @tool_tip = nil 167 | @mouse_over = nil # Element the mouse is hovering over. 168 | @mouse_down_on = Hash.new # Element that each button was pressed over. 169 | @mouse_down_pos = Hash.new # Position that each button was pressed down at. 170 | @drag_button = nil 171 | @dragging_element = nil 172 | @focus = nil 173 | @mouse_moved_at = Gosu::milliseconds 174 | 175 | nil 176 | end 177 | 178 | def finalize 179 | unset_mouse_over 180 | 181 | if @focus 182 | @focus.publish :blur 183 | @focus = nil 184 | end 185 | 186 | @tool_tip = nil 187 | 188 | nil 189 | end 190 | 191 | # Called by active elements when they are disabled. 192 | def unset_mouse_over 193 | @mouse_over.publish :leave if @mouse_over 194 | @mouse_over = nil 195 | end 196 | 197 | # Set the menu pane to be displayed. 198 | # 199 | # @param [MenuPane] menu Menu to display. 200 | # @return nil 201 | def show_menu(menu) 202 | hide_menu if @menu 203 | @menu = menu 204 | 205 | nil 206 | end 207 | 208 | # Hides the currently shown menu, if any. 209 | # @return nil 210 | def hide_menu 211 | @menu = nil 212 | 213 | nil 214 | end 215 | 216 | # Flush all pending drawing to the screen. 217 | def flush 218 | $window.flush 219 | end 220 | 221 | # Draw a filled rectangle. 222 | def draw_rect(x, y, width, height, z, color, mode = :default) 223 | @@draw_pixel.draw x, y, z, width, height, color, mode 224 | 225 | nil 226 | end 227 | 228 | # Draw an unfilled rectangle. 229 | def draw_frame(x, y, width, height, thickness, z, color, mode = :default) 230 | draw_rect(x - thickness, y, thickness, height, z, color, mode) # left 231 | draw_rect(x - thickness, y - thickness, width + thickness * 2, thickness, z, color, mode) # top (full) 232 | draw_rect(x + width, y, thickness, height, z, color, mode) # right 233 | draw_rect(x - thickness, y + height, width + thickness * 2, thickness, z, color, mode) # bottom (full) 234 | 235 | nil 236 | end 237 | 238 | def distance(x1, y1, x2, y2) 239 | Gosu.distance(x1, y1, x2, y2) 240 | end 241 | 242 | def show 243 | $window.game_state_manager.push self unless $window.game_state_manager.game_states.include? self 244 | nil 245 | end 246 | 247 | def hide 248 | $window.game_state_manager.pop if $window.game_state_manager.current == self 249 | nil 250 | end 251 | 252 | protected 253 | def redirect_mouse_button(button) 254 | # Ensure that if the user clicks away from a menu, it is automatically closed. 255 | hide_menu unless @menu and @menu == @mouse_over 256 | 257 | # Blur if clicking outside the focused element. 258 | if @focus and @mouse_over != @focus 259 | @focus.publish :blur 260 | @focus = nil 261 | end 262 | 263 | # Publish :left_mouse_button for the element that is clicked. 264 | if @mouse_over 265 | @mouse_down_pos[button] = [cursor.x, cursor.y] 266 | @mouse_down_on[button] = @mouse_over 267 | @mouse_over.publish :"#{button}_mouse_button", *@mouse_down_pos[button] 268 | else 269 | @mouse_down_pos[button] = nil 270 | @mouse_down_on[button] = nil 271 | end 272 | 273 | nil 274 | end 275 | 276 | protected 277 | def redirect_released_mouse_button(button) 278 | # Ensure that if the user clicks away from a menu, it is automatically closed. 279 | hide_menu if @menu and @mouse_over != @menu 280 | 281 | if @mouse_over 282 | @mouse_over.publish :"released_#{button}_mouse_button", cursor.x, cursor.y 283 | @mouse_over.publish :"clicked_#{button}_mouse_button", cursor.x, cursor.y if @mouse_over == @mouse_down_on[button] 284 | end 285 | 286 | if @dragging_element and @drag_button == button 287 | @dragging_element.publish :end_drag, cursor.x, cursor.y, @drag_button, @mouse_over 288 | @dragging_element = nil 289 | @drag_button = nil 290 | end 291 | 292 | @mouse_down_on[button] = nil 293 | @mouse_down_pos[button] = nil 294 | 295 | nil 296 | end 297 | 298 | protected 299 | def redirect_holding_mouse_button(button) 300 | if not @dragging_element and @mouse_down_on[button] and @mouse_down_on[button].drag?(button) and 301 | distance(*@mouse_down_pos[button], cursor.x, cursor.y) > @min_drag_distance 302 | @drag_button = button 303 | @dragging_element = @mouse_down_on[button] 304 | @dragging_element.publish :begin_drag, *@mouse_down_pos[button], :left 305 | end 306 | 307 | if @dragging_element 308 | if @drag_button == button 309 | @dragging_element.publish :update_drag, cursor.x, cursor.y 310 | end 311 | else 312 | @mouse_over.publish :"holding_#{button}_mouse_button", cursor.x, cursor.y if @mouse_over 313 | end 314 | 315 | nil 316 | end 317 | 318 | protected 319 | def redirect_mouse_wheel_up 320 | @active_element.publish :mouse_wheel_up, cursor.x, cursor.y if @active_element 321 | nil 322 | end 323 | 324 | protected 325 | def redirect_mouse_wheel_down 326 | @active_element.publish :mouse_wheel_down, cursor.x, cursor.y if @active_element 327 | nil 328 | end 329 | end 330 | end -------------------------------------------------------------------------------- /lib/fidgit/elements/text_area.rb: -------------------------------------------------------------------------------- 1 | module Fidgit 2 | class TextArea < Element 3 | TAGS_PATTERN = %r%<[a-z](?:=[a-f0-9]+)?>|%i 4 | 5 | # @return [Number] 6 | attr_reader :min_height 7 | # @return [Number] 8 | attr_reader :max_height 9 | 10 | # @return [Number] 11 | attr_reader :line_spacing 12 | 13 | # @param [Boolean] value 14 | # @return [Boolean] 15 | attr_writer :editable 16 | 17 | # @return [String] Text, but stripped of tags. 18 | attr_reader :stripped_text 19 | 20 | event :begin_drag 21 | event :update_drag 22 | event :end_drag 23 | 24 | event :changed 25 | event :focus 26 | event :blur 27 | 28 | def drag?(button); button == :left; end 29 | 30 | # Is the area editable? This will always be false if the Element is disabled. 31 | def editable? 32 | enabled? and @editable 33 | end 34 | 35 | # Text within the element. 36 | # @return [String] 37 | def text 38 | @text_input.text.force_encoding '-8' 39 | end 40 | 41 | # Returns the range of the selection. 42 | # 43 | # @return [Range] 44 | def selection_range 45 | from = [@text_input.selection_start, caret_position].min 46 | to = [@text_input.selection_start, caret_position].max 47 | 48 | (from...to) 49 | end 50 | 51 | # Returns the text within the selection. 52 | # 53 | # @return [String] 54 | def selection_text 55 | stripped_text[selection_range] 56 | end 57 | 58 | # Sets the text within the selection. The caret will be placed at the end of the inserted text. 59 | # 60 | # @param [String] str Text to insert. 61 | # @return [String] The new selection text. 62 | def selection_text=(str) 63 | from = [@text_input.selection_start, @text_input.caret_pos].min 64 | to = [@text_input.selection_start, @text_input.caret_pos].max 65 | new_length = str.length 66 | 67 | full_text = text 68 | tags_length_before = (0...from).inject(0) {|m, i| m + @tags[i].length } 69 | tags_length_inside = (from...to).inject(0) {|m, i| m + @tags[i].length } 70 | range = (selection_range.first + tags_length_before)...(selection_range.last + tags_length_before + tags_length_inside) 71 | full_text[range] = str.encode('-8', undef: :replace) 72 | @text_input.text = full_text 73 | 74 | @text_input.selection_start = @text_input.caret_pos = from + new_length 75 | 76 | recalc # This may roll back the text if it is too long! 77 | 78 | publish :changed, self.text 79 | 80 | str 81 | end 82 | 83 | # Position of the caret. 84 | # 85 | # @return [Integer] Number in range 0..text.length 86 | def caret_position 87 | @text_input.caret_pos 88 | end 89 | 90 | # Position of the caret. 91 | # 92 | # @param [Integer] pos Position of caret in the text. 93 | # @return [Integer] New position of caret. 94 | def caret_position=(position) 95 | raise ArgumentError, "Caret position must be in the range 0 to the length of the text (inclusive)" unless position.between?(0, stripped_text.length) 96 | @text_input.caret_pos = position 97 | 98 | position 99 | end 100 | 101 | # Sets caret to the end of the text. 102 | # 103 | # @param [String] text 104 | # @return [String] Current string (may be the old one if passed on was too long). 105 | def text=(text) 106 | @text_input.text = text 107 | recalc # This may roll back the text if it is too long. 108 | publish :changed, self.text 109 | self.text 110 | end 111 | 112 | 113 | # @param (see Element#initialize) 114 | # 115 | # @option (see Element#initialize) 116 | # @option options [String] :text ("") 117 | # @option options [Integer] :height Sets both min and max height at once. 118 | # @option options [Integer] :min_height 119 | # @option options [Integer] :max_height (Infinite) 120 | # @option options [Number] :line_spacing (0) 121 | # @option options [Boolean] :editable (true) 122 | def initialize(options = {}, &block) 123 | options = { 124 | text: '', 125 | max_height: Float::INFINITY, 126 | line_spacing: default(:line_spacing), 127 | background_color: default(:background_color), 128 | border_color: default(:border_color), 129 | caret_color: default(:caret_color), 130 | caret_period: default(:caret_period), 131 | focused_border_color: default(:focused, :border_color), 132 | selection_color: default(:selection_color), 133 | editable: true, 134 | }.merge! options 135 | 136 | @line_spacing = options[:line_spacing] 137 | @caret_color = options[:caret_color].dup 138 | @caret_period = options[:caret_period] 139 | @focused_border_color = options[:focused_border_color].dup 140 | @selection_color = options[:selection_color].dup 141 | @editable = options[:editable] 142 | 143 | @lines = [''] # List of lines of wrapped text. 144 | @caret_positions = [[0, 0]] # [x, y] of each position the caret can be in. 145 | @char_widths = [] # Width of each character in the text. 146 | @text_input = Gosu::TextInput.new 147 | @old_text = '' 148 | @old_caret_position = 0 149 | @old_selection_start = 0 150 | @tags = Hash.new("") # Hash of tags embedded in the text. 151 | 152 | @text_input.text = options[:text].dup 153 | @stripped_text = '' # Text stripped of xml tags. 154 | 155 | super(options) 156 | 157 | min_height = padding_left + padding_right + font.height 158 | if options[:height] 159 | @max_height = @min_height = [options[:height], min_height].max 160 | else 161 | @max_height = [options[:max_height], min_height].max 162 | @min_height = options[:min_height] ? [options[:min_height], min_height].max : min_height 163 | end 164 | rect.height = [padding_left + padding_right + font.height, @min_height].max 165 | 166 | subscribe :left_mouse_button, method(:click_in_text) 167 | subscribe :right_mouse_button, method(:click_in_text) 168 | 169 | # Handle dragging. 170 | subscribe :begin_drag do |sender, x, y| 171 | # Store position of the handle when it starts to drag. 172 | @drag_start_pos = [x - self.x, y - self.y] 173 | end 174 | 175 | subscribe :update_drag do |sender, x, y| 176 | index = text_index_at_position(x, y) 177 | self.caret_position = [index, @stripped_text.length].min if index 178 | end 179 | 180 | subscribe :end_drag do 181 | @drag_start_pos = nil 182 | end 183 | end 184 | 185 | # @return [nil] 186 | def click_in_text(sender, x, y) 187 | publish :focus unless focused? 188 | 189 | # Move caret to position the user clicks on. 190 | index = text_index_at_position x, y 191 | self.caret_position = @text_input.selection_start = [index, @stripped_text.length].min if index 192 | 193 | nil 194 | end 195 | 196 | # Does the element have the focus? 197 | def focused?; @focused; end 198 | 199 | # @return [nil] 200 | def focus(sender) 201 | @focused = true 202 | $window.current_game_state.focus = self 203 | $window.text_input = @text_input 204 | 205 | nil 206 | end 207 | 208 | # @return [nil] 209 | def blur(sender) 210 | if focused? 211 | $window.current_game_state.focus = nil 212 | $window.text_input = nil 213 | end 214 | 215 | @focused = false 216 | 217 | nil 218 | end 219 | 220 | # Draw the text area. 221 | # 222 | # @return [nil] 223 | def draw_foreground 224 | # Always roll back changes made by the user unless the text is editable. 225 | if editable? or text == @old_text 226 | recalc if focused? # Workaround for Windows draw/update bug. 227 | @old_caret_position = caret_position 228 | @old_selection_start = @text_input.selection_start 229 | else 230 | roll_back 231 | end 232 | 233 | if caret_position > stripped_text.length 234 | self.caret_position = stripped_text.length 235 | end 236 | 237 | if @text_input.selection_start >= stripped_text.length 238 | @text_input.selection_start = stripped_text.length 239 | end 240 | 241 | # Draw the selection. 242 | selection_range.each do |pos| 243 | char_x, char_y = @caret_positions[pos] 244 | char_width = @char_widths[pos] 245 | left, top = x + padding_left + char_x, y + padding_top + char_y 246 | draw_rect left, top, char_width, font.height, z, @selection_color 247 | end 248 | 249 | # Draw text. 250 | @lines.each_with_index do |line, index| 251 | font.draw(line, x + padding_left, y + padding_top + y_at_line(index), z) 252 | end 253 | 254 | # Draw the caret. 255 | if focused? and ((Gosu::milliseconds / @caret_period) % 2 == 0) 256 | caret_x, caret_y = @caret_positions[caret_position] 257 | left, top = x + padding_left + caret_x, y + padding_top + caret_y 258 | draw_rect left, top, 1, font.height, z, @caret_color 259 | end 260 | end 261 | 262 | protected 263 | # Index of character in reference to the displayable text. 264 | def text_index_at_position(x, y) 265 | # Move caret to position the user clicks on. 266 | mouse_x, mouse_y = x - (self.x + padding_left), y - (self.y + padding_top) 267 | @char_widths.each.with_index do |width, i| 268 | char_x, char_y = @caret_positions[i] 269 | if mouse_x.between?(char_x, char_x + width) and mouse_y.between?(char_y, char_y + font.height) 270 | return i 271 | end 272 | end 273 | 274 | nil # Didn't find a character at that position. 275 | end 276 | 277 | # y position of the 278 | protected 279 | def y_at_line(lines_number) 280 | lines_number * (font.height + line_spacing) 281 | end 282 | 283 | 284 | protected 285 | # Helper for #recalc 286 | # @return [Integer] 287 | def position_letters_in_word(word, line_width) 288 | # Strip tags before measuring word. 289 | word.gsub(ENTITIES_AND_TAGS_PATTERN, '').each_char do |c| 290 | char_width = font.text_width(c) 291 | line_width += char_width 292 | @caret_positions.push [line_width, y_at_line(@lines.size)] 293 | @char_widths.push char_width 294 | end 295 | 296 | line_width 297 | end 298 | 299 | protected 300 | # @return [nil] 301 | def layout 302 | # Don't need to re-layout if the text hasn't changed. 303 | return if @old_text == text 304 | 305 | publish :changed, self.text 306 | 307 | # Save these in case we are too long. 308 | old_lines = @lines 309 | old_caret_positions = @caret_positions 310 | old_char_widths = @char_widths 311 | 312 | @lines = [] 313 | @caret_positions = [[0, 0]] # Position 0 is before the first character. 314 | @char_widths = [] 315 | 316 | space_width = font.text_width ' ' 317 | max_width = width - padding_left - padding_right - space_width 318 | 319 | line = '' 320 | line_width = 0 321 | word = '' 322 | word_width = 0 323 | 324 | strip_tags 325 | 326 | stripped_text.each_char.with_index do |char, i| 327 | tag = @tags[i] 328 | 329 | case char 330 | when "\n" 331 | char_width = 0 332 | else 333 | char_width = font.text_width char 334 | end 335 | 336 | overall_width = line_width + (line_width == 0 ? 0 : space_width) + word_width + char_width 337 | if overall_width > max_width and not (char == ' ' and not word.empty?) 338 | if line.empty? 339 | # The current word is longer than the whole word, so split it. 340 | # Go back and set all the character positions we have. 341 | position_letters_in_word(word, line_width) 342 | 343 | # Push as much of the current word as possible as a complete line. 344 | @lines.push word + tag + (char == ' ' ? '' : '-') 345 | line_width = font.text_width(word) 346 | 347 | word = '' 348 | word_width = 0 349 | else 350 | 351 | # Adding the current word would be too wide, so add the current line and start a new one. 352 | @lines.push line 353 | line = '' 354 | end 355 | 356 | widen_last_character line_width 357 | line_width = 0 358 | end 359 | 360 | case char 361 | when "\n" 362 | # A new-line ends the word and puts it on the line. 363 | line += word + tag 364 | line_width = position_letters_in_word(word, line_width) 365 | @caret_positions.push [line_width, y_at_line(@lines.size)] 366 | @char_widths.push 0 367 | widen_last_character line_width 368 | @lines.push line 369 | word = '' 370 | word_width = 0 371 | line = '' 372 | line_width = 0 373 | 374 | when ' ' 375 | # A space ends a word and puts it on the line. 376 | line += word + tag + char 377 | line_width = position_letters_in_word(word, line_width) 378 | line_width += space_width 379 | @caret_positions.push [line_width, y_at_line(@lines.size)] 380 | @char_widths.push space_width 381 | 382 | word = '' 383 | word_width = 0 384 | 385 | else 386 | # If there was a previous line and we start a new line, put the caret pos on the current line. 387 | if line.empty? 388 | @caret_positions[-1] = [0, y_at_line(@lines.size)] 389 | end 390 | 391 | # Start building up a new word. 392 | word += tag + char 393 | word_width += char_width 394 | end 395 | end 396 | 397 | # Add any remaining word on the last line. 398 | unless word.empty? 399 | line_width = position_letters_in_word(word, line_width) 400 | @char_widths << width - line_width - padding_left - padding_right 401 | line += word 402 | end 403 | 404 | @lines.push line if @lines.empty? or not line.empty? 405 | 406 | # Roll back if the height is too long. 407 | new_height = padding_left + padding_right + y_at_line(@lines.size) 408 | if new_height <= max_height 409 | @old_text = text 410 | rect.height = [new_height, @min_height].max 411 | @old_caret_position = caret_position 412 | @old_selection_start = @text_input.selection_start 413 | else 414 | roll_back 415 | end 416 | 417 | nil 418 | end 419 | 420 | protected 421 | def roll_back 422 | @text_input.text = @old_text 423 | self.caret_position = @old_caret_position 424 | @text_input.selection_start = @old_selection_start 425 | recalc 426 | end 427 | 428 | protected 429 | def widen_last_character(line_width) 430 | @char_widths[-1] += (width - line_width - padding_left - padding_right) unless @char_widths.empty? 431 | end 432 | 433 | public 434 | # Cut the selection and copy it to the clipboard. 435 | def cut 436 | str = selection_text 437 | unless str.empty? 438 | Clipboard.copy str 439 | self.selection_text = '' if editable? 440 | end 441 | end 442 | 443 | public 444 | # Copy the selection to the clipboard. 445 | def copy 446 | str = selection_text 447 | Clipboard.copy str unless str.empty? 448 | end 449 | 450 | public 451 | # Paste the contents of the clipboard into the TextArea. 452 | def paste 453 | self.selection_text = Clipboard.paste 454 | end 455 | 456 | protected 457 | # Use block as an event handler. 458 | def post_init_block(&block) 459 | subscribe :changed, &block 460 | end 461 | 462 | protected 463 | # Strip XML tags ("") 464 | def strip_tags 465 | tags_length = 0 466 | @tags = Hash.new('') 467 | 468 | @stripped_text = text.gsub(TAGS_PATTERN) do |tag| 469 | pos = $`.length - tags_length 470 | tags_length += tag.length 471 | @tags[pos] += tag 472 | end 473 | end 474 | end 475 | end --------------------------------------------------------------------------------